Team handbook · audit plugin

Audit Plugin Handbook

The manifest is the plan. The evidence ledger is what actually ran. Commands move work through both, the board mirrors the plan, and the panel is the same levers with a mouse.

Written against the plugin, not against one repository: every command, flag and default here is the same wherever it is installed, and a value you supply appears as <org>, <project> or <phaseId>. This page names no version on purpose — it carried two and they disagreed. Run /plugin list for yours; a flag named here that it rejects means the install is behind.

01

Install & update

Two commands, typed into Claude Code rather than a shell: the first registers the marketplace this plugin ships from, the second installs the plugin out of it.

First session? This page is the reference, not the introduction — read QUICKSTART.md instead: install to a rendered report, one page, in order.

/plugin marketplace add AleksandarBisevac/claude-plugins
/plugin install audit@quality-gates

Requirements are deliberately thin: Python 3.8 or newer, reachable as python3, python or py; on Windows, run inside Git Bash. There is nothing to pip install — a guard that needs a dependency is a guard that is switched off on most machines.

Installing arms hooks everywhere

The guard hooks activate in all your projects, not just the one you installed from. What the plan gate does there is graded on evidence: with no manifest it only observes, with a manifest and nothing running it warns, and it denies an out-of-plan edit only while a phase has work in flight — a phase that only awaits sign-off does not hold it. So installing does not start refusing edits in repositories that never opted in. /audit:doctor prints the tier and its basis:

[OK     ] plan gate      deny - phase P2 has work in progress, so edits are held to the running plan

The refusal says different things to you and to a subagent. You get the two ways forward: widen the task's scope, or the single-use human bypass. A subagent gets neither — it is told to stop and report the path to the orchestrator, because it may not edit the manifest and has no way to ask you. It also cannot edit the plan to get around a refusal: the exemption that lets the orchestrator do its bookkeeping does not extend to a task, since a task that edits the plan it is judged by is one nobody can review. When a scope really is too narrow, widen it and tell the running agent to carry on — it does not need to be replaced, and replacing it throws away everything it has read.

Scope decides who else gets it

/plugin install takes a --scope. The choice is about where the enabling record is written, not about which repositories the plugin can read.

ScopeWritten toWho gets it
user~/.claude/settings.jsonevery repository you open; nothing lands in the repo
projectthe repo’s .claude/settings.jsoncommitted, so teammates are prompted to install it too
local.claude/settings.local.jsononly you; the file is gitignored

Installing enables as a side effect; the separate enable verb exists only to switch a disabled plugin back on.

Updating, and why it is three commands

/plugin marketplace update quality-gates
/plugin install audit@quality-gates
/reload-plugins

Each one undoes a different kind of staleness, which is why dropping one usually leaves you on the old version wondering why a documented flag is rejected:

  • marketplace update re-reads the marketplace, so it learns a release exists.
  • install bumps the cached copy and the install record. These drift apart: a record can pin a version long since superseded while a newer copy sits in the cache.
  • reload-plugins makes the new commands appear in the session you already have open — the command list is read once, at startup.
A project-scope install pins a version

It does not follow later releases: a repo set up months ago can still be running the version it was installed with while user scope has moved on. Re-run the install in that repository to bump it.

/plugin list      # audit@quality-gates, enabled
/audit:status     # renders the manifest, or points you at /audit:init

If /audit:* comes back not recognized, it is nearly always one of two things: installed but not enabled, or only ever installed at project scope for some other repository. The three commands above resolve both, and /audit:doctor reports what the hooks will actually use once they do.

02

The model in one minute

The plan hangs off one file — the manifest. It holds phases, each holding tasks, plus bugs and parked proposals. Commands read and write that file; nothing else is the source of truth about what is meant to happen.

What did happen is kept somewhere else. Every gate run is appended to an evidence ledger beside the manifest, and the manifest caches only a pointer — testEvidence — at the run that last exercised each task and each phase. The pointer is disposable: deleting it is always safe, the next run writes it back, and absent means no run was recorded, never “failed”. Running phases is where that gets read.

Where work lives, and how it moves

materialize

run

all done

merge

push

pull

proposals[]
parked

phase P0
tasks T1..Tn

task

phase sign-off
review · tests · invariants

phase done

Azure DevOps
work items

Solid arrows are the manifest's own lifecycle — they happen whether or not a board exists. Dotted arrows are the ADO connector, and they are the only two places anything leaves or enters this repo. A phase never reaches done by being closed on the board; it reaches it through sign-off, and the board is told afterwards.
Read the badges

Every command below carries what it touches: read-only writes manifest writes ADO takes lock

03

Setup, in brief

The manifest is generated once, before the pull request, so most of you will never run this. It is here so the output makes sense when you see it.

/audit:init writes manifest

Interviews for scope, fans out read-only explorers across the codebase, synthesizes a plan, and shows it for approval before writing anything. What you approve becomes live phases; what you decline is parked as proposals rather than thrown away. It also asks how to store the manifest: one file, or one per phase (sharded) for parallel worktrees.

/audit:doctor [--deep] read-only

The first thing to run when something behaves oddly. Reports the interpreter the hooks use, git root, config, manifest and shard integrity, the active plan-gate tier, build runners, whether hooks have ever fired, whether the capability policy is inert, whether every testEvidence pointer names a run this checkout holds, and what earlier runs left behind in git — worktrees whose branch already reached its parent, records pointing at directories somebody deleted by hand, and branches it could not compare. --deep adds the journal chain.

It reports the residue and never reaps it. The remedy it prints is the read-only command, and for a worktree this plugin did not create it prints remove --path <dir> — a diagnostic that deleted somebody's working directory would be the last one anyone let run.

/audit:version [--offline] [--json] read-only

Which build is running, and whether a newer one is published: the running copy's version, what the marketplace it came from offers and whether that marketplace auto-updates, every installed copy by project, and the newest release on the plugin's repository — each line naming where it was read. When a newer release is out it prints the update commands and exits 1; when the release feed cannot be reached it says so rather than calling you up to date. The feed is the plugin's one outbound request, and --offline skips it.

Auto-update is Claude Code's, not the plugin's. For a third-party marketplace it is off until you turn it on in /plugin → Marketplaces, or declare "autoUpdate": true for the marketplace in settings; /audit:version tells you which it is.

A gate that cannot run is not a gate that passed

meta.buildCommands names the commands a phase's sign-off runs, and /audit:doctor checks each one's runner against this machine's PATH. It warns per runner, because a missing one is infrastructure failure, not a red test — one verdict says “your change is wrong” and the other says “nothing was measured”. And the gate is chosen per phase, not per manifest: each phase's testGate lists which keys must pass, so where a repo's Python runner is absent a phase naming only the JS keys still runs a gate that grades. Read the warning against the phase you are about to start.

Do not answer a missing runner with a new path

Measured on a conda project: buildCommands.pytest read .venv/bin/python and no .venv existed anywhere. The repair was not a different command — the environment the project already declares in environment.yml, carrying both missing runners, had simply never been created on that machine. Repointing the key would have hidden that.

04

Running phases

A phase is the unit of work and the unit of review. Run a whole phase, one task, or “whatever is next” — each of them previews first with --dry-run.

/audit:phase <phaseId> [--dry-run] writes manifest

Every ready task in the phase — in parallel where that is safe — then Phase sign-off in a fixed order: review, the test gate, an invariant check, an optional runtime boot, the sign-off commit, the merge. A phase already done is refused and points you at /audit:review.

/audit:phase P0 --dry-run   # see the plan
/audit:phase P0             # then run it
/audit:next [--dry-run] writes manifest

The next ready task — phase order, then task-id order — then what became ready.

/audit:run <taskId> [--dry-run] writes manifest

One task by id, with guards on done, blocked and in_progress, plus blocker checks.

/audit:resume writes manifest

The in-progress phase, continued from the first uncommitted task — the answer to “my session died mid-phase”.

/audit:review <phaseId> writes manifest

Re-runs sign-off on demand, after fixes, without re-running the tasks.

/audit:worktree <list|add|remove|sweep> [phaseId] writes git

The git worktrees this plan owns. add sets one up for a phase so two people can run two phases at once: open a session in the printed path and run /audit:phase there. Sharded manifests merge back conflict-free, which is why that layout exists.

sweep is for the ones that outlived a run. It is read-only until you pass --apply plus a verb, and it reaps a worktree only when four things hold. Two are about permission: this plugin must have created it — add writes that record inside the worktree's own admin directory, where git deletes it along with its subject — and the phase must be settled: signed off, no task still open, mergedAt stamped. Only then do the two about safety apply: the branch is contained in its parent, and the tree is clean. Every one of them fails closed.

A worktree you opened yourself is not the plugin's to remove, and by branch name and merge state it is indistinguishable from one the plugin made — which is why nothing but an explicit record is allowed to decide. Those are reported and left alone; remove --path <dir> is how one comes down, named by the person who wants it gone. The clean-tree rule matters for the same reason in the other direction: removal also destroys ignored files (.env, node_modules) that git status never mentioned.

/audit:status [--view …] [--gate] [--fail-on …] read-only

Phases, tasks, bugs, the ready-now list, what each pending task waits on, and a tests column carrying what the run that last exercised it said. No locks, no mutations — safe mid-phase.

It lists the work in hand, not the whole plan. Finished phases — done and cancelled — fold into one line saying how many phases and task rows it left out; --view all brings them back, --view archived shows only those. A plan whose phases are all finished opens on everything, because an empty table answers nothing. Same three views, and the same default, as the report's toggle and the panel's Overview select. The render is printed verbatim, so what it costs you is its length — and an archive only grows.

  P2   Input validation           In progress [###.........] 1/4  audit/p2-input-validation
     [x] P2.1   Validate the checkout payload                Done         sonnet  -           passed     c4a11b9
     [~] P2.2   Sanitize the product-search query            In progress  opus    -           -          -
     [!] P2.3   Escape server-rendered template output       Blocked      sonnet  -           failed     -
     [ ] P2.4   Add zod schemas for cart mutations           Pending      haiku   -           -          -        READY

That column appears only once a task in view has recorded a run, so a plan that has never run a gate renders as it always did, and - inside it means no run was recorded — not a failure.

The gate is bracketed, and the run is recorded

The gate is never run by hand. It goes through run-test-gate.py, which brackets it and answers the two questions an exit code cannot: did the gate change the working tree (git status --porcelain before and after) and did anything actually run. Both failures used to be exit 0. --record then writes the run into the evidence ledger and points the plan at it.

# what /audit:run does for a task, and /audit:phase at sign-off
python3 "$CLAUDE_PLUGIN_ROOT/scripts/governance/run-test-gate.py" \
    docs/audit/audit-plan.json P2 --task P2.3 --record

  evidence: recorded 2026-07-15T09:40:00Z.13be6d
  pointer:  P2.3 now names it

Those last two lines are separate facts. Rows land in <manifest dir>/evidence/<YYYY-MM>.<writer>.jsonl — one file per writer per month, so two worktrees never conflict on merge — and the directory is meant to be committed: it is the record somebody hands to a client (evidence.dir moves it). A row keeps what ran, what it answered and what it observed; never a runner's output.

A refused pointer is a designed state, not an error

The ledger row is written first and the plan is pointed at it second, so the only reachable partial state is the harmless one: a run that happened with nothing yet naming it. When another live session holds the phase lock, the second line reads pointer:  NOT updated - the phase lock is held by another live run (…). Do not re-run the gate to chase it — the repair reads the ledger against the plan and runs no tests at all:

python3 "$CLAUDE_PLUGIN_ROOT/scripts/governance/run-test-gate.py" \
    docs/audit/audit-plan.json P2 --reconcile

[run-test-gate] reconcile: 7 subject(s) in the ledger
  already:  phase P1
  already:  task BF1.1
The runs a commit would otherwise never carry

A task commit stages the evidence directory, so a run that fails at attempt one and succeeds at attempt two is already durable, failure included. What is not durable is a run whose task or phase never commits at all — a task going blocked, an infrastructure stop, a red sign-off gate. commit-task-work.py --state covers exactly those points, staging the phase's manifest file, the manifest index, the journal and the evidence directory — and never the task's own files: the index is read back and compared against that list, and anything else refuses the commit rather than riding along. A failed task's code stays uncommitted; only the record of the failure lands.

Reading a verdict

The verdict is one word from the run, read off the ledger rather than the cached pointer. Only passed and empty-gate can sign work off.

WordShown asMeans
passedPassedevery command exited 0, the tree is unchanged, and at least one check ran
failedFaileda command ran to completion and came back non-zero
no-checksNo checks ranexit 0 and not a verdict: every step reported zero checks
timed-outTimed outa step overran its bound and was torn down; its exit code is an artefact of the kill
cancelledCancelledthe run stopped rather than answered
could-not-runCould not runthe runner never started — a missing binary, not a red test
empty-gateEmpty gatethe phase declares no gate, so sign-off rests on review alone
—No gate configuredneither the task's tests.gate nor its phase's testGate exists: nothing could have run
—No evidencea gate is configured and no run has been recorded. Not a failure
—Pointer without evidencethe plan names a runId this checkout's ledger does not carry — the evidence directory was not committed, or was pruned

A run also carries observations, which are not verdicts and sit beside the badge rather than inside it: not “what did it say” but “what else is true about the run that said it”. Each is three-valued, and null is never 0.

MarkerMeans
tree mutatedthe gate rewrote files. A gate is a measurement — do not commit on that run
tree unknownno comparison was made: git could not describe the tree, or the run was interrupted
no overlapthe run named paths, and none is a file this work declares. A runner that prints suite paths still counts: tests/parser.spec.ts is matched to the src/parser.ts it is named after, across directories — only for test-shaped paths and only onto the exact stem, because a false overlap would tell you your work was exercised when it was not
coverage unknownthe runner printed no paths, so overlap is not knowable from its output
checks unknownthis runner reports no check count — which is not the same as reporting zero

Every row below is a real subject in the published example report, acme-store.

You seeIt means
P2.1 — Passed, marked tree mutated Both at once: every command exited 0, and the gate rewrote src/checkout/validate.ts on its way through. Do not commit on that run; repair it with the read-only spelling of the check, via /audit:phase retarget.
BF1.1 — No checks ran npm test exited 0, and pre-commit run --all-files exited 0 having reported zero hooks. A gate that skipped everything and one that verified everything are the same exit code.
P2.3 — Failed, marked no overlap Two facts: npm test came back non-zero, and the run named none of the file this task declares. The report's Earlier runs disclosure carries the attempts before it.
BF1.2 — Passed, no markers A clean row: tree unchanged, both declared files named by the run, and a real check count behind the verdict. Nothing left unknown.
P2.2 — No evidence, - in the terminal Its phase declares lint, test, typecheck, so a gate would grade this task — and none has run against it. A gap in the record, not a red test.
P4.1 — No gate configured P4 is documentation and declares no testGate. Running the gate says so rather than passing: [run-test-gate] P4 declares an EMPTY gate: nothing here can prove it done, so sign-off rests on review alone
Gating a build on what the tests said

--fail-on gained two conditions. failing-tests trips on a task or phase whose pointer names a run that cannot sign work off; no-test-evidence trips on a done task or phase carrying no pointer at all. Neither is in the default set, and neither reads a silence as a failure — a plan that has never recorded a run trips neither.

/audit:status --gate --fail-on invalid,failing-tests

GATE FAILED: failing-tests (2 subject(s): task P2.3 failed; task BF1.1 no-checks)

Both read the manifest and nothing more; whether that runId still resolves is /audit:doctor's question. /audit:status --help prints the whole condition list, rendered from the same tuple the gate evaluates, and docs/examples/azure-pipelines.yml shows the shape of a pipeline built around it.

05

Shaping phases & tasks

The plan is not frozen. /audit:task and /audit:phase change its shape, and every verb records what it did — these are journalled operations, not edits. Every answer is a flag, and the dialogue only covers what you did not pass, so a fully-flagged call is non-interactive.

/audit:task add "<title>" [--phase <id>] […] writes manifest takes lock

Allocates the id, initializes every orchestrator field, updates fileIndex, and revalidates before returning.

/audit:task add "Extract the KPI query into a service" --phase P2 \
  --files src/kpi/query.ts,src/kpi/service.ts --tests-mode regression --risk med
/audit:task scope <taskId> [--files …] [--gate …] […] writes manifest takes lock

Re-scopes a task that already exists — files, gate, test mode, risk, edges. The alternative was cancel plus a fresh add, which lost the id and everything hanging off it.

/audit:task move <taskId> --to <phaseId> writes manifest takes lock

Renumbers the task into the target phase, rewrites every reference to it, and records a chained task.move row. You do not chase the id by hand.

/audit:phase add "<title>" --outcome "<what success is>" […] writes manifest takes lock

Adds a phase to a plan that already exists, without re-running /audit:init. --outcome is required — a phase with no statement of what success is cannot be signed off against anything.

/audit:phase add "Cache the catalog query" --outcome "The catalog page issues one query" \
  --area backend --gate lint --gate test --blocked-by P2
/audit:phase retarget <phaseId> [--gate …] [--gate-clear] [--outcome …] [--rename …] writes manifest takes lock

Changes what a phase is measured by — gate, area tags, desired outcome. This is the repair when a gate turns out to rewrite the tree. Refused on a phase already done or cancelled, or signed off and awaiting its merge, whose sign-off was given against the gate it had.

--rename changes the phase title, and it is the one option here with a deadline: the title is what the branch name is slugified from, so it may be corrected while the phase is still waiting and is refused once the branch has been cut. After that the title is the record of what the branch was cut for.

/audit:phase signoff <phaseId> --verdict passed|skipped --summary … [--review-outcome …] writes manifest takes lock

Records a phase’s sign-off once its review and the gates sign-off runs have passed: the verdict, the review’s outcome, the summary, the run’s claim released, and a journal row. It never writes the phase’s status — that is derived, and reads done once every task is finished, the verdict is recorded and, for a phase with a branch, the merge has landed. Until then a phase whose work is finished says sign-off due wherever it is shown, and it does not hold the plan gate in its denying tier. Refused with open work, on a phase with no task, a second time, and on a closed phase. skipped is honest where no review ran; the summary says so.

/audit:phase priority <phaseId> <tier|--clear> writes manifest

Pins a phase ahead of the others among work that is already ready: priority reorders the ready set, it does not override the graph. It is a phase lever, not a task one — tasks have no priority at all, and their order inside a phase comes from the dependency graph and their ids. /audit:task priority is the legacy spelling and still works.

/audit:phase cancel <phaseId> --reason "<why>" writes manifest takes lock

Closes a phase — and every unfinished task under it — as terminal but not done, recording the reason, the moment and a journal row; /audit:task cancel <taskId> does one task. Use it instead of deleting: “we decided not to” is information, and a deleted task cannot say it. Readiness treats a cancelled blocker as settled, so a plan never deadlocks on work nobody will do.

/audit:bug add · list · fix <bugId> · close <bugId> [wontfix] writes manifest

Bugs live in the manifest beside the plan. fix materializes a TDD fix task, and the repro test must fail red-first — that is what proves the bug is the bug. Execution stays in the normal run commands.

/audit:bug add "Sparkline drops the last day on month boundaries"
/audit:bug fix BUG-3 --phase P1
/audit:run T14

Areas — one repo, two stacks

A phase can carry area: one tag ("backend") or a list for cross-cutting work (["backend","frontend"], grouped under each). meta.areas is the registry that gives a tag properties — a root, a description, a default reviewSkill, default executor skills, an advisory owner. Reviewer resolution is phase.reviewSkill → areas[tag].reviewSkill → meta.reviewSkill; executor skills are the area's plus the task's own, deduped, area first. Where a phase carries several tags, written order decides.

This is a manifest label and it reaches no board. It groups and filters in status, the report and the panel, and selects reviewers and skills. It is not System.AreaPath — a backend card and a frontend card leave for ADO carrying the same tag set, and where the board has to tell them apart that is meta.ado.areaPath or a tag on the item itself, which a push preserves.

Two asymmetries. An absent tag is not a tag: a phase with no area raises nothing, and the validator warns only about an unregistered one — usually a typo — so leaving a phase untagged beats inventing a tag to fill the field. And registering an area makes its silence visible: an area declaring no skills resolves none, so a task with none of its own warns no skills resolve. Answer it with the task's own skills, defaults on the area, or "skills": null — that last one records the intention, where leaving the warning standing records nothing.

06

Parked proposals

Work that /audit:init proposed and nobody accepted yet lives in proposals[], outside the phases. It is a queue, not a plan — an empty live plan with parked proposals means nothing is scheduled, not that something was lost.

/audit:propose list [all] read-only

Prints id | status | reserved phase (task count) | name | openQuestions. Default shows proposed only; list all adds materialized and dropped history.

/audit:propose materialize <PROP-id> | --all writes manifest takes lock

Materialization is a move, not a re-synthesis. The proposal enters the plan as it was parked, so settle any open question that would change what the phase contains first — otherwise you materialize a guess and edit it afterwards. Phase ids are allocated at materialization, with a collision guard that reports [renamed from …], so a blockedBy P0 on a parked proposal points at nothing until then.

When one materialization waits on a still-parked proposal you get exactly one question: --with-deps pulls in what it waits on (recommended), --drop-edges cuts the edge and records the cut in the phase description, or you abort. --all implies --with-deps and goes in dependency order.

/audit:propose drop <PROP-id> writes manifest

Parks it as declined, with the reason kept.

/audit:propose revive <PROP-id> writes manifest

Brings a dropped proposal back to proposed.

07

Azure DevOps

Three sections, and nothing else on this page depends on them — skip the group unless the manifest syncs to a board.
07.1

ADO: connecting

The connector is configured in meta.ado in the manifest. Credentials never go there — not in the manifest, not in the config, not in any file. Auth belongs to az or the MCP server, and the secret guard blocks a token that tries to get printed.

/audit:sync connect reads ADO writes manifest

Start here. Read-only rungs first and one write at the end, so nothing is configured until access is proven — and the thing that proves it is no longer a push, which is also the first thing that can create items on somebody's real board. It checks the transport, reports which auth path is in effect rather than assuming one, probes read-only in the Work Items scope, and detects the process. Re-run against a configured manifest, it re-probes and reports rather than clobbering.

Every sync invocation walks the same ladder — manifest, meta.ado, enabled, transport — and stops at the first rung that fails, saying which. Nothing reaches the board before it passes, and enabled: false stops push and pull while status still runs. To write the block by hand, this is all that is required; everything else (stateMap, sprint, pull, onComplete, comments, echo, phaseWorkItems, fields, conventions, enabled) is optional and edited by the panel's ADO card:

"ado": { "organization": "<org>", "project": "<project>",
         "areaPath": null, "iterationPath": null,
         "types": { "bug": "Bug", "task": "Task" } }

The token, and what it may do

Configuration travels in the manifest and arrives with a merge; access does not — every teammate authenticates themselves. Create the token in the browser profile signed in to the account that has access, scope it to Work Items → Read & write and nothing else, then store it:

az devops login --organization https://dev.azure.com/<org>
Do not reach for Full access

Work Items read-write is the whole surface the connector writes: titles, descriptions, state, area, iteration, tags, comments, parent links. A token that narrow cannot read your source, touch pipelines or cut releases, which makes a longer expiry a reasonable trade — scope bounds the damage, expiry bounds the duration. With Full access the arithmetic reverses. An expired one makes sync return 401, which reads like a broken configuration, so write the expiry date where the team can see it.

A working board command proves the organization is reachable, never which identity reached it

A stored token is per organization. On one machine one org can resolve through a stored token while another resolves through your Azure sign-in, with no indication which is in play — measured: with a deliberately invalid AZURE_DEVOPS_EXT_PAT exported, a query against a sign-in org still succeeded. So before a first push check az account show and whether a token is stored or exported: a push under the wrong identity does not fail, it writes into the wrong organization. And you need to run the login command does not mean you are logged out — a nonexistent org produces the same message.

What the board actually uses

connect detects this for you. To ask it yourself, one read-only query does it — the process decides types.pbi and whether you need a stateMap:

az boards query --organization https://dev.azure.com/<org> --project <project> \
  --wiql "SELECT [System.Id],[System.State],[System.WorkItemType] FROM WorkItems
          WHERE [System.TeamProject] = '<project>'" -o json
If you seeProcessWhat follows
User Story · New/Active/Resolved/Closed/RemovedAgiletypes.pbi: "User Story"; the built-in stateMap defaults already match — leave it out
Product Backlog Item · New/Approved/Committed/DoneScrumtypes.pbi: "Product Backlog Item"; you must set stateMap — the defaults name Agile states
Board columns are not states

A card's column lives in per-team hidden WEF_ fields whose id differs per board, and the connector refuses to write them: it sets System.State and lets the team's own column↔state mapping move the card. So a pushed item lands in the default column for its state, and a column backed by no state of its own — a review lane, a split Doing/Done — cannot be targeted at all.

Relatedly, sprint resolves the current iteration through the team's timeframe, so a long-running workstream overlapping the numbered sprints makes “current” match more than one. A static iterationPath sidesteps the resolution, and sync status prints sprint: unresolvable rather than guessing.

Conventions the board enforces

meta.ado.conventions carries a board's house shape — a description skeleton, mandatory markers, a tag vocabulary, a required parent — and a conformance gate runs before every create, so a refusal reaches you before the item exists. A board with no conventions block passes saying nothing was checked: an answer, not a silent success.

requireParent: true means a card must hang under something; where it hangs is the field map's subject, and declaring neither end is not neutral — the connector then builds its own branch, putting audit work beside the team's planning instead of inside it. tagVocabulary restricts what tags an item may carry, one axis per prefix plus "*" for unprefixed tags, and the empty list means opposite things on the two: {"*": []} admits any bare tag, because a wildcard with nothing narrowing it is still a wildcard, while {"supplier": []} admits no supplier: value.

Check the vocabulary against the connector's own tag

The provenance tag defaults to audit-plugin, which carries no prefix — so a board whose vocabulary admits only prefixed tags refuses every item the connector creates, while the manifest still validates clean, because each block is graded alone. Nothing is wrong with either; they disagree. A prefixed meta.ado.tag closes it.

/audit:sync status read-only

The connector line, linked vs unlinked counts, then a row per linked item: manifest id | title | manifest status | ado id | ado state | drift? It writes to neither side and takes no lock.

/audit:sync parents reads ADO manifest

Caches the project's backlog ranks and parent-shaped items. Run it before the first push: measured, ADO does not enforce its own type hierarchy on a link made through the API, so the connector checks the tree itself — and without this cache every parent link reports not verified while the create proceeds anyway.

Order that keeps the first mistake small

Verify with /audit:sync status while the plan is still small, then materialize, then push one phase with --phase: a wrong state map then costs a few items instead of a whole board. On a fresh install, while every phase is still parked, there is nothing to push — so configuring the connector first is the one moment when a misconfiguration cannot create anything.

07.2

ADO: push & pull

One direction per invocation

sync push

sync pull

sync status
reads both, writes neither

manifest
bugs · tasks · phases

ADO board

drift report

There is no arrow that goes both ways. A push never reads the board's intent back, and a pull never writes to it — so anything that appears on the board is traceable to one invocation somebody ran. sync status is the only operation that touches both sides, and it writes to neither.
/audit:sync push [bugs|tasks|all] [--task <id> | --phase <id>] writes ADO writes manifest takes lock

Default is bugs. Idempotent, because the manifest side of each link (ado: {id, url, lastSyncedAt}) is written immediately after each successful call.

  • States are applied by UPDATE, never at create — ADO only accepts the initial state at creation.
  • A state the process rejects degrades per item: retry without State, report it, point at stateMap. One bad item does not abort the run.
  • Tags are read-merge-write against the item's existing list, never replaced wholesale.
/audit:sync push tasks --phase P0    # one phase, smallest first write
/audit:sync push all                # bugs + tasks + phases
/audit:sync pull [bugs|sprint] writes manifest takes lock

Brings assigned ADO bugs, or the current sprint's items, into the manifest. Never modifies ADO. A candidate whose id already appears in any manifest ado.id — live tasks, bugs, phases, and parked proposals — is dropped, so a re-pull imports nothing and parking a proposal does not make its item eligible again. Both flavours ask before importing, and they are scoped differently:

  • pull sprint refuses to import blind. It needs an iteration and a filter (pull.areaPath or pull.tags), because one sprint can span several repositories. With no filter it lists the sprint read-only, makes you pick, and offers to save the filter. If your team already tags the cards it owns, that tag is the filter: "pull": { "tags": ["<your-card-tag>"] }.
  • pull bugs is project-wide: every non-closed bug, narrowed only by areaPath if one is set. Not sprint-scoped.

Imported sprint items land as parked proposals, one per PBI with its child tasks — nothing enters the live plan until you materialize it.

Push recognises its own links, and only its own

An item with no ado field is CREATED; one with ado.id is fetched, diffed and UPDATED only if something differs. But push does not match by title or tag: a card somebody made by hand for the same phase is invisible to it, and pushing creates a second one. Broad dedup exists only in the other direction, so when the board already holds work matching your plan, pull first, then push.

07.3

ADO: the field map & drift

What lands where on a push. Every default is overridable in meta.ado. Test evidence is not on this list and will not be — the ledger stays in the repository.

Manifest sideADO fieldConfigured by
bugwork item typetypes.bug — default Bug
taskwork item typetypes.task — default Task
phasework item typetypes.pbi · phaseWorkItems (absent = on)
bug title · [taskId] title · [phaseId] titleTitle—
description + repro + expected/actualRepro Steps (Bug) / Description—
severity high / med / lowSeverity 2 - High / 3 - Medium / 4 - Low—
bug status open → New, triaged/in_progress → Active, fixed → Resolved, wontfix → ClosedStatestateMap.bug
task status pending → New, in_progress → Active, blocked → Active + tag blocked, done → Closed, cancelled → RemovedStatestateMap.task
phase statusStatestateMap.phase — phase-item vocabularies differ
task done + onComplete.remainingWorkRemainingWorkstock processes force-clear it at done → retry state-only, report the skip
alwaysArea · IterationareaPath · sprint else iterationPath
alwaysprovenance tagtag — absent = audit-plugin, null = none

A stateMap value of null means never move state for that transition: the field is skipped and the team moves that card by hand. The built-in defaults name Agile-process states, so a Scrum project must set stateMap — /audit:doctor carries that advisory.

Decide which phases belong on the board — the manifest cannot say it for you

Some phases deliver something a stakeholder recognises; others are “extract this query into a service”. Only the first kind belongs on a shared backlog, and there is no per-phase opt-out today: phaseWorkItems is a global on/off, so the only selectivity is what you name at the call site. Make it a team rule:

/audit:sync push tasks --phase P3    # always name the phase
/audit:sync push all                # the command your team does not run
Where each phase hangs — a board is a tree, and so is this

meta.ado.parentWorkItem is the fallback: one work item that anything undeclared hangs under. Where a sprint spans several Features, a phase declares its own instead:

"phases": [
  { "id": "P0", "adoParent": { "id": 71157 } },       // this Feature
  { "id": "P3", "adoParent": null },                  // nothing, on purpose
  { "id": "P4" }                                      // whatever the fallback says
]

Three states, three answers. Absent falls through to the fallback; an object names the parent; explicit null means this phase hangs under nothing even when a fallback is set — uncategorised on purpose, never an error. The id is an integer; a quoted one is a typo the validator names rather than coerces. The push plan prints the resolved parent per phase with where it came from, so a fallback quietly applying to every phase but one is visible before you confirm.

When the board demands fields the connector does not write

conventions says what an item must have and can only refuse. meta.ado.fields is the other half — what this project supplies, per work item type, merged into the create payload before the gate grades it:

"fields": {
  "Task":       { "Microsoft.VSTS.Common.Activity": "Development",
                  "Microsoft.VSTS.Scheduling.OriginalEstimate": 4 },
  "User Story": { "Microsoft.VSTS.Scheduling.StoryPoints": 3 }
}

A template may not name a field the connector already maps, nor a read-only one, and that is refused when you write it rather than tried — because read-only fields fail two ways and one looks like success. Measured live: System.BoardColumn refuses out loud, while System.Parent, System.Id and System.CreatedBy each create the item, report success, and leave the field unset. Values are literals, with no substitution language.

A difference has three readings, not two

The obvious two are “the manifest is ahead” and “the board is ahead”. On a board several teams write to, the common third is: somebody else moved this card after we last touched it, and neither side is wrong. The reading names the writer so you can go and ask that person — local ahead, external (<who>, <when>), or unknown, which draws no suggested action because the basis for one is missing.

The comparison is your status translated through stateMap against the board's. Without a map every row reads state not compared (no mapped state supplied) — so a whole table saying nothing is compared is not a clean board, it is a missing map.

A sharded manifest read raw looks like a clean answer

The index holds each phase as a stub, and every board link below a phase lives in the shard body — so a plain read of the manifest file finds the bugs and stops, where an assembled read finds every link. It does not error; it under-counts, and the connector line looks plausible. The scripts on this path all load assembled, so the trap is in any count you compute yourself. The board read is honest in the same way: a fetch that could not answer for every id exits 1 and names the ones it has no news about, rather than leaving a table that looks complete.

08

The panel

/audit:panel [stop|status] [--port <n>] writes manifest

A local control panel for .claude/audit.config.json and the manifest's composition levers, with live validation and discovery of the skills and agents available in this repo and globally. Ephemeral and on-demand; a per-project pidfile keeps it discoverable and stoppable.

The sections, in the order they appear:

Overview

Where a run stands, and what the tests said. The live view; it refreshes from disk while you watch.

Plan & models

Per-phase review model, per-task skills and models — and the Branch card, which is where a phase's landing place is decided: the branch-name convention, the merge target (meta.developmentBranch, and per phase parentBranch), the three meta.merge switches, and a live table of this plan's worktrees with a Sweep button.

Usage

Token spend by phase, task, model, author and time — /audit:usage's data.

Policy

What agents are allowed to do. Expandable full-screen, searchable by name and description.

Proposals

Materialize and drop, through the same script the CLI calls.

Settings

audit.config.json, validated as you type rather than on save; evidence.dir sits in its Audit trail card.

Appearance

Theme and presentation, for the panel and the rendered report.

What the tests said, on Overview

Open a phase and its detail carries two evidence lines, labelled apart: phase sign-off is the phase's own gate run, and tasks is a roll-up counting its tasks by verdict. They measure different things over different files, so one badge for both would claim a measurement nobody made. The task table below them ends in a tests column, in the same position the report uses.

Badges and markers read exactly as they do in Running phases. A badge with a run behind it is also a button: pressing it opens that run's steps (name, exit, checks, took, outcome) and the basis lines for anything it could not determine. A subject with no recorded run is deliberately not a control, because there is nothing to open.

It notices a run landing

The freshness fingerprint covers the config, the manifest, every shard, the usage ledger and the evidence directory, so a gate recorded in another window refreshes the badge without a reload. Only the runs the plan points at travel to the browser — earlier attempts stay in the ledger, and the report is where you read them. And where a pointer dangles it says how many ledger files it read and how many lines it could not parse, so Pointer without evidence tells an unwritten evidence directory apart from an unreadable one.

What the panel does not cover, stated rather than discovered

The rule is that every feature has a command and a way through the panel. Where it does not hold today, you look in the terminal:

Board status and drift. The ADO card shows manifest evidence only — how many tasks, bugs and phases carry a link, and when the last sync was. It never reads the board, so it cannot show state, parent or sprint drift, and the ADO parent cell shows what you declared: a phase whose card somebody dragged under a different epic by hand reads exactly like one that agrees. /audit:sync status knows the difference.

Pulling from the board. No panel path at all — both flavours ask you which items to import, and the panel's manifest writer is restricted to composition levers.

Pruning the plan-gate feed. The panel server can do it; the Plan gate card has no button yet, so /audit:logs prune is the way.

Running anything. The panel configures runs; it never starts one, and it never records a gate.

Everywhere else, panel and CLI are one implementation: materialize, drop and revive run the same script from either side, and so does the config validator. The gaps above are missing paths, not second implementations, which is why a change made in the panel shows up in /audit:status immediately.

Sweep is the panel's first git write, and it is worth knowing how

Until this release the panel ran git for reading only. Sweep is the exception, and it goes through the same path as saving the config rather than beside it: the loopback and token guards, the manifest write lock — so a running /audit blocks it — a dry run whose rows fill the ordinary confirmation dialog, and a hash-chained journal entry. The button never assembles a git command of its own; it calls the same planner the CLI's sweep calls, so both sides refuse for the same four reasons and neither can be the lenient one. There is no --force: a dirty worktree is named and left.

09

What the trail records

Every change to the plan and the config appends one hash-chained row to a journal beside the manifest. It is meant to be committed — that is what makes it evidence rather than a log — and that single fact decides everything else about what a row may hold.

What a row keeps, and what it keeps instead

never stored

a write
panel · command · hook

one redaction boundary
normalise_details()

row
ts · actor · action
target · details
stateHash · prev · hash

YYYY-MM.writerId.jsonl
one file per writer per month

verify

chain intact

out-of-band drift
a file moved with no row

command text
absolute cwd
machine name

One boundary, not one per writer. The panel, the commands and the hooks all append through the same function, so the redaction is implemented once and no new writer can reintroduce what an old one stopped storing.

What a row may hold, and what it may not

Because the file ships, no row names a machine. A command is stored as commandSha256 + commandBytes + program, so you can still ask “was it this command?” by hashing your candidate while the arguments — where paths, hostnames and tokens live — are not there. cwd is repo-relative, . at the root, or <outside-repo>; unresolvable, empty and outside all land on that token, because a redactor that guesses is a home directory in a file that ships. There is no actor.host at all. And the file name is the session id or a random per-checkout token rather than the machine's, which mattered most of all: the chain is seeded from that base name, so a name committed there could never be corrected without breaking verify on every clone that already had the file.

The rows a test run leaves

The journal anchors the evidence ledger rather than duplicating it: an exit code and a duration are things the plugin observed about the machine, not fields of the plan.

ActionSaysWritten when
test.evidence.recorded a gate run was written to the evidence record: runId, and the task or phase it was about its subject is the evidence file, so it is true the moment the row lands, whatever happens to the plan afterwards
task.testEvidence / phase.testEvidence a testEvidence pointer moved, naming from and to only after the pointer actually lands. A pointer refused by another live session leaves the row above standing and no row here, because the chain must never assert a transition that did not happen
audit.state.committed an audit-state commit was made for work no task commit will carry a task went blocked, an infrastructure stop, a red sign-off gate, or /audit:resume's opening sweep

Each action has exactly one writer — never append one by hand, because two writers means duplicate rows and a doctor that can no longer trust the count. These three are written in process rather than by the journal-writes hook, because the hook watches edit tools and these writers use os.replace and git commit.

Tamper-evident, not tamper-proof

Editing, deleting or reordering a row breaks the chain and verify names the row. What it cannot stop is a forger who rewrites the whole file and recomputes every hash forward — there is no secret key, and nowhere on a user's machine to keep one the same user cannot read. It is a smoke detector, not a vault. The other half of what it proves is out-of-band drift: a document that changed with no row to explain it, which comes back as a warning rather than a finding and is usually the interesting line. History is never rewritten to tidy any of this up — the chain runs through those bytes, so a row can be superseded by a later one, never corrected.

The command digest is unsalted on purpose — hashing your candidate is how you check a guess, a shipped salt would be published with the plugin, and a per-machine salt would be readable by the same user it hid things from. So a short command from an obvious vocabulary is recoverable. This buys friction and data minimisation; it is not anonymisation and must not be sold as it.

/audit:doctor --deep read-only

Verifies the chain across every journal file, live and archived, reporting drift separately from breakage. It also grades the pointers against the ledger both ways — a pointer naming a run this checkout does not have, and a recorded run the plan has not caught up to:

[OK     ] evidence       7 pointer(s) in the plan, 9 recorded run(s), and every
                          pointer names a run this checkout holds

Both are advisory, because a pointer refused by a live lock is a designed state and the repair is one --reconcile. Asked of what is committed, the same question is a breach instead: /audit:status --gate --fail-on invariant-breach.

10

Command reference

CommandArgumentsTouchesPanel path
/audit:status[--gate] [--fail-on …] [--phase ID] [--json] [--color …]read-onlyOverview — the live view of the same state
/audit:doctor[--deep] [--json] [--color …]read-onlynone — CLI only
/audit:report[--out-dir <dir>] [--share]read-onlyExport report, from any tab
/audit:usage[--by phase|task|model|author|agent|attr|branch|session|hour|day|month] [--phase] [--author] [--area] [--since 7d] [--format md|ascii] [--json] [--backfill]read-onlyUsage
/audit:guide<question>read-onlyHelp drawer
/audit:sync status—read-onlynone — the ADO card shows manifest counts, never the board
/audit:sync connect—reads ADO manifestnone — the card edits meta.ado once connected
/audit:sync parents—reads ADO manifestpartial — the cached candidates fill the parent picker
/audit:phase<phaseId> [--dry-run] · add · retarget · priority · cancelmanifest locknone — the panel configures runs, never starts one
/audit:next[--dry-run]manifestnone — same reason
/audit:run<taskId> [--dry-run]manifestnone — same reason
/audit:resume—manifestnone — same reason
/audit:review<phaseId>manifestnone — same reason
/audit:taskadd · scope · move · cancelmanifest lockpartial — Plan & models edits levers, not structure
/audit:bugadd "<title>" · list [all|<status>] · fix <bugId> [--phase] · close <bugId> [wontfix]manifestnone — bugs are listed, not edited
/audit:proposelist [all] · materialize <PROP-id>|--all · drop · revivemanifest lockProposals — same script from both sides
/audit:worktree<list|add|remove|sweep> [phaseId] [--path DIR] [--force] · sweep: [--apply] [--remove-worktrees] [--delete-branches] [--prune] [--json]git worktreesBranch card — the worktree table and its Sweep button
/audit:layout<sharded|single-file> [--dry-run] [--renumber] [--force]manifest locknone — CLI only
/audit:panel[stop|status] [--port <n>]manifestn/a — it is the panel
/audit:sync push[bugs|tasks|all] [--task | --phase]ADO manifestnone — CLI only, and it writes to somebody's board
/audit:sync pull[bugs|sprint]manifestnone — interactive by construction
/audit:logsprune [--older-than DAYS] [--dry-run] [--json]logs feedendpoint only — the server can prune, the Plan gate card has no button yet
/audit:migrate[--dry-run] [--renumber] [--force]manifest locknone — the legacy spelling of layout sharded
/audit:init[scope/goals]manifestnone — CLI only
Not on the table, and worth knowing

Nothing under scripts/governance/ has a slash command — run-test-gate.py, commit-task-work.py, verify-invariants.py, audit-journal.py and audit-lock.py. The pipeline invokes them; running one yourself means repairing something a run left behind. There is no /audit:run-gate verb — it is not built — so a gate you record by hand is a script invocation:

# record a run the pipeline did not
python3 "$CLAUDE_PLUGIN_ROOT/scripts/governance/run-test-gate.py" \
    <manifest> <phaseId> --task <taskId> --record

# the run landed but the pointer did not: read the ledger against the plan, running no tests
python3 "$CLAUDE_PLUGIN_ROOT/scripts/governance/run-test-gate.py" \
    <manifest> <phaseId> --reconcile

Running phases shows both against a real manifest, with what each prints. For anything else, /audit:guide <question> answers from the plugin's own documentation with a citation for every claim, and /audit:status --help prints its condition list from the same tuple the gate evaluates.