ticket-workflow-mcp
Allows tracking branches, commits, and other git operations as part of the ticket workflow pipeline.
Allows managing pull requests and repository interactions within the ticket pipeline.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ticket-workflow-mcplist all tickets"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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_ticketstatus:archivedis 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_ticketback tobacklog, andlist_ticketswithstatus: "archived"to find it again.start_ticketrefuses a ticket that is alreadyin-progress, since another session may be working it: the error carries the ticket's last## Checkpointblock and the ticket is not modified. Call it again withforce: trueonly when the branch or worktree that checkpoint names is yours, or its holder is gone — a cleangit statusis not evidence either way. Resuming your own ticket after a/cleartherefore needsforce. 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 armedguard-worktree, whosePreToolUsehook 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/) — aPreToolUseguard (guard-bash.mjs) that blocks whole-tree staging and commits/pushes tomain; aPostToolUsetracker (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 acdor--prefixinto 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'sevents/under the other repo's ticket id, where nothing joins to it; and an opt-inPreToolUseguard (guard-ticket.mjs) that blockscreate_ticketso new tickets are authored by a metered local-LLM intake agent instead of by the model driving the session — except a create whoseparentis an open spec ticket (non-nullspec, notdone/archived), which is planned slicing work. It reads that parent from the board its own environment resolves (BOARD_DIR_OVERRIDE, elseCLAUDE_PROJECT_DIR), so on a central board the hook's wiring must exportBOARD_DIR_OVERRIDEor the exception never admits outside the board's repo; and an opt-inPreToolUseguard (guard-board-writes.mjs) that refuses anEdit/Write/NotebookEditaimed at a board's own ticket or event file, so board data is written throughupdate_ticketrather than edited as text. It blocks on either of two independent tests — atkt-<12 hex>.mddirectly inside atickets/directory (or atkt-<12 hex>.jsonlinsideevents/), 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, elseBOARD_DIR_OVERRIDE/CLAUDE_PROJECT_DIR/ cwd), which catches a non-ticket name — and thetickets/.history/snapshots — sitting in a real board. Case is folded on both, because a case-insensitive filesystem makesTickets/the same file. The second test fires only where the board is declared by an explicit override or evidenced by the directory actually holding atkt-file: keying it on the bareCLAUDE_PROJECT_DIRfallback, which is set in every session, refused every edit into any repo whosetickets/is ordinary documentation. Where a session has calledstart_ticket,guard-worktreealready refuses writes into a repository's primary checkout, which covers a board that lives in one — not a board reached byBOARD_DIR_OVERRIDEinto 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 noPreToolUseEdit/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 aSessionStartstaleness warning (warn-stale-worktree.mjs) that reports when the session opened in a git worktree whoseCLAUDE.md/AGENTS.md/.cursorruleshas 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; setWORKTREE_STALE_THRESHOLDto tune the commit-distance fallback (default 15,0to always report). Also aPreToolUseguard (guard-review-target.mjs) that refuses a/code-reviewwith 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 aPreToolUseguard (guard-subagent-gates.mjs) that stops a subagent merging a PR —gh pr merge, anygh apicall carrying a method or a parameter (the implied POST of-f/-F/--inputincluded, 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, parameterlessgh apireads andgh pr commentare allowed. Push is judged byguard-bash, which lets a subagent (a payload carryingagent_id) push only its current branch: no tag, no delete, no other branch, and nopush.followTags/push.recurseSubmodulesconfig. And a PAIR —guard-worktree.mjswithguard-worktree-precheck.mjs— that, once a session has calledstart_ticket, refuses writes aimed at a repository's primary checkout, so two ticket sessions cannot share one working tree. The guard arms onstart_ticketby writing a marker keyed on the session id (under~/.claude/state/worktree-guard/, orWORKTREE_GUARD_STATE_DIR); the precheck is the half you wire onEdit|Write|NotebookEdit|Bash, and it costs onestat()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, orgit worktree addfor another repo), andgit stashpush/pop is refused from every checkout, becauserefs/stashlives in the shared common directory. A session never disarms, but once every ticket it started has a PR thatghreports merged into the default branch of the repooriginnames (pinned with-R,GH_REPOignored), 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,HEADis an ancestor oforigin/<default>, and no untracked or ignored file sits where the update would write; andgit checkout -- <file>/git restore <file>for a tracked regular file whose unfiltered content already equalsorigin/<default>. Ticketstatusis never consulted, and anyghfailure 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. Wireguard-ticket,guard-board-writesand theguard-worktreepair only if you want those policies — the others suit any consumer.CLI viewer (
ticket-workflow) —listandshow <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).
Related MCP server: Workfile
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>.mdOnly 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 ticketrestore --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? |
| structurally unreadable — bad JSON, missing or wrong-typed required keys. History is lost. | yes |
| well-formed, but names a | 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 returnedTicketEvent[]. It now returns{ events, skipped, unrecognized }, andTicketEventsResponsegained both counts as required fields — optional ones would let a consumer default them with?? 0and report a damaged log as healthy.
Event sources
Every event line names the writer that appended it in source:
| writer | what its |
| the service, on a status transition ( | its own write |
| the | the caller's say-so |
| a consumer's web UI, e.g. hardpack's review toggle (including its | a human click |
|
| the delivered hook event |
|
| 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()requiressourceand answers 400 for a value outsideEVENT_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.
projectis 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):
// 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 returnbyType, andDashboardSummary.byTypeand theTypeCounttype are gone. No consumer read the field, but a repo that duplicatesDashboardSummaryfor 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:
// .claude/settings.json — run the installed file directly
"command": "node node_modules/ticket-workflow/hooks/guard-bash.mjs"// 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 missingnode_modulesreads as allow. The baremain()call has the same hole: if the package resolves but exports no callablemain, the throw exits 1, again an allow. Wrap both, as Installing it once per machine 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):
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/), becauseimport('ticket-workflow/…')is a bare specifier, resolved by walking up from the launcher's own directory. Put it besidesettings.jsoninstead and it throwsERR_MODULE_NOT_FOUNDwith the install fully present — which, failing closed, wedges every Bash call on the machine.
// ~/.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 → |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| depends on arming — see below |
|
| open (exit 1) |
|
| 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, andgit 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:hasRemotereturns 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-ticketandguard-review-targetdo fail closed internally.guard-board-writesis 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-gatesis 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-precheckis 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 aticket-workflowit 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 usablesession_idexits 0 without reaching the marker at all, so an armed session whose payload cannot be attributed is allowed through.The
openrows 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.
"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:
Move the development checkout's
hooks/anddist/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.Move the install aside. Each
closedrow must exit 2 and eachopenrow 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:
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 servercheck | what it answers |
| how many |
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.
# 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:
.worktreeincludeat 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 asskipped, and a tracked one is never listed.worktree.symlinkDirectoriesin.claude/settings.json, e.g.["node_modules"]. Each entry is symlinked, not copied. Only the projectsettings.jsonis read; a value set in user or local settings reaches Claude Code but not this command.
# .worktreeinclude
.env
.claude/skills/*/repos.local.jsonWhat 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
presentand 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
.worktreeincludematch under one (a.envshipped insidenode_modules/pkg/) isskipped. 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, onegit addaway from a commit, and would makegit worktree removerefuse. It is removed and reported instead. Writenode_moduleswithout the slash.Read "could not check" as "nothing declared." Invalid
settings.json, a non-arraysymlinkDirectories, a.worktreeincludethat 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, thenorigin/main,origin/master,main,master— and if none exist it asks for--baserather than guessing. Guessingmainin amasterrepo 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
npx ticket-workflow gateRuns 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 |
| every gate script that exists passed |
| a gate failed |
| something was not checked: arguments given, no readable |
| 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.
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 dataIt does not ask "did the gate run". It asks the narrower, answerable question: does what the ticket says match what was observed?
outcome | meaning |
| claim and record agree — including |
| the summary claims tests, and no passing test milestone was recorded, while telemetry was demonstrably live for that ticket |
| not judgeable: telemetry never observed it, its log lost lines, it carries no |
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
npx ticket-workflow specs # the board BOARD_DIR_OVERRIDE / CLAUDE_PROJECT_DIR names
npx ticket-workflow specs --board <dir> # an explicit board rootA 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 |
| every spec ticket checked, nothing wrong ( |
| findings, every spec ticket checked |
| something could not be checked: no declared board, a missing |
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):
// 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 |
|
wait before refusing | 10 min |
|
state directory |
|
|
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 --standalonereportsonInitand 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
onTestRunEndon a TSC rebuild with noonTestRunStartbefore it. An unpaired end releases nothing, so the slot stays held for the session.An embedder calling
runTestSpecificationsdirectly never getsonInit; 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.
npx ticket-workflow test-slots # who holds what, with liveness and age
npx ticket-workflow test-slots clear-staleA 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).
npx ticket-workflow test-contention --runs 4 --control # TEST_SLOTS=N: the bound removed
npx ticket-workflow test-contention --runs 4 # the bound in forceEach 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
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
Related MCP Connectors
Manage feature requests, votes, roadmaps, and changelogs from any MCP client.
Kanban board for teams and coding agents: manage tasks, subtasks, sprints and wiki pages via MCP.
Agile project management over MCP: boards, epics, roadmap, retros, whiteboards, wiki, metrics
Read and write Mission Control state via MCP — projects, tasks, subtasks, templates, status updates.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceEnables agents to manage an event-sourced, git-backed ticket store through MCP tools, supporting operations like ticket creation, claiming, and transitions.33 PyPI4Apache 2.0
- AlicenseAqualityAmaintenanceRepository-native protocol and MCP server for coordinating work items, documentation, changelogs, and project memory between humans and AI agents, using Markdown files in a Git repository as the canonical data source.303MIT
- AlicenseAqualityBmaintenanceA Jira-lite MCP server that enables planning-first workflows for AI-assisted development. It manages tickets as markdown files with YAML frontmatter, guiding agents through epics, stories, tasks, and architecture decision records.1760 PyPIMIT
- AlicenseNot gradedqualityCmaintenanceProvides MCP tools to manage code review tasks, track evidence and GitHub checks, and record approval decisions in a local kanban board.1MIT