Skip to main content
Glama
mcinerneyjake

ticket-workflow-mcp

README.md
# ticket-workflow

A local-first, **per-repo** ticket board and git/PR pipeline, packaged so any repo
can adopt it by adding a dependency plus a little config — with **no coupling** to
any other repo. Each consuming repo owns its own `tickets/` and `events/`.

It ships three pieces:

- **MCP server** (`ticket-workflow-mcp`) — `list_tickets`, `get_ticket`,
  `start_ticket`, `create_ticket`, `update_ticket`, `record_review`,
  `archive_ticket`, `delete_ticket`. Tickets are markdown files (frontmatter +
  body); the board is the filesystem, no database.

  Archiving is its own tool rather than an `update_ticket` status: `archived` is
  deliberately absent from that tool's status enum, so a ticket can't be retired
  by mistyping a field on an ordinary edit, and the tool stays out of any
  name-allowlisted agent toolset. It is reversible — `update_ticket` back to
  `backlog`, and `list_tickets` with `status: "archived"` to find it again.
- **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; 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** running `git commit`,
  `git push`, `gh pr create` or `gh pr merge` — the actions that sit behind a
  human approval gate, which a subagent has no channel to ask for. Reading and
  posting findings (`git log`/`diff`, `gh pr view`/`diff`/`list`,
  `gh pr comment`) are untouched. 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` 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.**

## Board location

The board root is resolved at runtime as
`BOARD_DIR_OVERRIDE ?? CLAUDE_PROJECT_DIR ?? process.cwd()`, then `tickets/` and
`events/` under it. Claude Code sets `CLAUDE_PROJECT_DIR` for both the MCP server
and the hooks, so both write to the same per-repo board. `TICKETS_DIR_OVERRIDE`
and `EVENTS_DIR_OVERRIDE` take precedence (used by tests).

## Backup-on-write / recovery

`tickets/` is the source of truth and has no built-in history (consumers typically
gitignore it), so before `updateTicket` overwrites a ticket **body** the prior full
file — frontmatter + body — is snapshotted to:

```
<board>/tickets/.history/<id>/<ISO-timestamp>.md
```

Only body-changing updates snapshot; a structured-only edit (status, priority, …)
writes nothing to `.history/`. Successive edits accumulate one snapshot per prior
version, and `list_tickets` ignores `.history/`, so snapshots never surface on the
board.

**Recovery is manual — there is no restore UI.** To roll a body back, read the
relevant `.history/<id>/<timestamp>.md` and copy its body into the live ticket (e.g.
via `update_ticket`). Snapshotting is best-effort: a failure 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.

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

## Unassigned tickets

A ticket with no `project` is absent from every project-filtered view, so a work
queue that selects with `list_tickets({ project })` can never pick it — it is not
mislabelled, it is out of the queue. `list_tickets` therefore reports
`unassigned: [id, …]` in its envelope, plus a `note` naming the ids.

Like `unreadable`, it is board-wide and **not** narrowed by your filters — a
`project` filter would exclude the very tickets being reported, which is the bug
itself.

It covers **open** tickets only. `done` and `archived` are past selection, so an
unassigned one there is not lost work, and at least one such ticket is deliberately
project-less because it spans several repos. A field that flagged those on every
call is a field nobody reads by the second week.

Two more bounds, for the same reason:

- **Empty when the board uses no projects at all.** `project` is optional, and a
  single-repo board has nothing to partition — every ticket would be reported, on
  every call, with nothing wrong.
- **Capped at 20 ids**, with the true total in `note`. A truncated list must never
  read as the whole story.

A project of *whitespace* counts as unassigned: it is stored verbatim while a
caller's blank filter is normalized to "no filter", so no filter value can ever
match it — strictly worse than an absent project, and invisible without this.

Dropping an unresolvable project on the agent write path is deliberate (the intake
model hallucinates project names, and projects are *derived* from ticket values, so
a name that matches nothing is dropped rather than minted). This field is what
reconciles the result afterwards, so the drop does not depend on someone reading a
warning that has scrolled past.

## Consuming it in a repo

Add the dependency (public, pinned by tag):

```jsonc
// package.json
"devDependencies": { "ticket-workflow": "git+https://github.com/mcinerneyjake/ticket-workflow.git#v0.11.0" }
```

Wire the MCP server (`.mcp.json`) and the hooks + allowlist (`.claude/settings.json`);
see a consuming repo's config for the exact shape. Run `npx ticket-workflow show <id>`
to view a ticket's pipeline.

> **Breaking in 0.24.0:** `summarize()` / `summarizeBoard()` no longer return `byType`, and
> `DashboardSummary.byType` and the `TypeCount` type are gone. No consumer read the field, but a
> repo that duplicates `DashboardSummary` for the browser must drop it in the same pin bump or its
> typecheck fails — removal is a compile error there, not a silent one.

### Wiring the hooks from an install

Each hook is available both as a script path and as a subpath import, so a consumer can wire the
installed copy instead of vendoring one that then drifts:

```jsonc
// .claude/settings.json — run the installed file directly
"command": "node node_modules/ticket-workflow/hooks/guard-bash.mjs"
```

```js
// or import it, to set consumer-specific policy before handing over
import { main } from 'ticket-workflow/hooks/guard-bash.mjs';
main();
```

> **Both forms above are fail-open if the install is absent or stale**, and neither is what you want
> for a guard you rely on. `node <path-that-does-not-exist>` exits 1, and only exit **2** blocks — so
> a missing `node_modules` reads as *allow*. The bare `main()` call has the same hole: if the package
> resolves but exports no callable `main`, the throw exits 1, again an allow. Wrap both, as
> [Installing it once per machine](#installing-it-once-per-machine-user-scope) does.

The exported subpaths are exactly the hook files in `hooks/` — no count is given here on purpose,
because the one that used to be said "five" while the map listed six; `hooks/packaging.test.mjs`
asserts the two agree. Importing a hook does **not** run it;
`main()` reads the payload from stdin and ends in `process.exit()`, so it is one hook per process —
which is how Claude Code invokes them anyway (one process per matcher).

### Installing it once per machine (user scope)

The hooks and MCP server can govern *every* repo on a machine by wiring them at user scope
(`~/.claude/settings.json`, `~/.claude.json`) instead of per repo. Install to a stable location —
**not** a working checkout, and not `npm i -g` (`npm root -g` is Node-version-scoped, so the next
upgrade silently relocates it):

```bash
npm install --prefix ~/.claude/tools ticket-workflow@github:<owner>/ticket-workflow#<tag>
```

Wiring a checkout is the trap worth naming: hooks are re-read on **every** invocation, so checking
out a branch that edits `guard-bash.mjs` re-arms or dis-arms the machine's guard for every running
session, mid-edit — and `dist/` becomes the MCP server that every newly started session loads.

**Do not point the wiring straight at the installed file either** — same reason as above: a missing
file exits 1, which reads as *allow*. Wire a small launcher that converts "cannot load" into the
right answer for the event.

**Where the launcher lives is load-bearing, and the two constraints pull in opposite directions:**

- **outside `node_modules`** — its whole job is to still exist when the install does not;
- **but still under the install prefix** (`~/.claude/tools/hooks/`, not `~/.claude/hooks/`), because
  `import('ticket-workflow/…')` is a *bare specifier*, resolved by walking up from the launcher's own
  directory. Put it beside `settings.json` instead and it throws `ERR_MODULE_NOT_FOUND` **with the
  install fully present** — which, failing closed, wedges every Bash call on the machine.

```js
// ~/.claude/tools/hooks/run-hook.mjs <hook-name> <closed|open>
const [name, direction] = process.argv.slice(2);

// Validate up front. `direction === 'closed' ? 2 : 0` would make a one-character typo in
// settings.json silently disarm the guard, so an unusable direction blocks.
if (!name || (direction !== 'closed' && direction !== 'open')) {
  process.stderr.write('[run-hook] BLOCKED: usage: run-hook.mjs <hook-name> <closed|open>\n');
  process.exit(2);
}

try {
  const { main } = await import(`ticket-workflow/hooks/${name}.mjs`);
  if (typeof main !== 'function') throw new TypeError('no callable main — stale install?');
  await main();
} catch (err) {
  process.stderr.write(`[${name}] could not run: ${err?.message ?? err?.code ?? 'import failed'}\n`);
  process.exit(direction === 'closed' ? 2 : 1);
}

// Every hook here ends in process.exit(); returning instead is a contract violation (a stale pin),
// and falling off the end exits 0 — an allow. Guards must not resolve that permissively.
if (direction === 'closed') process.exit(2);
```

Fail direction is per event. A guard that cannot run must block; a reporter that cannot run has
nothing to block and must not wedge the session — but note it exits **1, not 0**:

| hook | event | cannot **load** → |
|---|---|---|
| `guard-bash` | `PreToolUse` | **closed** (exit 2) |
| `guard-ticket` | `PreToolUse` | **closed** (exit 2) |
| `guard-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-subagent-gates` is split: it fails **closed** when it knows the rule applies (a
  subagent whose command it cannot read) and **exits 1** when it cannot even establish that (an
  unparseable payload) — blocking there would wedge every main-thread command over a case the rule
  never covers, so it is loud rather than silent.
- `guard-worktree-precheck` is the one row whose fail direction is not fixed, and that is its whole
  design. It is wired directly rather than through a launcher, so if the *precheck file itself* cannot
  run, node exits 1 and the call is allowed. Once a marker says the session is armed, every failure
  past that point closes: an unreadable state directory, and a `ticket-workflow` it cannot import,
  both exit 2. The asymmetry is deliberate — a broken install must not wedge Edit and Bash for
  sessions that never started a ticket, but it must never let an armed one run unguarded. One
  exception, stated because "every failure closes" would otherwise overclaim: a payload carrying no
  usable `session_id` exits 0 without reaching the marker at all, so an armed session whose payload
  cannot be attributed is allowed through.
- The `open` rows are a genuine gap. Nothing here detects a reporter that stopped recording; the
  stderr is visible only if someone is looking. Treat "are my hooks actually running?" as a question
  needing its own check.

**`track-steps` must be wired to BOTH events, to the same command.** `PostToolUse` fires only when a
tool call *succeeds*; a failed one is dispatched to `PostToolUseFailure`. Wire only the first and the
hook is structurally incapable of ever recording a failure — it will not mislabel them, it will never
see them, and the log fills with `passed` rows that look like a clean record rather than a partial
one (`tkt-31f693ac8bb0`; 4,635 command milestones with zero failures among them). The outcome comes
from which event was delivered, so no other configuration expresses this.

```json
"PostToolUse":        [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }],
"PostToolUseFailure": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }]
```

This is **not** the duplicate-writer hazard below: those are two writers racing on one event, whereas
these are one writer on two disjoint events, and exactly one of them fires per tool call.

**Two shapes still record 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. Likewise a *failing*
command carrying several milestones records nothing, since nothing says which link failed.

**Wiring at user scope does not replace project scope — the two are additive.** Duplicate *guards*
are harmless (they decide identically), but a duplicate **writer** is not: two `track-steps` hooks
append to the same `events/<id>.jsonl` and double-log every milestone. If you wire `track-steps` at
user scope, remove any per-repo `PostToolUse` / `PostToolUseFailure` copy.

**Verify by removing things, not by reading the config** — and remove *both*, because they fail
differently:

1. Move the development checkout's `hooks/` and `dist/` aside. The guards must still block, still
   allow on a feature branch, and the MCP server must still answer. Anything that changes means the
   machine was still depending on that tree.
2. Move the **install** aside. Each `closed` row must exit 2 and each `open` row exit 1. This is the
   only step that exercises the launcher's reason to exist — with the install present, a mistyped
   fail direction is never even read, so step 1 alone would pass with the guard disarmed.

## `doctor` — checking that the wiring above is actually live

Everything in the two sections above lives in machine-local, unversioned files. No repository's test
suite can see them, so nothing detects that a hook drifted, that an install half-upgraded, or that
the telemetry writer stopped recording. `doctor` reads that wiring and reports on it:

```bash
npx ticket-workflow doctor            # from any repo
npx ticket-workflow doctor --strict   # UNKNOWN counts as failure (for a gate)
npx ticket-workflow doctor --no-mcp   # skip starting the MCP server
```

| check | what it answers |
|---|---|
| `writer-uniqueness` | how many `PostToolUse` telemetry writers are wired — two double-log every milestone |

`writer-uniqueness` counts `PostToolUse` writers **only**. It does not check that
`PostToolUseFailure` is wired, so a machine missing that subscription is reported `ok` while being
structurally incapable of recording a failure. Verify that half by hand until a check exists.
| `hook-wiring` | does any **vendored** hook copy differ from the one this package ships |
| `pin` | is more than one version of this package live at once |
| `mcp` | does the configured server start and answer `initialize`, at what version |
| `board` | do the MCP server and the telemetry writer point at the **same** board |
| `protected-branch` | which branches `guard-bash` will actually protect *here* |
| `reporter-liveness` | when did the hook last write a step only it can write |
| `toolchain` | which of this repo's gate steps can be recorded at all |

**Every check returns OK / MISMATCH / UNKNOWN, never a boolean.** UNKNOWN is the point: on a machine
with no `~/.claude` — CI, a container, a fresh clone — the user-scope checks are genuinely
unanswerable, and reporting that as OK is the fail-open shape this package exists to reject. The
exit code is 0 unless a check MISMATCHes; `--strict` also fails on UNKNOWN, which is what a gate wants.

Two things it deliberately does **not** claim. It cannot see a *running* MCP server — a session's
server is not observable from another process — so it answers the weaker, checkable question of
whether a new session's server would start. And a `hook-wiring` OK means the wired files match what
this package ships, not that the guards are correct; that is what their own suites are for.

## `worktree` — one checkout per session, from any repo

Claude Code's own worktree support isolates only the repo a session was **started in**. Where one
board serves every repository, sessions start in the board's repo and edit sibling repos — which get
no isolation at all. That is not theoretical: two sessions shared one checkout, and a branch switch by
one moved HEAD out from under the other while it held a full ticket's work uncommitted.

```bash
# From a repo that HAS this package installed:
npx ticket-workflow worktree <ticket-id>                 # branch named from the ticket
npx ticket-workflow worktree --branch feat/x             # when the board is not reachable from here
npx ticket-workflow worktree <id> --base origin/release  # explicit base
npx ticket-workflow worktree <id> --repo ../other-repo   # operate on ANOTHER checkout

# From a repo that does not depend on it — including non-Node repos:
npx -y github:mcinerneyjake/ticket-workflow#<tag> worktree <id> --repo .
```

**Use the git spec, not the bare name, from a repo that does not have this installed.** This package
is **not published to npm** — consumers depend on it by git tag — so `npx ticket-workflow` cannot
resolve there. Worse, the bare name is a namespace someone else could take: if a package called
`ticket-workflow` is ever published, `npx` would silently run *theirs*. (`tkt-bd0b84d61db0` tracks
publishing, which would change this; until then the git spec is the only correct remote form.)

The `--repo` flag is the other answer, and usually the better one: run it from the repo that already
has the package and point it at whichever checkout needs the worktree.

The worktree lands in `.claude/worktrees/<name>`, **inside** the repo. That is load-bearing rather
than tidy: Node resolves upward, so the gate runs in a fresh worktree with no install. A sibling
directory would need a full `node_modules` per worktree. Resolving upward is not the same as having a
`node_modules` at the worktree root, though: a suite that asserts that path exists still needs the
link described below.

### Provisioning: the files a checkout does not carry

A new worktree has no `.env`, no machine-local config and no `node_modules`. After creating one, the
command copies them in from the checkout it was created from. It reads **Claude Code's own
declaration**, so `EnterWorktree` / `claude -w` and this command get the same result from one source:

- **`.worktreeinclude`** at the repo root, gitignore syntax. A file is copied only when it matches
  **and git ignores it**, which is the rule Claude Code applies. An untracked file that is not ignored
  is reported as `skipped`, and a tracked one is never listed.
- **`worktree.symlinkDirectories`** in `.claude/settings.json`, e.g. `["node_modules"]`. Each entry
  is symlinked, not copied. Only the project `settings.json` is read; a value set in user or local
  settings reaches Claude Code but not this command.

```
# .worktreeinclude
.env
.claude/skills/*/repos.local.json
```

What it will not do, each reported and each failing the command with a non-zero exit:

- **Provision anything but a linked worktree of the same repository.** The source checkout itself, a
  plain directory and another repository's worktree are all refused.
- **Overwrite.** An existing destination is `present` and left alone, so re-running is safe.
- **Copy a symlink**, or write through a destination directory that resolves outside the worktree.
- **Copy a file the new worktree does not ignore.** The worktree is cut from a commit, so its ignore
  rules can differ from the source checkout's working tree.
- **Copy into a linked directory.** Links are made first, as Claude Code orders them, and a
  `.worktreeinclude` match under one (a `.env` shipped inside `node_modules/pkg/`) is `skipped`.
  Copying first would create a real directory the link could never replace.
- **Leave an unignored link behind.** `node_modules/` with a trailing slash ignores a directory but
  **not a symlink to one**. The link would sit untracked, one `git add` away from a commit, and would
  make `git worktree remove` refuse. It is removed and reported instead. Write `node_modules` without
  the slash.
- **Read "could not check" as "nothing declared."** Invalid `settings.json`, a non-array
  `symlinkDirectories`, a `.worktreeinclude` that is not a regular file, or git failing all refuse.

Do not list `.claude/settings.local.json`. Measured against Claude Code 2.1.273, `EnterWorktree`
does not copy it, because it resolves a worktree's local settings to the main checkout. A copy would
be a stale overlay that could bring back a permission you have since revoked.

`provisionWorktree({ repoDir, worktreeDir })` is exported from the package root for scripts that
create worktrees with plain `git worktree add`.

Two refusals worth knowing, both cases where proceeding would be worse than stopping:

- **An unresolvable base is refused, never defaulted.** The ladder is `origin/HEAD`, then
  `origin/main`, `origin/master`, `main`, `master` — and if none exist it asks for `--base` rather
  than guessing. Guessing `main` in a `master` repo cuts the branch from the wrong history, and that
  only surfaces at review.
- **An existing branch or occupied path is refused before git is asked**, so the message names the
  situation instead of surfacing git's.

The base ladder is duplicated in `hooks/lib/default-branch.mjs`, which `src` cannot import. A test
drives the hooks version with a recording double and asserts both probe the same refs in the same
order, so the copy is held by a failing test rather than by a comment asking someone to keep them in
sync.

Pair it with the `warn-stale-worktree` SessionStart hook: this command creates worktrees, that hook
catches the ones left behind — a stale worktree carries its own `CLAUDE.md`, and stale instructions
on disk still instruct.

The `gitignore` audit check requires `.claude/worktrees` to be ignored — **without a trailing
slash**, which matches directories only and so misses a worktree materialised as a symlink. 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 `.claude/worktrees` directory already on disk can change
the verdict.

## `verify` — checking a ticket's claims against the record

Every ticket ends with an agent-authored `## Implementation summary` asserting `Tests: N added` and a
green gate. Nothing checks that assertion. The events log **can**: it is written by a `PostToolUse`
hook that fires on actual command execution, so an agent cannot produce a `test: passed` event by
claiming tests passed. That makes it testimony from a process outside the agent's control.

**Read that narrowly.** A `test: passed` row witnesses that a test COMMAND RAN and the tool call
succeeded — never that the tests themselves passed, and it is testimony only about the command the
shell actually reported on. Rows written before `tkt-31f693ac8bb0` witness even less: the writer
derived every outcome from a field that did not exist, so a failing gate recorded nothing and a
masked one recorded `passed`. `verify` reports those as `unknown` rather than reading them, keyed on
an `outcomeFrom` marker the fixed writer stamps on each row — not on a date, which would start
trusting a machine that never upgraded.

```bash
npx ticket-workflow verify                       # all closed tickets
npx ticket-workflow verify <id>                  # one ticket, whatever its status
npx ticket-workflow verify --project <name>      # one project
npx ticket-workflow verify --all --json          # every status, as data
```

It does not ask "did the gate run". It asks the narrower, answerable question: **does what the ticket
says match what was observed?**

| outcome | meaning |
|---|---|
| `ok` | claim and record agree — including `Tests: none — …`, which asserts nothing the record can contradict |
| `violation` | the summary claims tests, and no passing test milestone was recorded, *while telemetry was demonstrably live for that ticket* |
| `unknown` | not judgeable: telemetry never observed it, its log lost lines, it carries no `Tests:` line, or its milestones predate the outcome fix |

**Report-only, and exit 0 even with violations.** A violation is a *discrepancy*, not proof of
misconduct — the gate may have run under a command the hook does not recognise. Until that rate is
known and defensible, failing a build on it would assert more than the data carries.

**The coverage line comes first, always.** A findings list printed above its own coverage invites
reading absence-of-findings as compliance, and an empty run is reported as *"not a clean result — it
is an empty one"* rather than as zero violations. What this offers is a stated boundary on what it
vouches for, so that boundary is the headline.

Scope, plainly: this raises confidence in **process claims** only — whether the gate ran, in what
order, on a branch, before the PR. It cannot establish that the code is correct, that the tests were
meaningful, that a bug repro was written first, or that the reasoning was sound. Never sell it as
more than that.

## Development

```bash
npm install      # runs the prepare build
npm run typecheck
npm test
npm run build    # emit dist/
```