Skip to main content
Glama
mcinerneyjake

ticket-workflow-mcp

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).

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>.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):

// 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:

// .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 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 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/), 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.

// ~/.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.

"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:

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.

# 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

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.

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

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):

// 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.

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).

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

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.

Maintenance

ActivityActive
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables agents to manage an event-sourced, git-backed ticket store through MCP tools, supporting operations like ticket creation, claiming, and transitions.
    33 PyPI
    4
    Apache 2.0
  • A
    license
    A
    quality
    A
    maintenance
    Repository-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.
    30
    3
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    A 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.
    17
    60 PyPI
    MIT