ticket-workflow-mcp
Allows tracking branches, commits, and other git operations as part of the ticket workflow pipeline.
Allows managing pull requests and repository interactions within the ticket pipeline.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ticket-workflow-mcplist all tickets"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
ticket-workflow
A local-first, per-repo ticket board and git/PR pipeline, packaged so any repo
can adopt it by adding a dependency plus a little config — with no coupling to
any other repo. Each consuming repo owns its own tickets/ and events/.
It ships three pieces:
MCP server (
ticket-workflow-mcp) —list_tickets,get_ticket,start_ticket,create_ticket,update_ticket,record_review,archive_ticket,delete_ticket. Tickets are markdown files (frontmatter + body); the board is the filesystem, no database.Archiving is its own tool rather than an
update_ticketstatus:archivedis deliberately absent from that tool's status enum, so a ticket can't be retired by mistyping a field on an ordinary edit, and the tool stays out of any name-allowlisted agent toolset. It is reversible —update_ticketback tobacklog, andlist_ticketswithstatus: "archived"to find it again.Hooks (
hooks/) — aPreToolUseguard (guard-bash.mjs) that blocks whole-tree staging and commits/pushes tomain; aPostToolUsetracker (track-steps.mjs) that records pipeline milestones (branch, typecheck, lint, test, commit, PR) by watching the commands you run — each attributed to the ticket named by the branch of the repo the command actually ran in, so acdor--prefixinto another repo is credited to that repo's ticket rather than the session's, and a directory it cannot resolve records nothing at all. The events directory is still resolved once from the hook's own environment, never per milestone, so that attribution is end-to-end only on a shared board (BOARD_DIR_OVERRIDE); with a board per repo the row lands in the session'sevents/under the other repo's ticket id, where nothing joins to it; and an opt-inPreToolUseguard (guard-ticket.mjs) that blockscreate_ticketso new tickets are authored by a metered local-LLM intake agent instead of by the model driving the session; and aSessionStartstaleness warning (warn-stale-worktree.mjs) that reports when the session opened in a git worktree whoseCLAUDE.md/AGENTS.md/.cursorruleshas since changed on the base branch — a stale instruction file does not fail, it instructs. It only writes to stdout, never blocks, and stays silent outside a linked worktree; setWORKTREE_STALE_THRESHOLDto tune the commit-distance fallback (default 15,0to always report). Also aPreToolUseguard (guard-review-target.mjs) that refuses a/code-reviewwith no explicit target when the session's own repository has no diff to review, because a wrong-repo review reads exactly like a clean one. And aPreToolUseguard (guard-subagent-gates.mjs) that stops a subagent runninggit commit,git push,gh pr createorgh 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. Wireguard-ticketonly if you want that policy — the others suit any consumer.CLI viewer (
ticket-workflow) —listandshow <id>, rendering a ticket's pipeline from the same reducer the web board uses.
The pipeline a ticket flows through: Started · Branch · Typecheck · Lint · Tests · Review · Commit · PR · QA · Done.
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: llm-backlog
Backup-on-write / recovery
tickets/ is the source of truth and has no built-in history (consumers typically
gitignore it), so before updateTicket overwrites a ticket body the prior full
file — frontmatter + body — is snapshotted to:
<board>/tickets/.history/<id>/<ISO-timestamp>.mdOnly body-changing updates snapshot; a structured-only edit (status, priority, …)
writes nothing to .history/. Successive edits accumulate one snapshot per prior
version, and list_tickets ignores .history/, so snapshots never surface on the
board.
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? |
| structurally unreadable — bad JSON, missing or wrong-typed required keys. History is lost. | yes |
| well-formed, but names a | bump the pin |
They are separate because the track-steps hook is installed once per machine
while readers are pinned per repo. A newer hook writing a step id added after a
consumer's pin is routine; folding that into skipped would report every healthy
log as damaged for as long as the pin lagged.
Neither count is logged to stderr — this path is polled (a board re-reads it every few seconds while a ticket is on screen), so reporting is left to the caller that decides to surface it.
One deliberate exemption: a non-empty final line that won't parse is not
counted. appendEvent terminates every complete record with \n, so an
unterminated tail is a write in flight, and counting it would flap between polls.
Once any later event lands it is no longer last, and it is counted from then on.
Breaking in 0.10.0:
readEvents()previously returnedTicketEvent[]. It now returns{ events, skipped, unrecognized }, andTicketEventsResponsegained both counts as required fields — optional ones would let a consumer default them with?? 0and report a damaged log as healthy.
Unassigned tickets
A ticket with no project is absent from every project-filtered view, so a work
queue that selects with list_tickets({ project }) can never pick it — it is not
mislabelled, it is out of the queue. list_tickets therefore reports
unassigned: [id, …] in its envelope, plus a note naming the ids.
Like unreadable, it is board-wide and not narrowed by your filters — a
project filter would exclude the very tickets being reported, which is the bug
itself.
It covers open tickets only. done and archived are past selection, so an
unassigned one there is not lost work, and at least one such ticket is deliberately
project-less because it spans several repos. A field that flagged those on every
call is a field nobody reads by the second week.
Two more bounds, for the same reason:
Empty when the board uses no projects at all.
projectis optional, and a single-repo board has nothing to partition — every ticket would be reported, on every call, with nothing wrong.Capped at 20 ids, with the true total in
note. A truncated list must never read as the whole story.
A project of whitespace counts as unassigned: it is stored verbatim while a caller's blank filter is normalized to "no filter", so no filter value can ever match it — strictly worse than an absent project, and invisible without this.
Dropping an unresolvable project on the agent write path is deliberate (the intake model hallucinates project names, and projects are derived from ticket values, so a name that matches nothing is dropped rather than minted). This field is what reconciles the result afterwards, so the drop does not depend on someone reading a warning that has scrolled past.
Consuming it in a repo
Add the dependency (public, pinned by tag):
// package.json
"devDependencies": { "ticket-workflow": "git+https://github.com/mcinerneyjake/ticket-workflow.git#v0.11.0" }Wire the MCP server (.mcp.json) and the hooks + allowlist (.claude/settings.json);
see a consuming repo's config for the exact shape. Run npx ticket-workflow show <id>
to view a ticket's pipeline.
Wiring the hooks from an install
Each hook is available both as a script path and as a subpath import, so a consumer can wire the installed copy instead of vendoring one that then drifts:
// .claude/settings.json — run the installed file directly
"command": "node node_modules/ticket-workflow/hooks/guard-bash.mjs"// or import it, to set consumer-specific policy before handing over
import { main } from 'ticket-workflow/hooks/guard-bash.mjs';
main();Both forms above are fail-open if the install is absent or stale, and neither is what you want for a guard you rely on.
node <path-that-does-not-exist>exits 1, and only exit 2 blocks — so a missingnode_modulesreads as allow. The baremain()call has the same hole: if the package resolves but exports no callablemain, the throw exits 1, again an allow. Wrap both, as Installing it once per machine does.
The exported subpaths are exactly the five hook files above. 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 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/), becauseimport('ticket-workflow/…')is a bare specifier, resolved by walking up from the launcher's own directory. Put it besidesettings.jsoninstead and it throwsERR_MODULE_NOT_FOUNDwith the install fully present — which, failing closed, wedges every Bash call on the machine.
// ~/.claude/tools/hooks/run-hook.mjs <hook-name> <closed|open>
const [name, direction] = process.argv.slice(2);
// Validate up front. `direction === 'closed' ? 2 : 0` would make a one-character typo in
// settings.json silently disarm the guard, so an unusable direction blocks.
if (!name || (direction !== 'closed' && direction !== 'open')) {
process.stderr.write('[run-hook] BLOCKED: usage: run-hook.mjs <hook-name> <closed|open>\n');
process.exit(2);
}
try {
const { main } = await import(`ticket-workflow/hooks/${name}.mjs`);
if (typeof main !== 'function') throw new TypeError('no callable main — stale install?');
await main();
} catch (err) {
process.stderr.write(`[${name}] could not run: ${err?.message ?? err?.code ?? 'import failed'}\n`);
process.exit(direction === 'closed' ? 2 : 1);
}
// Every hook here ends in process.exit(); returning instead is a contract violation (a stale pin),
// and falling off the end exits 0 — an allow. Guards must not resolve that permissively.
if (direction === 'closed') process.exit(2);Fail direction is per event. A guard that cannot run must block; a reporter that cannot run has nothing to block and must not wedge the session — but note it exits 1, not 0:
hook | event | cannot load → |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| closed (exit 2) |
|
| open (exit 1) |
|
| open (exit 1) |
Why 1 and not 0. Exit 0 is success, and its stderr is not surfaced; a non-zero, non-2 exit is a non-blocking error, and its stderr is. Exiting 0 would therefore make a dead reporter quieter than no launcher at all — an unlaunched broken hook exits 1 and is at least visible. 1 keeps it non-blocking and visible.
Two honest limits on that table:
It describes what happens when a hook cannot load. It is not a claim about each hook's internal behaviour:
guard-bash, once loaded, deliberately exits 0 on a payload it cannot parse — but several of its own rules then fail closed, wherever an unknown would otherwise silently disable the rule it guards. An unresolvable current branch, a protected branch it cannot identify, a directory move it located but could not name, andgit switch -, whose destination is unknowable so it is assumed protected. Read that as a design principle, not a closed list, and never as a count — this sentence has claimed one, then three, and each was an undercount found by review (tkt-3006d09810f7). "Anything unexpected fails open" is not a safe reading either:hasRemotereturns true on any error, and an unreadable directory falls back to the session repo's branch, which can block a commit that was never going near a protected branch. Check the code.guard-ticketandguard-review-targetdo fail closed internally.guard-subagent-gatesis split: it fails closed when it knows the rule applies (a subagent whose command it cannot read) and exits 1 when it cannot even establish that (an unparseable payload) — blocking there would wedge every main-thread command over a case the rule never covers, so it is loud rather than silent.The
openrows are a genuine gap. Nothing here detects a reporter that stopped recording; the stderr is visible only if someone is looking. Treat "are my hooks actually running?" as a question needing its own check.
track-steps must be wired to BOTH events, to the same command. PostToolUse fires only when a
tool call succeeds; a failed one is dispatched to PostToolUseFailure. Wire only the first and the
hook is structurally incapable of ever recording a failure — it will not mislabel them, it will never
see them, and the log fills with passed rows that look like a clean record rather than a partial
one (tkt-31f693ac8bb0; 4,635 command milestones with zero failures among them). The outcome comes
from which event was delivered, so no other configuration expresses this.
"PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }],
"PostToolUseFailure": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "<writer>" }] }]This is not the duplicate-writer hazard below: those are two writers racing on one event, whereas these are one writer on two disjoint events, and exactly one of them fires per tool call.
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:
Move the development checkout's
hooks/anddist/aside. The guards must still block, still allow on a feature branch, and the MCP server must still answer. Anything that changes means the machine was still depending on that tree.Move the install aside. Each
closedrow must exit 2 and eachopenrow exit 1. This is the only step that exercises the launcher's reason to exist — with the install present, a mistyped fail direction is never even read, so step 1 alone would pass with the guard disarmed.
doctor — checking that the wiring above is actually live
Everything in the two sections above lives in machine-local, unversioned files. No repository's test
suite can see them, so nothing detects that a hook drifted, that an install half-upgraded, or that
the telemetry writer stopped recording. doctor reads that wiring and reports on it:
npx ticket-workflow doctor # from any repo
npx ticket-workflow doctor --strict # UNKNOWN counts as failure (for a gate)
npx ticket-workflow doctor --no-mcp # skip starting the MCP servercheck | what it answers |
| how many |
writer-uniqueness counts PostToolUse writers only. It does not check that
PostToolUseFailure is wired, so a machine missing that subscription is reported ok while being
structurally incapable of recording a failure. Verify that half by hand until a check exists.
| hook-wiring | does any vendored hook copy differ from the one this package ships |
| pin | is more than one version of this package live at once |
| mcp | does the configured server start and answer initialize, at what version |
| board | do the MCP server and the telemetry writer point at the same board |
| protected-branch | which branches guard-bash will actually protect here |
| reporter-liveness | when did the hook last write a step only it can write |
| toolchain | which of this repo's gate steps can be recorded at all |
Every check returns OK / MISMATCH / UNKNOWN, never a boolean. UNKNOWN is the point: on a machine
with no ~/.claude — CI, a container, a fresh clone — the user-scope checks are genuinely
unanswerable, and reporting that as OK is the fail-open shape this package exists to reject. The
exit code is 0 unless a check MISMATCHes; --strict also fails on UNKNOWN, which is what a gate wants.
Two things it deliberately does not claim. It cannot see a running MCP server — a session's
server is not observable from another process — so it answers the weaker, checkable question of
whether a new session's server would start. And a hook-wiring OK means the wired files match what
this package ships, not that the guards are correct; that is what their own suites are for.
worktree — one checkout per session, from any repo
Claude Code's own worktree support isolates only the repo a session was started in. Where one board serves every repository, sessions start in the board's repo and edit sibling repos — which get no isolation at all. That is not theoretical: two sessions shared one checkout, and a branch switch by one moved HEAD out from under the other while it held a full ticket's work uncommitted.
# From a repo that HAS this package installed:
npx ticket-workflow worktree <ticket-id> # branch named from the ticket
npx ticket-workflow worktree --branch feat/x # when the board is not reachable from here
npx ticket-workflow worktree <id> --base origin/release # explicit base
npx ticket-workflow worktree <id> --repo ../other-repo # operate on ANOTHER checkout
# From a repo that does not depend on it — including non-Node repos:
npx -y github:mcinerneyjake/ticket-workflow#<tag> worktree <id> --repo .Use the git spec, not the bare name, from a repo that does not have this installed. This package
is not published to npm — consumers depend on it by git tag — so npx ticket-workflow cannot
resolve there. Worse, the bare name is a namespace someone else could take: if a package called
ticket-workflow is ever published, npx would silently run theirs. (tkt-bd0b84d61db0 tracks
publishing, which would change this; until then the git spec is the only correct remote form.)
The --repo flag is the other answer, and usually the better one: run it from the repo that already
has the package and point it at whichever checkout needs the worktree.
The worktree lands in .claude/worktrees/<name>, inside the repo. That is load-bearing rather
than tidy: Node resolves upward, so the gate runs in a fresh worktree with no install. A sibling
directory would need a full node_modules per worktree.
Two refusals worth knowing, both cases where proceeding would be worse than stopping:
An unresolvable base is refused, never defaulted. The ladder is
origin/HEAD, thenorigin/main,origin/master,main,master— and if none exist it asks for--baserather than guessing. Guessingmainin amasterrepo cuts the branch from the wrong history, and that only surfaces at review.An existing branch or occupied path is refused before git is asked, so the message names the situation instead of surfacing git's.
The base ladder is duplicated in hooks/lib/default-branch.mjs, which src cannot import. A test
drives the hooks version with a recording double and asserts both probe the same refs in the same
order, so the copy is held by a failing test rather than by a comment asking someone to keep them in
sync.
Pair it with the warn-stale-worktree SessionStart hook: this command creates worktrees, that hook
catches the ones left behind — a stale worktree carries its own CLAUDE.md, and stale instructions
on disk still instruct.
The gitignore audit check requires .claude/worktrees 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.
npx ticket-workflow verify # all closed tickets
npx ticket-workflow verify <id> # one ticket, whatever its status
npx ticket-workflow verify --project <name> # one project
npx ticket-workflow verify --all --json # every status, as dataIt does not ask "did the gate run". It asks the narrower, answerable question: does what the ticket says match what was observed?
outcome | meaning |
| claim and record agree — including |
| the summary claims tests, and no passing test milestone was recorded, while telemetry was demonstrably live for that ticket |
| not judgeable: telemetry never observed it, its log lost lines, it carries no |
Report-only, and exit 0 even with violations. A violation is a discrepancy, not proof of misconduct — the gate may have run under a command the hook does not recognise. Until that rate is known and defensible, failing a build on it would assert more than the data carries.
The coverage line comes first, always. A findings list printed above its own coverage invites reading absence-of-findings as compliance, and an empty run is reported as "not a clean result — it is an empty one" rather than as zero violations. What this offers is a stated boundary on what it vouches for, so that boundary is the headline.
Scope, plainly: this raises confidence in process claims only — whether the gate ran, in what order, on a branch, before the PR. It cannot establish that the code is correct, that the tests were meaningful, that a bug repro was written first, or that the reasoning was sound. Never sell it as more than that.
Development
npm install # runs the prepare build
npm run typecheck
npm test
npm run build # emit dist/This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables management of GitHub issues, milestones, projects, and labels for a single repository via MCP tools.4671ISC
- FlicenseNot gradedqualityCmaintenanceMCP server for managing a project backlog as Markdown files in Git, enabling AI agents to read, create, and update tasks programmatically.2
- AlicenseNot gradedqualityAmaintenanceEnables agents to manage an event-sourced, git-backed ticket store through MCP tools, supporting operations like ticket creation, claiming, and transitions.3Apache 2.0
- AlicenseAqualityAmaintenanceRepository-native protocol and MCP server for coordinating work items, documentation, changelogs, and project memory between humans and AI agents, using Markdown files in a Git repository as the canonical data source.6302MIT
Related MCP Connectors
Manage feature requests, votes, roadmaps, and changelogs from any MCP client.
A MCP server built for developers enabling Git based project management with project and personal…
Create, update, and publish changelog entries on your Patchlog changelog from any MCP client.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/mcinerneyjake/ticket-workflow'
If you have feedback or need assistance with the MCP directory API, please join our Discord server