ticketwright
AGENTS.md4 starsChanged 18 days agoMITView on GitHub
(.claude/CLAUDE.md is a one-line @../AGENTS.md import so Claude Code loads these rules. It lives in .claude/ because the repo root is also the plugin root, where the plugin directory flags a CLAUDE.md.) This is the development repo for Ticketwright itself — the tool-agnostic AI layer for ticket-driven work repos. Do not confuse it with a repo that uses Ticketwright: here you are editing the plugin/kit, not working tickets. It's a public OSS repo on the personal kyle-chalmers GitHub account (see…
- Installs packages
- Commits and pushes
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,700 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
The file
We split this AGENTS.md into the rules it gives an agent
3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 32
The record has two readers: Every artifact is consumed by a person *and* by an agent.
- Distinctive: line 41
Reuse beats rebuild, here too: Before adding a mechanism, check whether the kit already has one doing the job.
- Very specific: line 256
This is a PUBLIC kit — no org-specific content: No organization names, real ticket IDs, industry vocabulary, secrets, or PII in tracked …
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) and other coding agents when working in
this repository. (.claude/CLAUDE.md is a one-line @../AGENTS.md import so Claude Code loads these
rules. It lives in .claude/ because the repo root is also the plugin root, where the plugin directory
flags a CLAUDE.md.)
This is the development repo for Ticketwright itself — the tool-agnostic AI layer for
ticket-driven work repos. Do not confuse it with a repo that uses Ticketwright: here you are
editing the plugin/kit, not working tickets. It's a public OSS repo on the personal kyle-chalmers
GitHub account (see PAT routing in ~/.claude/CLAUDE.md), shipped two ways from one source tree:
- a Claude Code plugin (
.claude-plugin/), and - a PyPI package (
ticketwright/) whose wheel bundles the kit underticketwright/_kit/.
Mission and vision: the tiebreakers
Mission. Ticketwright empowers a team to do a high volume of analysis without letting quality slide, on whatever tools they already use.
Vision. Any new or experienced member can pick up any analysis and be productive the same day, because the team's past work is written down and organized, and AI can trace it.
These are not decoration. Cite them in review. When a change to this kit is ambiguous, they decide it:
- The lifecycle is fixed; the tools are not. Supporting a new tool means a new adapter or a new named target inside an existing seam, never a tool name inside a skill. If a change makes a skill know which vendor it is talking to, it is the wrong change.
- Volume is only worth having if the quality holds. The mission is a conjunction, and the second half is the one under pressure. A change that raises throughput by making a check skippable is a regression, however much faster it feels.
- A phase that leaves no reusable record is not finished. The enrich step, the QC verdict, the written assumptions and the object index are the product, not overhead on the way to it.
- The record has two readers. Every artifact is consumed by a person and by an agent. A feature only one of them can use is half-built, and which is which should be stated honestly rather than implied.
- "On whatever tools they already use" includes the agent. Logic belongs in harness-neutral CLIs
under
tools/; hooks and skills are presentation. Any behavior whose correctness depends on a Claude-specific lifecycle callback is broken for most of the field. - Safety gates get more visible, never more convenient. Anything making an external post or a destructive write easier must make it more legible at the same time. Where a runtime cannot enforce a policy mechanically, say so plainly instead of implying parity.
- Reuse beats rebuild, here too. Before adding a mechanism, check whether the kit already has one
doing the job. The per-user viewer config, the multi-target seam shape,
dev_key:/container_key:frontmatter, and recall's--ownerdisambiguation are existing answers to extend, not duplicate.
Commands
bash tools/selftest.sh # THE gate — kit integrity + hook/engine unit tests. Run before and after any change.
bash tools/selftest.sh 6 6b # only those sections (most take under 3s alone) while you work; the full run still gates
bash tools/selftest.sh --list # every section's id and title
bash tools/selftest.sh --jobs 8 # the full suite, 8 sections at a time (about 50s vs about 255s serial on an 8-core Mac); same totals as a serial run
python3 tools/build_ticket_index.py --check # staleness gate: INDEX.md/OBJECTS.md + the graph nodes must be in sync (CI enforces)
bash tools/verify_stack.sh <stack.yaml> --dry-run # prove every seam resolves to an adapter (no network)
claude plugin validate .claude-plugin/marketplace.json --strict # marketplace schema ONLY
claude plugin validate .claude --strict # skill + agent CONTENT (the only target that reads it)
claude plugin validate --profile directory --strict . # the plugin directory's checks (Claude Code 2.1.305+)
python3 -m build # build the sdist + wheel (needs the `dev` extra: pip install -e ".[dev]")claude plugin validate . does NOT validate this plugin's content. The repo is both a plugin and
a single-plugin marketplace, and a root target resolves to marketplace.json in preference to
plugin.json. Pointing it at plugin.json checks the manifest and that its component paths exist,
but still reads no skill or agent content: the validator reads content only in the default
skills/ and agents/ folders, and plugin.json points into .claude/ instead. A garbage
SKILL.md passes both, exit 0. Point it at .claude to read the content; CI does all three, with a
negative control (the plugin-validate job), and runs the directory profile
(the plugin-directory job) on both the committed tree and the trimmed tree the listing serves
(.github/scripts/directory_tree.sh, which publish.yml uses to build the directory branch;
selftest section 60b). Components are real files under .claude/, never root symlinks: the
plugin directory blocks a symlinked component. A new .claude/agents/*.md must also be listed
under agents in plugin.json, because that key replaces the default scan and takes files only;
selftest section 15 fails on an unlisted one. Locally, bash tools/selftest.sh is the gate that
matters.
selftest.sh is authoritative and self-contained: read-only, no network, no credentials, bash 3.2-safe.
"3.2-safe" is enforced, not aspirational: stock macOS /bin/bash (3.2.57) mis-parses a heredoc nested
inside $( ) — the whole file dies at parse time while earlier sections' ✓ output scrolls past — so
capture heredoc output via a temp file (python3 - <<'PY' >"$TMP/x.out" … then var="$(cat …)"),
never var="$(python3 - <<'PY' …)". Section 52 lints the file for the forbidden shape and CI runs the
full suite under /bin/bash on macOS (this shipped broken three releases in a row before the guard).
"No credentials" is guarded the same way. verify_stack.sh evals each seam's verify: command,
so running it without --dry-run executes whatever the fixture names — one such site quietly fired
snow connection test (a Duo MFA push that blocks forever), acli and gh at every contributor with
those CLIs, invisible to CI, which has none. Never call verify_stack.sh for real directly: use
safe_verify_stack (VS_PATH= to substitute a sandbox), which runs it under a hermetic PATH built
from a small utility allowlist, so a bare snow/acli/gh in a fixture's verify: does not resolve.
Know the limit as well as the guarantee: it shadows CLIs by NAME on PATH, so an absolute path, a
verify: that resets PATH, or a CLI nobody listed would still run — and routing through the helper
is a convention the shell cannot enforce. Section 53(a) matches three invocation shapes (a bash
prefix, an sh prefix, and the executable in bare command position — verify_stack.sh is +x, so
that last one needs no interpreter), but it matches TEXT, so indirection through a variable or eval
slips past it; 53 also pins that the allowlist grows no tracker/warehouse
CLI (git is listed deliberately — kit_paths shells out to rev-parse).
selftest.sh needs yq and python3 only. It is organized into numbered sections, plus lettered sub-sections such as 21b (config resolution, adapter
verb coverage, tool-name isolation, every hook, the index/recall engines, the render gate, the Obsidian
graph layer, version sync, …); --list prints them. When you change behavior, add or update the matching numbered section —
a green selftest is the contract this kit ships on. It must print 0 failed.
Every section must pass on its own (bash tools/selftest.sh <id>), because that is how a filtered
run works: it runs the setup block plus the chosen sections, cut out of the file by their
hdr "<id> · …" lines. So a new section's hdr line sits at column 0 with an id no other section
uses, and anything a second section needs (a helper, a variable, a fixture in $TMP) goes in the
setup block near the top ("fixtures and helpers that more than one section uses"), never in the
section that first built it. Anything that starts processes is a fixture_* builder that does its
work on the first call. Checks that need the whole suite's count live in the footer and run only
on a full run. --jobs relies on all of this: it runs each section as its own copy, prints the
output in file order, and runs the footer once with the summed counts. CI's macOS bash 3.2 job runs
--jobs (it was the slowest job); the Linux job stays serial as the exact reference.
There is no separate lint/format step; correctness is enforced by selftest assertions.
Architecture — the essential model
Full contributor map: docs/architecture.md. The load-bearing ideas:
Seams + adapters + a verb contract. A seam is a tool slot — and "tool slot" is the
user-facing name: all user-facing prose (README, setup questions, rendered docs) says "tool slot",
while seam stays the internal word (the seams: config key, adapter frontmatter, selftest, and
contributor docs like this one). A seam is one of: tracker, warehouse, chat,
docstore, meetings, vcs. Three layers wire it:
.claude/config/stack.yaml— names which tool fills each seam, plus project facts and the 11policies. Schema in.claude/config/stack.schema.md; seven worked examples ship (stack.yaml+stack.example.*.yaml) and selftest runs skills against all of them.adapters/<seam>/<tool>.md— maps the abstract verbs (fetch_ticket,query,draft,backup,commit, …) to that tool's concrete commands. Contract + per-seam verb counts inadapters/README.md.tools/verify_stack.sh— pings each seam's read-onlyverifybefore use.
Messages a person reads follow one writing rule. Lead with what happened and what to do, in
plain words; put anything technical (a policy key, a script path, an exception) after Details:.
Talk to the person, never to the model, in a field the person sees (an ask reason, a
systemMessage). Keep every caveat about what a guard cannot see, in plain words. Protocol tokens
stay exact: ship unreviewed, verdict: values, command names, record keys. Selftest section 59
checks one part of this: the plain part before Details: is non-empty and contains none of the
terms in tests/plain_language/terms.txt (outside code spans), and it pins the caveats of specific
named messages. Contributors follow the rest by hand. Say-this text in a skill sits between
<!-- ticketwright:user-facing:begin --> and <!-- ticketwright:user-facing:end -->.
THE GOLDEN RULE: skills/commands are tool-agnostic. Anything in .claude/skills/ (or
.claude/commands/) is written once against verbs and must never invoke a concrete tool — no
acli/snow/gh/psql/mcp__* calls in orchestration. Tool specifics live only in adapters +
stack.yaml. Selftest section 3 greps for leaks and fails on one. Two sanctioned exceptions: the
CLI-detection probe in setup, and the self-lint line in skillify. Naming a tool in
illustrative prose is fine; calling one is not. If a new tool requires a skill edit, the abstraction
is leaking — flag it.
Adding a tool (the common contribution): copy the closest adapter in the same seam, keep the
frontmatter (seam, tool, transport, requires, auth), implement every verb section the
seam's contract lists, point a stack.yaml seam at it with a read-only verify, then run
verify_stack.sh + selftest.sh. No skill edits.
The kit's scripts live in tools/; the launcher that finds them lives in the PROJECT at bin/tw.
tools/kit_paths.py is the one authority on where the kit is, where the project is, and what the
running runtime can do (--kit/--project/--runtime/--json/--scripts-dir, no Claude variable
required); tools/tw is the launcher that finds it. The kit ships no top-level bin/: claude.ai and
Cowork refuse to install a plugin with one, and the plugin directory holds it (LABS-209). Every skill,
reference file, template and generated skill calls the PROJECT's copy exactly one way:
bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" <script> [args]No Claude variable appears in that line, deliberately (tiebreaker 5). /setup step 5b copies
tools/tw + tools/kit_paths.py into <project>/bin/ and commits them; kit_paths.py then resolves a
plugin install by reading ~/.claude/plugins/installed_plugins.json — the exact installed kit for
THIS project, no environment variable, no glob, no guess — and bin/tw's project-shim tier
delegates to it.
The project's copy is stamped and refreshable, and reads both kit layouts (LABS-209). The
project launcher stays at <project>/bin/tw (that folder is the user's repo, not the plugin), so the
line above never changes, and it reads a kit whose scripts sit in tools/ or, for an older kit, in
bin/. In this dev repo there is no bin/tw: run kit scripts directly (bash tools/selftest.sh).
Both copied files carry # tw-launcher: N (LAUNCHER_STAMP in kit_paths.py).
refresh_launcher.py --write replaces an older copy, /setup's preflight runs it every time, and
kit_paths.py, whoami.py, the session banner and the doctor's project_launcher check say when a
copy is out of date. Bump the stamp whenever tw or kit_paths.py changes in a way an older copy
cannot handle, or nothing will replace the copies already committed in users' repos. Selftest 31b
runs the pre-stamp copy (tests/launcher/v1/) against fixture kits in both layouts.
Two earlier forms were bugs; do not reintroduce either. ${CLAUDE_PLUGIN_ROOT:-...} shipped for
months and broke every documented bin/ command on plugin installs: the runtime substitutes only
the EXACT token ${CLAUDE_PLUGIN_ROOT}, so a token carrying a :- default never matched and
survived into the shell, where the variable is genuinely unset — ${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}
then produced /bin/… and ${CLAUDE_PLUGIN_ROOT:-$(git …)} produced <project>/bin/…, which a
plugin install never creates. The dev repo never noticed because its fallback lands on the repo
root, where bin/ really does live. The replacement TW_KIT="${CLAUDE_PLUGIN_ROOT}"; … fixed only
half the surface, which is subtler and worth stating: substitution fires ONLY in a SKILL.md body
injected at skill launch — never in a file opened with Read. So it worked in the seven SKILL.md
bodies and did nothing at all in references/ files, templates, or the rendered AGENTS.md, which
is where /setup's scaffolding and all of /refresh index actually live. That is why the answer is
a project-owned launcher rather than a cleverer spelling of the token.
.claude/skills/setup/SKILL.md is the one exemption, and only in its own body: it is the
bootstrapper that installs the launcher, so it cannot depend on its own output. Its body is
substituted, so it may use TW_KIT="${CLAUDE_PLUGIN_ROOT}"; … — but setup/'s reference files
(scaffold.md, teammate.md, voice.md) are read, not injected, and must use the project form
like everything else. Runtime capabilities live in adapters/runtime/*.md frontmatter, with the
sourced evidence in docs/runtimes.md.
Config is three tiers behind one resolver. .claude/config/stack.yaml is TEAM config
(committed); people/<id>.yaml is PERSON config (committed, portable — a cross-repo copy under
$XDG_CONFIG_HOME/ticketwright/people/ supplies defaults and the in-repo file overrides it key by
key); .claude/config/connections.local.yaml is PERSON+MACHINE config (gitignored). tools/effective_config.py
is the single authority merging them, and tools/_yamlite.py is the one YAML reader (an explicit
supported subset, stdlib-only, failing loudly rather than misreading). Every config consumer goes
through that stack. Three honest qualifications, each deliberate: several consumers keep their
pre-resolver reader as a FALLBACK so a config outside the supported subset still yields a banner or a
catalog rather than a hard failure; tools/whoami.py — the kit's SINGLE identity resolver (tier-3
person: → $TICKETWRIGHT_PERSON → the enumerated identity map; statuses
resolved/miss/ambiguous/conflict, never a guess; tools/resolve_user.py is a thin voice-mapping shim
over it) — reads people/*.yaml with _yamlite directly rather than via the resolver, because the
resolver asks IT who the person is and the dependency cannot run both ways; and the two hooks below
never call the resolver at all. The scope rule is enforced in code: tier 3 may
only set keys the adapter declares in its user_keys: frontmatter, never anything that selects data
(catalog, schema, warehouse_id, …) and never policies: — those are REJECTED, not ignored.
Two deliberate exceptions: .claude/hooks/db_write_guard.py and .claude/hooks/_stack.py read
the policy IN-PROCESS and must stay that way — see the comment in _stack.py before "finishing" the
migration.
Deterministic engines, no vector store. Catalog rendering and prior-art recall are plain stdlib
Python the model calls, not prose it approximates. Recall is lexical + structural (object match ×4,
tag ×3, cross-ref +5, keyword ×1, IDF down-weighting, recency tiebreak). Engines:
build_ticket_index.py (renders tickets/INDEX.md + OBJECTS.md + the Obsidian graph layer),
recall.py, ingest_index_records.py, enrich_ticket.py, summarize_hits.py. Only the last two run a
model, through the shared model_call.py.
Hooks are the only Claude-Code-specific layer (declared in .claude-plugin/plugin.json,
mirrored into .claude/settings.json for non-plugin installs). All are Python stdlib-only, make no
network calls, never write outside the repo, and fail open — a hook error never blocks a session;
a guard only ever adds a confirmation. Each is repo-gated (zero output outside a configured repo):
db_write_guard.py(PreToolUse/Bash) — asks before destructive warehouse SQL, including SQL hidden in a-ffile or stdin redirect; read-only SELECT/DESCRIBE/SHOW pass through.review_verdict_guard.py(PreToolUse/Bash) — asks beforegit push/ a PR opens or merges for a ticket that has deliverables but no APPROVE verdict on file;tools/review_verdict.pyis the classifier the skills call too (highest-numberedqc_queries/<n>_review_verdict.md,verdict:line).source_material_guard.py(PreToolUse/Bash): asks before a raw meeting transcript in a ticket'ssource_materials/is staged for commit or copied into a docstore backup.regenerate_ticket_index.py(PostToolUse/Write·Edit) — re-renders the index when a ticket changes.session_context.py+ticket_index_context.py(SessionStart) — prime the stack + catalog banner.
Hard constraints
- Zero runtime dependencies. The package is stdlib-only by design (
dependencies = []). Do not add a runtime dep. Tooling for the kit is stdlib Python +yq/jq+ standard CLIs. - Version has ONE source of truth:
ticketwright/__init__.py.pyproject.tomlreads it dynamically;.claude-plugin/plugin.jsonandmarketplace.jsonmust match it. A release bumps all three in lockstep — selftest section 16 asserts they agree.autoUpdatere-installs teammates only when the version moves, so day-to-day commits tomainmust NOT bump it; version bumps belong to tagged release commits. - The repo root is the source; the wheel bundles copies.
pyproject.tomlforce-includestools/,.claude/*,adapters/,templates/intoticketwright/_kit/. Don't move kit files into the package dir — keep the layout the plugin andcp -r/ticketwright initinstalls expect. Scripts and hooks must resolve sibling files from their own kit dir (__file__/CLAUDE_PLUGIN_ROOT), never the project root (selftest section 20 guards this — it's a class of real install bugs). - This is a PUBLIC kit — no org-specific content. No organization names, real ticket IDs,
industry vocabulary, secrets, or PII in tracked files.
tickets/index_data.jsonis a per-install private store (gitignored); anything tracked must be fixture-only (ENG-/DEMO-/TEST-/SAMPLE-). Selftest sections 13, 14, and 20 enforce this. - The
AGENTS.md/CLAUDE.mdundertemplates/are user-repo scaffolds, rendered bytools/render.shfrom.tmplfiles — not this repo's own config. Don't hand-edit rendered output; edit the.tmpl.templates/CLAUDE.md.tmplmust stay exactly one line:@AGENTS.md.
Conventions
- Conventional-commit titles (
feat:/fix:/docs:…). Updatedocs/andREADME.mdwhen behavior changes; keepCHANGELOG.mdcurrent for anything user-facing. - The README is the plugin directory's listing text and the PyPI page, so it has a 900-word prose
budget (selftest §58; fences, image lines and comments don't count) and every image is an
absolute raw.githubusercontent.com URL into the repo's docs images folder on
main. Detail goes indocs/, indexed bydocs/README.md. Its## Data and networksection must name everything the plugin runs, sends or fetches: the directory's security scan checks the plugin against it. scripts/readme_media/is maintainer-only tooling for the logo, icon, diagrams, GIF and social preview (KC AI Labs brand tokens in_common.py). It runs throughuv run --with ..., never ships in the wheel, and is not bound by the stdlib-only rule. ItsREADME.mdhas the commands.- Skill/command/adapter frontmatter must be valid, parseable YAML with a
description— a broken flow-node silently drops all metadata when loaded as a plugin (selftest section 14 checks this). - The v1 command names (
/start-ticket,/qc-review, …) were retired in v3; the surface is 7 skills (setup,ticket,build,review,ship,skillify,refresh). Don't reintroduce the old aliases (selftest section 14b fails on them).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

