ticket-workflow-mcp
README.md
# ticket-workflow
A local-first, **per-repo** ticket board and git/PR pipeline, packaged so any repo
can adopt it by adding a dependency plus a little config — with **no coupling** to
any other repo. Each consuming repo owns its own `tickets/` and `events/`.
It ships three pieces:
- **MCP server** (`ticket-workflow-mcp`) — `list_tickets`, `get_ticket`,
`start_ticket`, `create_ticket`, `update_ticket`, `record_review`,
`archive_ticket`, `delete_ticket`. Tickets are markdown files (frontmatter +
body); the board is the filesystem, no database.
Archiving is its own tool rather than an `update_ticket` status: `archived` is
deliberately absent from that tool's status enum, so a ticket can't be retired
by mistyping a field on an ordinary edit, and the tool stays out of any
name-allowlisted agent toolset. It is reversible — `update_ticket` back to
`backlog`, and `list_tickets` with `status: "archived"` to find it again.
`start_ticket` **refuses a ticket that is already `in-progress`**, since
another session may be working it: the error carries the ticket's last
`## Checkpoint` block and the ticket is not modified. Call it again with
`force: true` only when the branch or worktree that checkpoint names is
yours, or its holder is gone — a clean `git status` is not evidence either
way. Resuming your own ticket after a `/clear` therefore needs `force`. The
check is serialized within one MCP server process only, so two sessions (each
with its own server) starting the same free ticket at the same instant can
both succeed. A refused start has still **armed** `guard-worktree`, whose
`PreToolUse` hook runs before the tool, and the refused ticket is recorded in
the session's marker like any other — so the post-merge cleanups below stay
closed until it, too, is merged.
- **Hooks** (`hooks/`) — a `PreToolUse` **guard** (`guard-bash.mjs`) that blocks
whole-tree staging and commits/pushes to `main`; a `PostToolUse` **tracker**
(`track-steps.mjs`) that records pipeline milestones (branch, typecheck, lint,
test, commit, PR) by watching the commands you run — each attributed to the
ticket named by the branch of the repo the command *actually ran in*, so a
`cd` or `--prefix` into another repo is credited to that repo's ticket rather
than the session's, and a directory it cannot resolve records nothing at all.
The events *directory* is still resolved once from the hook's own environment,
never per milestone, so that attribution is end-to-end only on a shared board
(`BOARD_DIR_OVERRIDE`); with a board per repo the row lands in the session's
`events/` under the other repo's ticket id, where nothing joins to it;
and an opt-in `PreToolUse` guard (`guard-ticket.mjs`) that blocks
`create_ticket` so new tickets are authored by a metered local-LLM intake
agent instead of by the model driving the session — except a create whose
`parent` is an open spec ticket (non-null `spec`, not `done`/`archived`),
which is planned slicing work. It reads that parent from the board its own
environment resolves (`BOARD_DIR_OVERRIDE`, else `CLAUDE_PROJECT_DIR`), so on
a central board the hook's wiring must export `BOARD_DIR_OVERRIDE` or the
exception never admits outside the board's repo; and an opt-in `PreToolUse`
guard (`guard-board-writes.mjs`) that refuses an `Edit`/`Write`/`NotebookEdit`
aimed at a board's own ticket or event file, so board data is written through
`update_ticket` rather than edited as text. It blocks on either of two
independent tests — a `tkt-<12 hex>.md` directly inside a `tickets/` directory
(or a `tkt-<12 hex>.jsonl` inside `events/`), which needs no environment and so
catches an absolute write from a session rooted in another repo; or any path
inside the board dirs this package resolves
(`TICKETS_DIR_OVERRIDE`/`EVENTS_DIR_OVERRIDE`, else `BOARD_DIR_OVERRIDE` /
`CLAUDE_PROJECT_DIR` / cwd), which catches a non-ticket name — and the
`tickets/.history/` snapshots — sitting in a real board. Case is folded on both,
because a case-insensitive filesystem makes `Tickets/` the same file. The second
test fires only where the board is **declared** by an explicit override or
**evidenced** by the directory actually holding a `tkt-` file: keying it on the
bare `CLAUDE_PROJECT_DIR` fallback, which is set in every session, refused every
edit into any repo whose `tickets/` is ordinary documentation. Where a session
has called `start_ticket`, `guard-worktree` already refuses writes into a
repository's primary checkout, which covers a board that lives in one — not a
board reached by `BOARD_DIR_OVERRIDE` into a directory that is not a primary
checkout, and not a session that never claimed a ticket, which is what this
guard is for. It guards the **tool**: a shell write (`sed -i`, `>`, a heredoc)
reaches no `PreToolUse` Edit/Write hook and is not covered by this or any other
guard here, and a non-`tkt-`-named file in a board *other* than the one the
environment resolves is reachable by neither test; and a `SessionStart`
**staleness warning**
(`warn-stale-worktree.mjs`) that reports when the session opened in a git
worktree whose `CLAUDE.md` / `AGENTS.md` / `.cursorrules` has since changed on
the base branch — a stale instruction file does not fail, it *instructs*. It
only writes to stdout, never blocks, and stays silent outside a linked
worktree; set `WORKTREE_STALE_THRESHOLD` to tune the commit-distance fallback
(default 15, `0` to always report). Also a `PreToolUse` guard
(`guard-review-target.mjs`) that refuses a `/code-review` with no explicit
target when the session's own repository has no diff to review, because a
wrong-repo review reads exactly like a clean one. And a `PreToolUse` guard
(`guard-subagent-gates.mjs`) that stops a **subagent** merging a PR —
`gh pr merge`, any `gh api` call carrying a method or a parameter (the
implied POST of `-f`/`-F`/`--input` included, so a GraphQL *read* is
refused too), or a verb that can reach one (`workflow run`, `repo sync`,
`alias set`, …) — and from closing a PR, publishing a release, or
editing or deleting a repo. Merge is a human approval gate, which a
subagent has no channel to ask for. Commit, `gh pr create`/`edit`/`ready`,
parameterless `gh api` reads and `gh pr comment` are allowed. Push is
judged by `guard-bash`, which lets a subagent (a payload carrying
`agent_id`) push only its current branch: no tag, no delete, no other
branch, and no `push.followTags`/`push.recurseSubmodules` config.
And a PAIR — `guard-worktree.mjs` with
`guard-worktree-precheck.mjs` — that, once a session has called
`start_ticket`, refuses writes aimed at a repository's **primary** checkout,
so two ticket sessions cannot share one working tree. The guard arms on
`start_ticket` by writing a marker keyed on the session id (under
`~/.claude/state/worktree-guard/`, or `WORKTREE_GUARD_STATE_DIR`); the
precheck is the half you wire on `Edit|Write|NotebookEdit|Bash`, and it costs
one `stat()` in a session that never started a ticket, reaching package code
only once armed — so a broken install cannot wedge editing machine-wide. A
block names the fix (`EnterWorktree`, or `git worktree add` for another
repo), and `git stash` push/pop is refused from **every** checkout, because
`refs/stash` lives in the shared common directory. A session never disarms,
but once **every** ticket it started has a PR that `gh` reports merged into
the default branch of the repo `origin` names (pinned with `-R`, `GH_REPO`
ignored), with no PR for that ticket still open, that repo's primary — on
its default branch — also admits two cleanups: `git pull --ff-only origin
<default>` / `git merge --ff-only origin/<default>` when there are no
tracked modifications, `HEAD` is an ancestor of `origin/<default>`, and no
untracked or **ignored** file sits where the update would write; and
`git checkout -- <file>` / `git restore <file>` for a tracked regular file
whose unfiltered content already equals `origin/<default>`. Ticket `status`
is never consulted, and any `gh` failure keeps the session armed; edits,
commits, branches and other repos stay blocked. Only the 100 most recent
PRs are read, so an older merge reads as unmerged; the check runs before
the command, so a compound command that writes a file and then restores it
in one line is not protected; and sessions armed before this release keep
a single-ticket marker and never reach this state. Wire `guard-ticket`,
`guard-board-writes` and
the `guard-worktree` pair only if you want those policies — the others suit
any consumer.
- **CLI viewer** (`ticket-workflow`) — `list` and `show <id>`, rendering a
ticket's pipeline from the same reducer the web board uses.
The pipeline a ticket flows through:
**Started · Branch · Typecheck · Lint · Tests · Review · Commit · PR · QA · Done.**
Archiving logs an `archived` event, never `done`, since `archive_ticket` also retires abandoned
work. The row's detail names the prior status (`from done`, `from todo`, …), which is how retired
work is told from abandoned. `archiveStaleTickets` archives only `done` tickets, so its rows always
read `from done`. `archived` is an end state rather than a stage, so it is absent from the rendered
pipeline (`PIPELINE_STEPS`) and appears only in the event log.
## Board location
The board root is resolved at runtime as
`BOARD_DIR_OVERRIDE ?? CLAUDE_PROJECT_DIR ?? process.cwd()`, then `tickets/` and
`events/` under it. Claude Code sets `CLAUDE_PROJECT_DIR` for both the MCP server
and the hooks, so both write to the same per-repo board. `TICKETS_DIR_OVERRIDE`
and `EVENTS_DIR_OVERRIDE` take precedence (used by tests).
## Backup-on-write / recovery
`tickets/` is the source of truth and has no built-in history (consumers typically
gitignore it), so before `updateTicket` overwrites a ticket **body** the prior full
file — frontmatter + body — is snapshotted to:
```
<board>/tickets/.history/<id>/<ISO-timestamp>.md
```
Only body-changing updates snapshot; a structured-only edit (status, priority, …)
writes nothing to `.history/`. Successive edits accumulate one snapshot per prior
version, and `list_tickets` ignores `.history/`, so snapshots never surface on the
board.
`deleteTicket` records too, and does it **fail-closed**: it snapshots the ticket's
final state — the raw bytes, so a file whose frontmatter won't parse is still
deletable — and writes a `deleted.json` tombstone naming that snapshot and the
blocker/parent edges its cleanup strips from other tickets. If either write fails,
**the delete is refused and the ticket is left alone.** That is deliberately the
opposite of the update path's posture: a refused edit wedges work in progress, while
a refused delete costs only a retry, and after an unlink there is nothing left to
recover from.
### Restoring
```
ticket-workflow history <id> # snapshots newest-first; works for a DELETED id
ticket-workflow restore <id> --at <snapshot> # roll the body back
ticket-workflow restore <id> --at <snapshot> --full # …and every other writable field
ticket-workflow restore <id> --undelete # recreate a deleted ticket
```
`restore --at` goes through `updateTicket`, so **the pre-restore state is itself
snapshotted** and a restore is undoable in turn. `--full` puts back every writable
field; `created`, `source` and `runId` are set-once and are never forged by a
restore. `--undelete` writes the snapshot's bytes back verbatim, refuses to
overwrite a live ticket, and **prints** the edges the delete stripped rather than
re-linking them — the tickets on the other end may have moved on since.
There is no MCP tool for either verb: restoring is a human, command-line act, so no
agent can perform one unasked.
A restore refuses — writing nothing — on a malformed id, an id with no history, a
snapshot that does not parse, a file that is not a `.md` snapshot (so `--at
deleted.json`, or an orphaned `*.tmp`, cannot be restored as a ticket body), a path
that does not resolve inside `.history/<id>/`, a `.history/<id>` that is itself a
symlink out of the board, `--undelete` on a live id, a plain `--at` restore on a
deleted id, and a restore that would change nothing. **`--undelete` is checked the
same way**: its snapshot name comes from `readdir` and so cannot traverse, but it is
still resolved through the same containment check, because the name can be a symlink.
Containment is what identifies a snapshot as this ticket's, since snapshot
frontmatter carries no id — the directory is the identity.
Two limits worth knowing: the per-id lock is in-process only, so a CLI restore
racing another process's write can still lose it; and `.history/` lives inside
`tickets/`, so none of this survives losing that directory. Update-path
snapshotting also remains best-effort — a failure there is logged but never blocks
the edit, so a write can still land without a backup.
## Corrupt ticket files
A ticket file whose frontmatter won't parse is **skipped**, not fatal — one
hand-edited file must never take the whole board down. But the skip is reported to
the caller, never only to stderr: `listBoard()` returns
`{ tickets, unreadable: [{ file, reason }] }`, and `list_tickets` carries the same
`unreadable` array in its envelope plus a `note` naming the files. `listTickets()`
is the tickets-only shorthand for callers that don't need the report.
This matters because the failure is otherwise invisible: a shorter list looks
exactly like a complete one. `unreadable` is board-wide and is **not** run through
the `status`/`project`/`query` filters — a file that won't parse has no fields to
filter on, so no filter may hide it. The usual cause is a hand-edited unquoted
`title:` containing a colon.
A file whose `status` is not exactly one of the status ids — missing, misspelled,
wrong case, a list — is reported the same way, with `reason: 'invalid status'`,
rather than being read as `backlog`. Status decides the column, so guessing one
would move the ticket in silence. `getTicket` and `start_ticket` refuse it, since
the value may be a mangled `in-progress` that another session holds. Once you have
checked that nobody does, an update that sets `status` repairs it. That is the only
repair: it snapshots the original file byte for byte first and records no status
milestone. `restore --full` refuses such a ticket rather than repairing it, while a
body-only `restore --at` accepts the snapshot a repair left behind, and
`restore --undelete` brings a deleted one back exactly as it was, still unreadable.
An invalid `type` or `priority` still falls back to `task`/`medium`.
## Corrupt event lines
The same rule applies to the JSONL telemetry, for the same reason: a line that
won't parse is skipped rather than fatal, and a shorter pipeline looks exactly like
a ticket with fewer milestones. So `readEvents()` returns
`{ events, skipped, unrecognized }`, and `getTicketEvents()` — the payload behind
`GET /api/tickets/:id/events` and the `get_ticket_events` tool — carries both counts
beside the pipeline it reduced.
**The two counts mean different things, and only one is a problem:**
| field | meaning | act on it? |
|---|---|---|
| `skipped` | structurally unreadable — bad JSON, missing or wrong-typed required keys. History is **lost**. | yes |
| `unrecognized` | well-formed, but names a `step`/`state` this reader's version doesn't know. **Version skew, not damage.** | bump the pin |
They are separate because the `track-steps` hook is installed **once per machine**
while readers are pinned **per repo**. A newer hook writing a step id added after a
consumer's pin is routine; folding that into `skipped` would report every healthy
log as damaged for as long as the pin lagged.
Neither count is logged to stderr — this path is polled (a board re-reads it every
few seconds while a ticket is on screen), so reporting is left to the caller that
decides to surface it.
One deliberate exemption: a non-empty **final** line that won't parse is *not*
counted. `appendEvent` terminates every complete record with `\n`, so an
unterminated tail is a write in flight, and counting it would flap between polls.
Once any later event lands it is no longer last, and it is counted from then on.
> **Breaking in 0.10.0:** `readEvents()` previously returned `TicketEvent[]`. It now
> returns `{ events, skipped, unrecognized }`, and `TicketEventsResponse` gained both
> counts as **required** fields — optional ones would let a consumer default them with
> `?? 0` and report a damaged log as healthy.
## Event sources
Every event line names the writer that appended it in `source`:
| `source` | writer | what its `state` rests on |
|---|---|---|
| `engine` | the service, on a status transition (`started`, `qa`, `done`, `archived`) | its own write |
| `review` | the `record_review` tool | the caller's say-so |
| `web` | a consumer's web UI, e.g. hardpack's review toggle (including its `cleared` un-review) | a human click |
| `hook` | `track-steps`, from Bash command text — including the `review` it derives from a passing commit | the delivered hook event |
| `gate` | `ticket-workflow gate` | the script's own exit code |
A row with no `source` came from a writer that predates the field — not necessarily an old row: the
`track-steps` hook is installed once per machine at its own pin, so a machine whose `~/.claude/tools`
install was never bumped keeps writing sourceless rows. A reader passes a known value through and
drops an unknown or non-string one **without** counting the line as `skipped` or `unrecognized`,
because the milestone itself is still sound. `show` prints it as `via <source>` beside each step.
`source` is a label the writer sets, not an authentication: anything that can append to `events/`
can write any value. `verify`'s trust decision still keys on `outcomeFrom`.
> **Breaking (`tkt-5350b624e3cc`):** `appendEvent()` requires `source` and answers 400 for a value outside
> `EVENT_SOURCES`, writing nothing. A TypeScript caller that omits it no longer compiles; that is
> deliberate, so no writer can go on appending unlabelled rows.
## Unassigned tickets
A ticket with no `project` is absent from every project-filtered view, so a work
queue that selects with `list_tickets({ project })` can never pick it — it is not
mislabelled, it is out of the queue. `list_tickets` therefore reports
`unassigned: [id, …]` in its envelope, plus a `note` naming the ids.
Like `unreadable`, it is board-wide and **not** narrowed by your filters — a
`project` filter would exclude the very tickets being reported, which is the bug
itself.
It covers **open** tickets only. `done` and `archived` are past selection, so an
unassigned one there is not lost work, and at least one such ticket is deliberately
project-less because it spans several repos. A field that flagged those on every
call is a field nobody reads by the second week.
Two more bounds, for the same reason:
- **Empty when the board uses no projects at all.** `project` is optional, and a
single-repo board has nothing to partition — every ticket would be reported, on
every call, with nothing wrong.
- **Capped at 20 ids**, with the true total in `note`. A truncated list must never
read as the whole story.
A project of *whitespace* counts as unassigned: it is stored verbatim while a
caller's blank filter is normalized to "no filter", so no filter value can ever
match it — strictly worse than an absent project, and invisible without this.
Dropping an unresolvable project on the agent write path is deliberate (the intake
model hallucinates project names, and projects are *derived* from ticket values, so
a name that matches nothing is dropped rather than minted). This field is what
reconciles the result afterwards, so the drop does not depend on someone reading a
warning that has scrolled past.
## Consuming it in a repo
Add the dependency (public, pinned by tag):
```jsonc
// package.json
"devDependencies": { "ticket-workflow": "git+https://github.com/mcinerneyjake/ticket-workflow.git#v0.11.0" }
```
Wire the MCP server (`.mcp.json`) and the hooks + allowlist (`.claude/settings.json`);
see a consuming repo's config for the exact shape. Run `npx ticket-workflow show <id>`
to view a ticket's pipeline.
> **Breaking in 0.24.0:** `summarize()` / `summarizeBoard()` no longer return `byType`, and
> `DashboardSummary.byType` and the `TypeCount` type are gone. No consumer read the field, but a
> repo that duplicates `DashboardSummary` for the browser must drop it in the same pin bump or its
> typecheck fails — removal is a compile error there, not a silent one.
### Wiring the hooks from an install
Each hook is available both as a script path and as a subpath import, so a consumer can wire the
installed copy instead of vendoring one that then drifts:
```jsonc
// .claude/settings.json — run the installed file directly
"command": "node node_modules/ticket-workflow/hooks/guard-bash.mjs"
```
```js
// or import it, to set consumer-specific policy before handing over
import { main } from 'ticket-workflow/hooks/guard-bash.mjs';
main();
```
> **Both forms above are fail-open if the install is absent or stale**, and neither is what you want
> for a guard you rely on. `node <path-that-does-not-exist>` exits 1, and only exit **2** blocks — so
> a missing `node_modules` reads as *allow*. The bare `main()` call has the same hole: if the package
> resolves but exports no callable `main`, the throw exits 1, again an allow. Wrap both, as
> [Installing it once per machine](#installing-it-once-per-machine-user-scope) does.
The exported subpaths are exactly the hook files in `hooks/` — no count is given here on purpose,
because the one that used to be said "five" while the map listed six; `hooks/packaging.test.mjs`
asserts the two agree. Importing a hook does **not** run it;
`main()` reads the payload from stdin and ends in `process.exit()`, so it is one hook per process —
which is how Claude Code invokes them anyway (one process per matcher).
### Installing it once per machine (user scope)
The hooks and MCP server can govern *every* repo on a machine by wiring them at user scope
(`~/.claude/settings.json`, `~/.claude.json`) instead of per repo. Install to a stable location —
**not** a working checkout, and not `npm i -g` (`npm root -g` is Node-version-scoped, so the next
upgrade silently relocates it):
```bash
npm i -g npm@11 # this package declares engines.npm; engine-strict makes a stale npm REFUSE
npm install --prefix ~/.claude/tools ticket-workflow@github:<owner>/ticket-workflow#<tag>
```
The npm line is a prerequisite, not a suggestion: the package ships `engine-strict=true`, which a
git-tag install inherits, so an npm below `engines.npm` fails with `EBADENGINE` instead of writing a
lockfile that silently drops `libc` from optional native entries. Node 24.5.0 bundles npm 11.5.1,
which is below the floor. An existing install survives an aborted upgrade — the check throws while
npm is still building the ideal tree — but the upgrade will not proceed until npm is current.
Wiring a checkout is the trap worth naming: hooks are re-read on **every** invocation, so checking
out a branch that edits `guard-bash.mjs` re-arms or dis-arms the machine's guard for every running
session, mid-edit — and `dist/` becomes the MCP server that every newly started session loads.
**Do not point the wiring straight at the installed file either** — same reason as above: a missing
file exits 1, which reads as *allow*. Wire a small launcher that converts "cannot load" into the
right answer for the event.
**Where the launcher lives is load-bearing, and the two constraints pull in opposite directions:**
- **outside `node_modules`** — its whole job is to still exist when the install does not;
- **but still under the install prefix** (`~/.claude/tools/hooks/`, not `~/.claude/hooks/`), because
`import('ticket-workflow/…')` is a *bare specifier*, resolved by walking up from the launcher's own
directory. Put it beside `settings.json` instead and it throws `ERR_MODULE_NOT_FOUND` **with the
install fully present** — which, failing closed, wedges every Bash call on the machine.
```js
// ~/.claude/tools/hooks/run-hook.mjs <hook-name> <closed|open>
const [name, direction] = process.argv.slice(2);
// Validate up front. `direction === 'closed' ? 2 : 0` would make a one-character typo in
// settings.json silently disarm the guard, so an unusable direction blocks.
if (!name || (direction !== 'closed' && direction !== 'open')) {
process.stderr.write('[run-hook] BLOCKED: usage: run-hook.mjs <hook-name> <closed|open>\n');
process.exit(2);
}
try {
const { main } = await import(`ticket-workflow/hooks/${name}.mjs`);
if (typeof main !== 'function') throw new TypeError('no callable main — stale install?');
await main();
} catch (err) {
process.stderr.write(`[${name}] could not run: ${err?.message ?? err?.code ?? 'import failed'}\n`);
process.exit(direction === 'closed' ? 2 : 1);
}
// Every hook here ends in process.exit(); returning instead is a contract violation (a stale pin),
// and falling off the end exits 0 — an allow. Guards must not resolve that permissively.
if (direction === 'closed') process.exit(2);
```
Fail direction is per event. A guard that cannot run must block; a reporter that cannot run has
nothing to block and must not wedge the session — but note it exits **1, not 0**:
| hook | event | cannot **load** → |
|---|---|---|
| `guard-bash` | `PreToolUse` | **closed** (exit 2) |
| `guard-ticket` | `PreToolUse` | **closed** (exit 2) |
| `guard-board-writes` | `PreToolUse` | **closed** (exit 2) |
| `guard-review-target` | `UserPromptExpansion` | **closed** (exit 2) |
| `guard-subagent-gates` | `PreToolUse` | **closed** (exit 2) |
| `guard-worktree` | `PreToolUse` | **closed** (exit 2) |
| `guard-worktree-precheck` | `PreToolUse` | **depends on arming** — see below |
| `warn-stale-worktree` | `SessionStart` | open (exit 1) |
| `track-steps` | `PostToolUse` **and** `PostToolUseFailure` | open (exit 1) |
**Why 1 and not 0.** Exit 0 is *success*, and its stderr is not surfaced; a non-zero, non-2 exit is a
non-blocking *error*, and its stderr **is**. Exiting 0 would therefore make a dead reporter quieter
than no launcher at all — an unlaunched broken hook exits 1 and is at least visible. 1 keeps it
non-blocking and visible.
Two honest limits on that table:
- It describes what happens when a hook cannot **load**. It is not a claim about each hook's internal
behaviour: `guard-bash`, once loaded, fails **closed** on a payload it cannot **parse**
(`tkt-92360b0e2079`), as do several of its own rules, wherever an unknown would otherwise silently
disable the rule it guards. An unresolvable current branch, a protected branch it cannot identify, a
directory move it located but could not name, and `git switch -`, whose destination is unknowable
so it is assumed protected. **Read that as a design principle, not a closed list, and never as a
count** — this sentence has claimed one, then three, and each was an undercount found by review
(`tkt-3006d09810f7`). "Anything unexpected fails open" is not a safe reading either: `hasRemote`
returns true on any error, and an unreadable directory falls back to the *session* repo's branch,
which can block a commit that was never going near a protected branch. Check the code.
`guard-ticket` and `guard-review-target` do fail closed
internally. `guard-board-writes` is split, and in the same direction as the precheck it sits beside:
**closed** on a payload it cannot parse, but **allowing** a payload that parsed and named no path at
all, because that matcher covers every edit on the machine and an unattributable payload shape is
not worth wedging them over. That second case is a residual, not a guarantee.
`guard-subagent-gates` is split: it fails **closed** when it knows the rule applies (a
subagent whose command it cannot read) and **exits 1** when it cannot even establish that (an
unparseable payload) — blocking there would wedge every main-thread command over a case the rule
never covers, so it is loud rather than silent.
- `guard-worktree-precheck` is the one row whose fail direction is not fixed, and that is its whole
design. It is wired directly rather than through a launcher, so if the *precheck file itself* cannot
run, node exits 1 and the call is allowed. Once a marker says the session is armed, every failure
past that point closes: an unreadable state directory, and a `ticket-workflow` it cannot import,
both exit 2. The asymmetry is deliberate — a broken install must not wedge Edit and Bash for
sessions that never started a ticket, but it must never let an armed one run unguarded. One
exception, stated because "every failure closes" would otherwise overclaim: a payload carrying no
usable `session_id` exits 0 without reaching the marker at all, so an armed session whose payload
cannot be attributed is allowed through.
- The `open` rows are a genuine gap. Nothing here detects a reporter that stopped recording; the
stderr is visible only if someone is looking. Treat "are my hooks actually running?" as a question
needing its own check.
**`track-steps` must be wired to BOTH events, to the same command.** `PostToolUse` fires only when a
tool call *succeeds*; a failed one is dispatched to `PostToolUseFailure`. Wire only the first and the
hook is structurally incapable of ever recording a failure — it will not mislabel them, it will never
see them, and the log fills with `passed` rows that look like a clean record rather than a partial
one (`tkt-31f693ac8bb0`; 4,635 command milestones with zero failures among them). The outcome comes
from which event was delivered, so no other configuration expresses this.
```json
"PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }],
"PostToolUseFailure": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }]
```
This is **not** the duplicate-writer hazard below: those are two writers racing on one event, whereas
these are one writer on two disjoint events, and exactly one of them fires per tool call.
**One shape still records nothing, by design.** A milestone whose exit is hidden from the tool call —
`npm test | tail`, `npm test; echo done`, `npm test || true`, `npm test &` — has no knowable outcome,
because the delivered event describes the command as a whole, and the shell discarded the
milestone's own status before the tool call ever ended. (The payload carries no exit status at
all — that is the original defect.) The
hook records nothing there rather than guessing `passed`. An unbroken `&&` chain is the exception and
is recorded in full: if the whole command succeeded, every link in it exited 0.
**A failure it cannot pin on one milestone records `unattributed`, never `failed`.** A failing
`npm run typecheck && npm run lint && npm test` stopped at *some* link, and a failing
`npm ci && npm test` may never have reached `test` at all — nothing in the payload says which. Each
link whose exit reached the end of the command gets `state: "unattributed"`: `failed` would accuse a
gate that may have passed or never run, and silence would leave an earlier `passed` standing as the
latest word (`tkt-24925929919c`). `verify` reads a latest `unattributed` test as UNKNOWN. A reader
pinned before that state existed counts those rows as `unrecognized` (see **Corrupt event lines**)
until its pin is bumped — so bump the readers before the machine's writer — and a consumer keeping
its **own copy** of the state vocabulary must add the value in the same bump.
**Wiring at user scope does not replace project scope — the two are additive.** Duplicate *guards*
are harmless (they decide identically), but a duplicate **writer** is not: two `track-steps` hooks
append to the same `events/<id>.jsonl` and double-log every milestone. If you wire `track-steps` at
user scope, remove any per-repo `PostToolUse` / `PostToolUseFailure` copy.
**Verify by removing things, not by reading the config** — and remove *both*, because they fail
differently:
1. Move the development checkout's `hooks/` and `dist/` aside. The guards must still block, still
allow on a feature branch, and the MCP server must still answer. Anything that changes means the
machine was still depending on that tree.
2. Move the **install** aside. Each `closed` row must exit 2 and each `open` row exit 1. This is the
only step that exercises the launcher's reason to exist — with the install present, a mistyped
fail direction is never even read, so step 1 alone would pass with the guard disarmed.
## `doctor` — checking that the wiring above is actually live
Everything in the two sections above lives in machine-local, unversioned files. No repository's test
suite can see them, so nothing detects that a hook drifted, that an install half-upgraded, or that
the telemetry writer stopped recording. `doctor` reads that wiring and reports on it:
```bash
npx ticket-workflow doctor # from any repo
npx ticket-workflow doctor --strict # UNKNOWN counts as failure (for a gate)
npx ticket-workflow doctor --no-mcp # skip starting the MCP server
```
| check | what it answers |
|---|---|
| `writer-uniqueness` | how many `PostToolUse` telemetry writers are wired — two double-log every milestone |
`writer-uniqueness` counts `PostToolUse` writers **only**. It does not check that
`PostToolUseFailure` is wired, so a machine missing that subscription is reported `ok` while being
structurally incapable of recording a failure. Verify that half by hand until a check exists.
| `hook-wiring` | does any **vendored** hook copy differ from the one this package ships |
| `pin` | is more than one version of this package live at once |
| `mcp` | does the configured server start and answer `initialize`, at what version |
| `board` | do the MCP server and the telemetry writer point at the **same** board |
| `protected-branch` | which branches `guard-bash` will actually protect *here* |
| `reporter-liveness` | when did the hook last write a step only it can write |
| `toolchain` | which of this repo's gate steps can be recorded at all |
**Every check returns OK / MISMATCH / UNKNOWN, never a boolean.** UNKNOWN is the point: on a machine
with no `~/.claude` — CI, a container, a fresh clone — the user-scope checks are genuinely
unanswerable, and reporting that as OK is the fail-open shape this package exists to reject. The
exit code is 0 unless a check MISMATCHes; `--strict` also fails on UNKNOWN, which is what a gate wants.
Two things it deliberately does **not** claim. It cannot see a *running* MCP server — a session's
server is not observable from another process — so it answers the weaker, checkable question of
whether a new session's server would start. And a `hook-wiring` OK means the wired files match what
this package ships, not that the guards are correct; that is what their own suites are for.
## `worktree` — one checkout per session, from any repo
Claude Code's own worktree support isolates only the repo a session was **started in**. Where one
board serves every repository, sessions start in the board's repo and edit sibling repos — which get
no isolation at all. That is not theoretical: two sessions shared one checkout, and a branch switch by
one moved HEAD out from under the other while it held a full ticket's work uncommitted.
```bash
# From a repo that HAS this package installed:
npx ticket-workflow worktree <ticket-id> # branch named from the ticket
npx ticket-workflow worktree --branch feat/x # when the board is not reachable from here
npx ticket-workflow worktree <id> --base origin/release # explicit base
npx ticket-workflow worktree <id> --repo ../other-repo # operate on ANOTHER checkout
# From a repo that does not depend on it — including non-Node repos:
npx -y github:mcinerneyjake/ticket-workflow#<tag> worktree <id> --repo .
```
**Use the git spec, not the bare name, from a repo that does not have this installed.** This package
is **not published to npm** — consumers depend on it by git tag — so `npx ticket-workflow` cannot
resolve there. Worse, the bare name is a namespace someone else could take: if a package called
`ticket-workflow` is ever published, `npx` would silently run *theirs*. (`tkt-bd0b84d61db0` tracks
publishing, which would change this; until then the git spec is the only correct remote form.)
The `--repo` flag is the other answer, and usually the better one: run it from the repo that already
has the package and point it at whichever checkout needs the worktree.
The worktree lands in `.claude/worktrees/<name>`, **inside** the repo. That is load-bearing rather
than tidy: Node resolves upward, so the gate runs in a fresh worktree with no install. A sibling
directory would need a full `node_modules` per worktree. Resolving upward is not the same as having a
`node_modules` at the worktree root, though: a suite that asserts that path exists still needs the
link described below.
### Provisioning: the files a checkout does not carry
A new worktree has no `.env`, no machine-local config and no `node_modules`. After creating one, the
command copies them in from the checkout it was created from. It reads **Claude Code's own
declaration**, so `EnterWorktree` / `claude -w` and this command get the same result from one source:
- **`.worktreeinclude`** at the repo root, gitignore syntax. A file is copied only when it matches
**and git ignores it**, which is the rule Claude Code applies. An untracked file that is not ignored
is reported as `skipped`, and a tracked one is never listed.
- **`worktree.symlinkDirectories`** in `.claude/settings.json`, e.g. `["node_modules"]`. Each entry
is symlinked, not copied. Only the project `settings.json` is read; a value set in user or local
settings reaches Claude Code but not this command.
```
# .worktreeinclude
.env
.claude/skills/*/repos.local.json
```
What it will not do, each reported and each failing the command with a non-zero exit:
- **Provision anything but a linked worktree of the same repository.** The source checkout itself, a
plain directory and another repository's worktree are all refused.
- **Overwrite.** An existing destination is `present` and left alone, so re-running is safe.
- **Copy a symlink**, or write through a destination directory that resolves outside the worktree.
- **Copy a file the new worktree does not ignore.** The worktree is cut from a commit, so its ignore
rules can differ from the source checkout's working tree.
- **Copy into a linked directory.** Links are made first, as Claude Code orders them, and a
`.worktreeinclude` match under one (a `.env` shipped inside `node_modules/pkg/`) is `skipped`.
Copying first would create a real directory the link could never replace.
- **Leave an unignored link behind.** `node_modules/` with a trailing slash ignores a directory but
**not a symlink to one**. The link would sit untracked, one `git add` away from a commit, and would
make `git worktree remove` refuse. It is removed and reported instead. Write `node_modules` without
the slash.
- **Read "could not check" as "nothing declared."** Invalid `settings.json`, a non-array
`symlinkDirectories`, a `.worktreeinclude` that is not a regular file, or git failing all refuse.
Do not list `.claude/settings.local.json`. Measured against Claude Code 2.1.273, `EnterWorktree`
does not copy it, because it resolves a worktree's local settings to the main checkout. A copy would
be a stale overlay that could bring back a permission you have since revoked.
`provisionWorktree({ repoDir, worktreeDir })` is exported from the package root for scripts that
create worktrees with plain `git worktree add`.
Two refusals worth knowing, both cases where proceeding would be worse than stopping:
- **An unresolvable base is refused, never defaulted.** The ladder is `origin/HEAD`, then
`origin/main`, `origin/master`, `main`, `master` — and if none exist it asks for `--base` rather
than guessing. Guessing `main` in a `master` repo cuts the branch from the wrong history, and that
only surfaces at review.
- **An existing branch or occupied path is refused before git is asked**, so the message names the
situation instead of surfacing git's.
The base ladder is duplicated in `hooks/lib/default-branch.mjs`, which `src` cannot import. A test
drives the hooks version with a recording double and asserts both probe the same refs in the same
order, so the copy is held by a failing test rather than by a comment asking someone to keep them in
sync.
Pair it with the `warn-stale-worktree` SessionStart hook: this command creates worktrees, that hook
catches the ones left behind — a stale worktree carries its own `CLAUDE.md`, and stale instructions
on disk still instruct.
The `gitignore` audit check requires `.claude/worktrees` and `node_modules` to be ignored —
**without a trailing slash**, which matches directories only and so misses a symlink: a worktree
materialised as one, or a worktree's `node_modules` linked to the primary's. An unignored worktree
shows up as a mountain of untracked files in the main checkout, which is exactly when someone
reaches for `git add -A` and commits another session's in-flight work. The check asks git for the
ignore effect in a scratch repository holding only this repo's committed ignore files, so neither
your global ignore file nor a directory already on disk can change the verdict.
## `gate` — run the quality gate and record what it did
```bash
npx ticket-workflow gate
```
Runs the repo's `typecheck`, `lint` and `test` scripts in that order and appends one event per
script to the ticket named by the current branch. Each event carries the `passed`/`failed` state read
from the script's own exit code, plus `exitCode`, `durationMs` and, for `test`, the
passed/failed/skipped counts from vitest's `Tests` summary line. That line is read only directly
after vitest's `Test Files` line, and only when its parts add up to its own total. Otherwise the counts
are left off, never zeroed. A script that exits 0 while its summary reports failures (`vitest run ||
true`) is recorded `failed`. `show` prints the measurements beside each step, and `verify` trusts them
as observed outcomes.
**Every defined script runs even after one fails, and each writes a row.** Stopping early, or
writing nothing, would leave a step's earlier `passed` row as its latest state and vouch for code
that was never checked. So a script that could not be started is recorded `unattributed`, which
`verify` reads as unknown.
It exists because the `track-steps` hook infers gate results from Bash command text, so a gate run
inside `git commit`'s pre-commit hook, behind a pipe, or after a `;` leaves no milestone. `gate` reads
its own exit codes, so it works from a session and from a git hook alike. The scripts run with stdin
closed, so a bare `vitest` script cannot drop into watch mode. They also run without git's
repo-local variables (from `git rev-parse --local-env-vars`) and without any board variable,
`CLAUDE_PROJECT_DIR` included. The gate returns when npm exits, even if a background process the
script left behind still holds its output.
**It writes only to a board that holds the ticket.** A shell or a git hook usually has no board
variable set, and falling back to the cwd would put the events in the wrong board without any sign.
So when `<tickets>/<id>.md` is not there, `gate` still runs the scripts, prints `NOT RECORDED` with
the path it checked, and writes nothing. That skip is reserved for the case where no board was
configured. A **configured** board that is wrong fails with exit 3 instead: no `tickets/` directory
there, only one of `TICKETS_DIR_OVERRIDE` and `EVENTS_DIR_OVERRIDE` set, or `TICKET_WORKFLOW_BOARD_DIR`
set alongside either of them. A typo would otherwise record nothing, forever, with no signal. The
branch must name exactly one ticket id.
To record into a central board from a shell or a hook, export **`TICKET_WORKFLOW_BOARD_DIR`** (the
board's root) in the shell profile. Only `gate` reads it. Exporting `BOARD_DIR_OVERRIDE` globally
instead would also aim every test run on the machine at the real board.
| exit | meaning |
|---|---|
| `0` | every gate script that exists passed |
| `1` | a gate failed |
| `2` | something was not checked: arguments given, no readable `package.json`, none of the three scripts defined, or a script that could not be started |
| `3` | a record could not be written, the board could not be checked, or a configured board is wrong. This wins over the others, since a lost record is otherwise invisible |
A missing script is named and never counted as a pass. A branch with no ticket id (such as `main`) is
not an error: the scripts run and the exit code is theirs.
## `verify` — checking a ticket's claims against the record
Every ticket ends with an agent-authored `## Implementation summary` asserting `Tests: N added` and a
green gate. Nothing checks that assertion. The events log **can**: it is written by a `PostToolUse`
hook that fires on actual command execution, so an agent cannot produce a `test: passed` event by
claiming tests passed. That makes it testimony from a process outside the agent's control.
**Read that narrowly.** A `test: passed` row witnesses that a test COMMAND RAN and the tool call
succeeded — never that the tests themselves passed, and it is testimony only about the command the
shell actually reported on. Rows written before `tkt-31f693ac8bb0` witness even less: the writer
derived every outcome from a field that did not exist, so a failing gate recorded nothing and a
masked one recorded `passed`. `verify` reports those as `unknown` rather than reading them, keyed on
an `outcomeFrom` marker the fixed writer stamps on each row — not on a date, which would start
trusting a machine that never upgraded.
```bash
npx ticket-workflow verify # all closed tickets
npx ticket-workflow verify <id> # one ticket, whatever its status
npx ticket-workflow verify --project <name> # one project
npx ticket-workflow verify --all --json # every status, as data
```
It does not ask "did the gate run". It asks the narrower, answerable question: **does what the ticket
says match what was observed?**
| outcome | meaning |
|---|---|
| `ok` | claim and record agree — including `Tests: none — …`, which asserts nothing the record can contradict |
| `violation` | the summary claims tests, and no passing test milestone was recorded, *while telemetry was demonstrably live for that ticket* |
| `unknown` | not judgeable: telemetry never observed it, its log lost lines, it carries no `Tests:` line, or its milestones predate the outcome fix |
**Report-only, and exit 0 even with violations.** A violation is a *discrepancy*, not proof of
misconduct — the gate may have run under a command the hook does not recognise. Until that rate is
known and defensible, failing a build on it would assert more than the data carries.
**The coverage line comes first, always.** A findings list printed above its own coverage invites
reading absence-of-findings as compliance, and an empty run is reported as *"not a clean result — it
is an empty one"* rather than as zero violations. What this offers is a stated boundary on what it
vouches for, so that boundary is the headline.
Scope, plainly: this raises confidence in **process claims** only — whether the gate ran, in what
order, on a branch, before the PR. It cannot establish that the code is correct, that the tests were
meaningful, that a bug repro was written first, or that the reasoning was sound. Never sell it as
more than that.
## `specs` — checking spec tickets against their spec files
```bash
npx ticket-workflow specs # the board BOARD_DIR_OVERRIDE / CLAUDE_PROJECT_DIR names
npx ticket-workflow specs --board <dir> # an explicit board root
```
A spec ticket is any ticket whose `spec` field is set (`owner/repo:path/to/spec.md`). For each one,
`specs` asks GitHub (through `gh api`) about the owning repo's **default branch**. An open spec's file
must exist there as a regular file. A `done` spec's file must be gone, and the repo's
`docs/specs/README.md` must index it by a `https://github.com/<owner>/<repo>/blob/<sha>/<path>`
permalink, with a full 40-character sha, whose commit still holds the file. An `archived` spec ticket
is listed as `retired` and not checked, because archiving accepts any prior status. It reads the
board and the index and writes neither.
It refuses to guess the board: with no `BOARD_DIR_OVERRIDE`, `CLAUDE_PROJECT_DIR` or `--board`, it
exits `2` rather than read whatever `tickets/` sits in the cwd, and `--board` alongside
`TICKETS_DIR_OVERRIDE` (which would silently outrank it) is refused the same way.
| exit | meaning |
|---|---|
| `0` | every spec ticket checked, nothing wrong (`nothing to check` when the board has none) |
| `1` | findings, every spec ticket checked |
| `2` | something could not be checked: no declared board, a missing `tickets/` directory, a malformed or empty `spec` value, an unreadable ticket file, a board with no ticket files, `gh` missing or failing, a repo it cannot see, a non-file at the spec path, or an index too large to read inline. Findings still print. |
**A 404 on the repo is not a finding.** GitHub answers 404 for a private repo you cannot see, exactly
as for one that does not exist, so that case is `2`. Only a 404 on a path inside a repo it *can* see
counts as missing.
## `test-run` — one machine, K test runs at a time, each in its own TMPDIR
Several agent sessions committing at once each run a full vitest suite through the husky gate, and
the spawn-heavy suites starve each other past their timeouts while the machine looks idle. A
per-repo worker cap bounds one run; it cannot see across runs. This does (`tkt-14788b3fc356`):
```ts
// vitest.config.ts — before `export default defineConfig`
import { holdTestRun, TEST_RUN_GLOBAL_SETUP, TestRunSlotReporter } from 'ticket-workflow/test-run';
await holdTestRun({ repo: 'my-repo' });
export default defineConfig({
test: {
globalSetup: [TEST_RUN_GLOBAL_SETUP], // releases the slot; ROOT `test` block only under a projects split
reporters: ['default', new TestRunSlotReporter({ repo: 'my-repo' })], // frees it between watch runs
// ...
},
});
```
At config resolution — before vitest snapshots the worker environment or cleans the coverage
directory — it takes one of **K** slots under `~/.claude/state/test-slots/` and points `TMPDIR` at a
fresh `<tmpdir>/<repo>-test/run-<pid>-<rand>`, so every `mkdtemp` a suite leaks dies with the run and
no two runs share a directory. Waiting is foreground with backoff, printing who holds the slots.
| setting | default | env |
|---|---|---|
| slots | 2 | `TEST_SLOTS` |
| wait before refusing | 10 min | `TEST_SLOTS_WAIT_MS` |
| state directory | `~/.claude/state/test-slots` | `TEST_SLOTS_DIR` |
| heartbeat TTL (a holder past it is reclaimed with a warning) | 15 min | — |
An integer override that is not one refuses rather than guessing. K is read by each run, so a run
started with a larger `TEST_SLOTS` widens the cap for itself — a choice, not a guard failure, and the
holder list shows it. There is no opt-out: the helper is
a no-op inside a vitest worker (`VITEST_WORKER_ID`, so a test may import the config) and on `CI`
(`TEST_SLOTS_FORCE=1` re-enables it there). Exit codes are distinct and are the claim: **75** every
slot is held by a live holder and the wait ran out · **78** the state directory or a slot file cannot
be read or trusted, or an override is invalid · **74** the per-run TMPDIR cannot be created. A
refused run holds nothing.
### Watch mode: `TestRunSlotReporter` (`tkt-43881f6840ad`)
Vitest resolves the config **once**, so `holdTestRun` alone gives a watcher a slot on launch and
keeps it for the whole interactive session — idle or not. Two idle watchers exhaust the default pool
of two, and every other run on the machine, the husky gate included, waits its budget and then exits
75. `TestRunSlotReporter` closes that: it releases at the end of each run and takes a slot again at
the start of the next, so a watcher holds one only while it is actually running tests.
It is **inert unless `config.watch` is true**, and inert until `onInit` has said so. Under
`vitest run` nothing changes — the release stays with `globalSetup`, after coverage is written.
Wire it in addition to the two lines above, never instead of them.
**A re-acquire that finds every slot taken stops the watcher.** It rejects rather than run
unguarded, and vitest turns a rejected `onTestRunStart` into an unhandled rejection that exits the
process — so the session ends and has to be restarted. That is the fail-closed direction and is
deliberate, but it is a session lost, not merely a run skipped. Because it now runs on every save
rather than once per process, the re-acquire waits **30 s** by default instead of
`TEST_SLOTS_WAIT_MS`'s ten minutes; an explicit `waitMs`, or an explicit `TEST_SLOTS_WAIT_MS`,
still wins.
Three cases it deliberately does **not** cover, each of which keeps the slot rather than risking an
unguarded run:
- **`vitest --standalone`** reports `onInit` and then runs nothing until you trigger it, so the
config-time hold is held while idle exactly as before. Standalone is the mode chosen to sit idle,
so the headline problem survives there.
- **Typecheck watch** dispatches `onTestRunEnd` on a TSC rebuild with no `onTestRunStart` before it.
An unpaired end releases nothing, so the slot stays held for the session.
- **An embedder** calling `runTestSpecifications` directly never gets `onInit`; the reporter warns
once and holds.
Playwright holds a slot from its own config load and needs the equivalent per-run seam in the
consumer's config; this reporter covers vitest only.
A holder is reclaimed only when its pid is gone (`ESRCH`); a pid another user owns reads as alive,
and a live holder whose heartbeat stopped is reclaimed after the TTL. One further case: a slot
recording the *claimant's own* pid **and a per-hold token the claimant no longer holds** is reclaimed
on sight, whatever its age — that is a leak from a release that threw, and left there it reads as a
live holder to every other repo until the TTL.
The token is what makes that judgement safe. A process can hold **more than one** slot — the config and
globalSetup load through separate module registries, and `registry` is a caller-supplied option — so
*own pid* alone let one hold reclaim a live sibling's record, over-granting the pool, and let a
released hold delete the record of the hold that had since taken the same slot (`tkt-a99209bedbb9`).
Releasing matches the token too, so a hold whose slot was reissued **reports** it rather than deleting
the new owner's record.
Two discriminators sit on top of the per-hold token. A record written **before** tokens carries none,
so ownership falls to its `startedAt`: written before this process started it is a recycled pid's leak
and is reclaimed; written during it, only a pre-token copy in this process can have written it, so it
is a live hold and is kept (`tkt-a51a84902cc9`). And every token is minted
`<ownership domain>.<per-hold nonce>` with the domain per-isolate, so a token from a domain that is
not ours is a stranger wearing **our own pid** rather than our leak (`tkt-f5dae96f0298`). Refusing
there costs at most one TTL: a refused record stops being heartbeated, so it expires and is reclaimed
on the TTL path, which never consults the domain.
**A shared `TEST_SLOTS_DIR` across a pid-namespace boundary is still not safe, and the domain does
not make it so** — it settles only the slice where the stranger's pid collides with ours. Where it
does not collide, that holder's pid simply does not exist on this side, `pidLiveness` reads `ESRCH`
as `dead`, and a live slot is reclaimed on the liveness path before ownership is consulted at all. A
record carries no host or namespace identity, so nothing on that path can tell the two apart. Give
each namespace its own `TEST_SLOTS_DIR`.
Also unguarded, each needing a colliding pid: a **foreign** pre-token holder, whose token-less record
predating this process is indistinguishable from a recycled pid's leak; and two pre-nonce copies,
which carry no domain to compare and so match on pid alone as before. And because the domain is
per-isolate, a leak left by a pre-nonce copy — or by any isolate that has since exited — is a stranger
to the surviving one, so it waits out a TTL instead of being reclaimed on sight.
The claim is an atomic `link(2)` and the reclaim decides under a per-slot lock — measured on the race
test, a reclaim that judged from an earlier read could rename a live winner's fresh record.
```bash
npx ticket-workflow test-slots # who holds what, with liveness and age
npx ticket-workflow test-slots clear-stale
```
A slot file this copy cannot parse refuses any run that reaches it, because it may be a live hold
written by another version of the package. `clear-stale` judges it by mtime alone: it removes it once no
heartbeat has refreshed it within the default TTL, and refuses the whole sweep before then
(`tkt-c3df050395d8`). A holder configured with a TTL or heartbeat above the default can be removed early.
### `test-contention` — proving the bound works
`test-contention` checks the bound (`tkt-98cdd3b87020`). It runs `npm test` N times concurrently,
each run in its own throwaway worktree of the resolved `HEAD` commit under the tmpdir. Each worktree
is provisioned as `worktree` provisions one, and gets a `node_modules` link. It then prints a
per-file table (file · runs failed · timeouts) and removes the worktrees, on Ctrl-C too. Reports and
logs stay under `~/.claude/state/test-contention/runs/` (`TEST_CONTENTION_DIR`).
```bash
npx ticket-workflow test-contention --runs 4 --control # TEST_SLOTS=N: the bound removed
npx ticket-workflow test-contention --runs 4 # the bound in force
```
Each run's slot wait is at least N × 30 min, because queued runs wait out whole suites. This
command's one exception to "an explicit `TEST_SLOTS_WAIT_MS` wins" above: a caller's value can
lengthen that wait but never shorten it, and a note says so when it is raised. An invalid value is
refused before any worktree is made. A `waitMs` passed to `holdTestRun` in the repo's config still
beats both.
The bounded arm prints a verdict **only** against a control that went red the same local day, for
the same checkout, commit and N. A green run on a machine that cannot show contention proves nothing.
A control counts as red only when every failure is a timeout. An assertion failure means `HEAD` is
broken on its own, and that control is not recorded as red. Nor is one with a test that timed out
**and** hit another error (a retry's assertion, an `afterEach` that assumed setup finished, or a report
entry that cannot be read): the report cannot tell a timeout's side effect from a real bug, so the
verdict is withheld and each such test is named, `(×k)` when k tests in one run share a name. The
`timeouts` column still counts it.
The bounded arm applies the same rule to its own runs. It prints FAIL when at least one file failed
only on timeouts, since one clean timeout under the bound shows the bound did not hold. A red arm
whose failures are all mixed tests has shown nothing, so it gets no verdict (exit 2) and is not
recorded as red. Any assertion failure beside those tests is reported too.
Unless `dangerouslyIgnoreUnhandledErrors` is set, vitest exits 1 for an unhandled error just as for a
timeout, and its JSON report drops the error. So a run with both a timeout and an unhandled error
reads as contention from the report alone (`tkt-b1182a02fb14`). Each run therefore also loads a
second reporter, `contentionReporter`, whose `onTestRunEnd` hook writes an evidence file beside the
report. That hook needs vitest 3 or later. Any run with a timed-out test needs readable evidence,
whatever its exit code. Without it the run is undetermined and the verdict is withheld. On vitest 2
or earlier that is every run with a timeout, so the control can never be recorded red and the command
never reaches a verdict. Nothing checks the version first. On newer vitest there is no evidence either
when a globalSetup entry is not a string path, or when the `test` script does not end in the vitest
command, so the appended `--reporter` flags never reach it. With evidence, a run with a timeout is
withheld as above when it records any unhandled error, ignored ones included, or when coverage is
`enabled` with `reportOnFailure` and a non-empty `thresholds` object, since a threshold checked after
the failure also exits 1. Any key makes it non-empty, `perFile` or `autoUpdate` alone included.
Other exit causes land after that evidence is written, so a run with a timeout is withheld the same
way when vitest logged `error during close` (a globalSetup teardown failed), when the run exited with
any code but vitest's own 1, when its config (root or any project) declares a
globalSetup other than ticket-workflow's own, or when it held a slot without declaring
`TEST_RUN_GLOBAL_SETUP` (the release then runs from an exit hook). A teardown can set the exit code
silently, and nothing in the run can see that, so a repo with its own globalSetup gets no verdict on
a run with a timeout. Not covered: other code that runs after the report and silently sets exit code
1, such as a `vitest.onClose` callback or an exit listener the config registers.
The verdict is also withheld when any run wrote no readable report, when a run with a timeout left no
readable log (a close error cannot then be ruled out), or when a run held no slot or
K ≥ N. A repo whose vitest config does not await `holdTestRun` is unbounded. It is withheld as well
when a vitest process outside this command's own process tree appeared during the runs. The process
list is polled every 5 s.
The command refuses to start while such a process is live, or when the process list cannot be read.
Uncommitted changes are not in the runs. There is no per-run timeout, so a hung suite hangs the
command; Ctrl-C still cleans up.
While the control arm runs, it holds N machine-wide slots, and the queued waits scale with N. Other
sessions' test runs wait meanwhile, and on a long control their wait budget can run out (exit 75).
Run it when the machine is otherwise idle.
Exit codes: **0** PASS, or control red · **1** FAIL · **2** no verdict, refused, or usage.
## Development
```bash
npm install # runs the prepare build; refuses outright below engines.npm
npm run typecheck
npm test
npm run build # emit dist/
```
`engine-strict=true` is on, so `npm install` errors rather than warns when npm is below the declared
floor. That is deliberate — an outdated npm rewrites `package-lock.json` and drops the `libc` field
from optional native entries. `npm i -g npm@11` first if it refuses.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues