Verify
Prove a ticket meets its criteria. Works with or without an active ticket.
Reviewer class: class-2 — independent observation: the test suite and parsers are the independent party, so no fresh-context or cross-model reviewer applies.
Closing Check, Not Fast Feedback
/safeword:verify is the closing gate: it prefers the project's authoritative suite over
a fast subset. If the project exposes only test:done, record that limited
evidence rather than calling it a full run. While implementing, use focused
tests and relevant static checks; before handing work back, run the project's
smoke lane when it has one. Do not substitute smoke for /safeword:verify when proving
ticket completion.
Invocation log
This skill is required before marking a feature ticket done. The line below appends a current-run entry to skill-invocations.log under the project namespace root (.project/, or legacy .safeword-project/ where that exists) so the done-gate hook can verify /verify was actually invoked. Claude Code expands the ! line automatically and passes ${CLAUDE_SESSION_ID} when available. The helper also resolves Claude remote-container ids from the runtime environment, and on Cursor and Codex the pre-shell hook (beforeShellExecution / PreToolUse) bridges the session id to the helper — so on all three runtimes the fallback runs without hand-picking an id. Hand-writing verify.md cannot produce this feature-gate proof.
!bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/record-skill-invocation.ts" "$CLAUDE_PROJECT_DIR" verify "${CLAUDE_SESSION_ID:-}" || echo "[skill-invocation-log] FAILED - no current-run proof logged"
If no [skill-invocation-log] verify ✓ line appears above, run this fallback before continuing:
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify "${CLAUDE_SESSION_ID:-}"
If the automatic line or fallback prints [skill-invocation-log] FAILED, prints no run identity, or still does not print verify ✓: a feature ticket can't be marked done without this proof — don't hand-write verify.md as a substitute. Report the failure to the user (most likely cause: inline shell execution was denied, the runtime did not expose a usable run identity, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /verify.
For task, patch, or no-ticket work, this proof isn't required — note it's missing and continue.
Instructions
1. Find Current Ticket (if any)
Use the installed resolver. It reconciles this runtime's session binding with
the current PR or worktree's Git changes and fails closed when those signals
conflict. It never scans the global in_progress backlog. A session-bound
ticket remains relevant after its status changes during closeout; a changed
done ticket remains eligible.
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/resolve-verify-ticket.ts" "$PROJECT_DIR"
If Safeword's injected context names a ticket but the host exposes no runtime
identity to the helper, rerun with --ticket <id>. Multiple changed tickets
fail closed with the same instruction. No candidate means continue without an
active ticket.
Git cannot infer current work from commits made directly on an up-to-date
default branch because there is no distinct merge-base range. In that case,
pass --ticket <id> when ticket context is required.
If a ticket is found, read it to get:
parent:field (if any)- Ticket ID/slug for test-definitions lookup
## Killer Demofrom this ticket's ownspec.md, when it declares one — the Experience Peak below walks that Payoff, so a ticket that declares its own demo must never reachN/Afor want of reading it. A child inherits instead: step 4 resolves the demo from the parent.
If no ticket is found, skip scenario validation (step 3) and parent check (step 4).
2. Run Automated Checks
Run these in sequence, reporting each result:
- Run
/lintto auto-fix style issues first - Then run target-project verification checks from project evidence.
Safeword runtime vs target project: Safeword may use Bun for its TypeScript helpers; that does not mean the target project uses Bun. Use Bun for installed helpers, then choose target project verification commands from stack manifests, lockfiles, and available scripts. A package.json may be safeword lane-host evidence in pure Python, Rust, and Go installs, so do not treat package.json as proof the target project is only JavaScript.
Per-language test/build/typecheck/bdd/deps commands all come from safeword test-plan — one source of truth (the same plan the stop-hook gate runs). Eval its
shell plan in a child shell: an absent toolchain prints a visible skip, and a
failing suite exits non-zero so the gate blocks. The Gherkin acceptance lane is
resolved the same way (--kind bdd): cucumber-js / behave get their own lane,
while godog and cucumber-rs fold into the Go/Rust test lanes and need no separate
command.
Run the block below verbatim, as ONE bash invocation. Do not extract or paraphrase individual commands — the CLI resolver, the generator exit-code check inside run_plan, and the git preflight are load-bearing (regressions 487, 375, and 469 each came from a hand-rolled variant of this block).
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
cd "$PROJECT_DIR" || exit 1
# Local evidence preflight: many repo tests create throwaway git repos. Some
# agent sandboxes cannot initialize repos in temp dirs, which makes those tests
# fail for environment reasons instead of product reasons.
LOCAL_EVIDENCE_LIMITS=""
if GIT_PROBE_DIR="$(mktemp -d 2> /dev/null)" && git init "$GIT_PROBE_DIR" > /dev/null 2>&1; then
find "$GIT_PROBE_DIR" -depth -delete
else
LOCAL_EVIDENCE_LIMITS="${LOCAL_EVIDENCE_LIMITS}
- Temporary git repos: local environment cannot run git init in temp dirs. Treat git-backed test failures as local environment limitations until reproduced outside the sandbox or in CI."
[ -d "${GIT_PROBE_DIR:-}" ] && find "$GIT_PROBE_DIR" -depth -delete
fi
if [ -n "$LOCAL_EVIDENCE_LIMITS" ]; then
printf '%s\n' "Local evidence limits detected:${LOCAL_EVIDENCE_LIMITS}"
fi
# Resolve a test-plan-capable safeword CLI — prefer the locally installed one
# only if it actually supports test-plan.
supports_test_plan() {
case "$CANDIDATE" in
node_modules/.bin/safeword) node_modules/.bin/safeword project test-plan --help > /dev/null 2>&1 ;;
"bun packages/cli/src/cli.ts") bun packages/cli/src/cli.ts project test-plan --help > /dev/null 2>&1 ;;
"bunx safeword") bunx safeword project test-plan --help > /dev/null 2>&1 ;;
esac
}
run_safeword() {
case "$SW" in
node_modules/.bin/safeword) node_modules/.bin/safeword "$@" ;;
"bun packages/cli/src/cli.ts") bun packages/cli/src/cli.ts "$@" ;;
"bunx safeword") bunx safeword "$@" ;;
esac
}
# >>> run_plan (behavior covered by verify-skill.test.ts #487)
# Capture the plan, then check the generator's exit status BEFORE running it.
# `bash -c "$(...)"` discards the substitution's exit code, so a failed
# generator that prints nothing would leave `bash -c ""` — a false green (#487).
# Reads $plan_kind from the caller, not a bash positional parameter: in a
# command file Claude Code rewrites slash-command argument tokens before bash
# runs, so a positional would be clobbered. Plain assignment (never
# `local x=$(...)`, which masks `$?`); this block runs without `set -e`, so the
# explicit check is load-bearing. A successful empty plan stays a clean no-op
# (`bash -c ""` → exit 0).
run_plan() {
plan="$(run_safeword project test-plan --kind "$plan_kind" --format sh)"
rc=$?
if [ "$rc" -ne 0 ]; then
echo "❌ Evidence generation failed: safeword project test-plan --kind $plan_kind exited $rc (red, not a passed check)" >&2
return "$rc"
fi
bash -c "$plan"
}
# <<< run_plan
CANDIDATE="node_modules/.bin/safeword"
if [ -x node_modules/.bin/safeword ] && supports_test_plan; then
SW="node_modules/.bin/safeword"
elif CANDIDATE="bun packages/cli/src/cli.ts" && [ -f packages/cli/src/cli.ts ] && supports_test_plan; then
SW="bun packages/cli/src/cli.ts"
elif CANDIDATE="bunx safeword" && supports_test_plan; then
SW="bunx safeword"
else
echo "No test-plan-capable safeword CLI found. Tried node_modules/.bin/safeword, packages/cli/src/cli.ts, and bunx safeword." >&2
exit 1
fi
# >>> verification_lanes (behavior covered by verify-skill.test.ts)
# Run every lane for complete evidence, but preserve the first non-zero status
# so a later successful lane cannot turn the aggregate invocation green.
verification_status=0
record_verification_status() {
if [ "$lane_status" -ne 0 ] && [ "$verification_status" -eq 0 ]; then
verification_status="$lane_status"
fi
}
# --- Test suite (resolved by safeword project test-plan — one source of truth) ---
plan_kind=verify
run_plan
lane_status=$?
record_verification_status
# --- Gherkin acceptance lane (resolved by safeword project test-plan --kind bdd:
# cucumber-js, behave, … — godog/cucumber-rs fold into the Go/Rust test lanes).
# Mirrors run_plan's generator exit-code check (#487) so a failed generator is
# not read as an empty (skipped) lane. ---
bdd_plan="$(run_safeword project test-plan --kind bdd --format sh)"
rc=$?
if [ "$rc" -ne 0 ]; then
echo "❌ Evidence generation failed: safeword project test-plan --kind bdd exited $rc (red, not a passed check)" >&2
lane_status=$rc
record_verification_status
elif [ -z "$bdd_plan" ]; then
echo "Gherkin acceptance lane: ⏭️ Skipped — no acceptance lane detected"
else
bash -c "$bdd_plan"
lane_status=$?
record_verification_status
fi
# --- Build check (resolved by safeword project test-plan) ---
plan_kind=build
run_plan
lane_status=$?
record_verification_status
# --- Typecheck: static type-check where the stack has one — `tsc --noEmit` for
# TypeScript (the same signal CI's lint job runs, #436), mypy/pyright for
# Python when configured, and `cargo clippy -- -D warnings` (the strict
# lint-gate that subsumes `cargo check`) for Rust. A green targeted-test run
# is NOT readiness if types are broken. Go is absent by design — its compiler
# is the type checker, already covered by build. An empty plan is a silent
# no-op; when the ticket touched TypeScript, run `/lint` (which runs tsc) so
# it isn't a gap. ---
plan_kind=typecheck
run_plan
lane_status=$?
record_verification_status
# --- Supply-chain: JavaScript's package-manager audit, Python's `uv audit` or
# `pip-audit`, Go's pinned `govulncheck`, and Rust's cargo-deny advisories.
# A missing scanner prints a visible skip, never a false green. ---
plan_kind=deps
run_plan
lane_status=$?
record_verification_status
exit "$verification_status"
# <<< verification_lanes
The /lint command handles linting with auto-fix. Report any remaining unfixable errors. Aggregate every attempted stack test into the final **Test Suite:** status, and every attempted stack build into the final **Build:** status. Typecheck is part of the gate, not optional: when the ticket changed TypeScript, a passing targeted-test run is not "ready" until test-plan --kind typecheck (or /lint, which runs tsc --noEmit) is green — CI's lint job runs it and will go red otherwise. A skipped or empty test-plan is not a failure when the project lacks a matching automated check; it is an explicit evidence gap to mention when the ticket touched that stack.
If LOCAL_EVIDENCE_LIMITS is non-empty, keep running checks that can run, but classify affected failures as ⚠️ Local environment limitation: <reason>. The common Cursor sandbox symptom is .git/hooks/: Operation not permitted during git init. A failure caused only by that preflight is not proof of product failure; confirm outside the sandbox or in CI before calling it real.
If a full Vitest suite fails only because packages/cli/tests/integration/cucumber-bdd.test.ts times out while bun run --cwd packages/cli test:bdd passes directly, classify it as ⚠️ Local environment limitation: Cucumber wrapper timed out under full-suite load. Report the isolated Cucumber lane as the Gherkin evidence and rerun that direct lane once. Treat it as a real product failure only when the direct Cucumber lane fails or CI reproduces it.
Regression fixtures covered by safeword project test-plan and its tests:
- no-build JavaScript: a
testscript with nobuildscript runs tests and has no JavaScript build entry. - non-Bun JavaScript: lockfiles and
packageManagerselect the matching package manager instead of assuming Bun. - non-JavaScript installs: Python, Rust, and Go manifests are resolved independently of any safeword lane-host
package.json.
3. Validate Test Definitions (skip if no ticket)
- Find matching file:
$NS_ROOT/tickets/{ID}-{slug}/test-definitions.md - Count scenarios: lines matching
- [ ]or- [x]— a bare- [prefix also matches link list items and inflates the total - Count completed:
- [x]lines - Report: "Scenarios: X/Y complete"
If any unchecked [ ] remain, list them.
4. Check Parent Epic (skip if no ticket)
If ticket has parent: field:
- Read parent ticket
- Get
children:array - Check each child's
status: - Report: "Siblings: X/Y done"
- Read the parent
spec.mdand extract## Killer Demo. The Experience Peak below walks the inherited Payoff, so it needs the parent's Audience, Starting state, Action, Payoff, and Proof. Separate the two ways this can come up empty: a parent spec you could not read is unresolved, and that is a finding, becauseN/Awould claim no demo exists rather than that you could not look. A parent you read that simply declares none isN/A, the same as a ticket with no demo of its own.
5. Check Dependency Drift
Compare the project's declared dependencies against ARCHITECTURE.md:
- If
ARCHITECTURE.mddoes not exist, skip this check - Read
ARCHITECTURE.mdcontent - Read the project's dependency manifest(s) — whichever exist:
- JS/TS:
package.jsondependenciesanddevDependencies - Python:
pyproject.toml([project]dependencies,[tool.poetry.dependencies]) orrequirements.txt - Go: the
requireblock ingo.mod - Rust:
[dependencies](and[dev-dependencies]) inCargo.toml
- JS/TS:
- For each dependency name:
- Extract the bare name (drop the
@scope/prefix for JS, version/path specifiers for the others); check both full and short forms - Check if
ARCHITECTURE.mdmentions it (case-insensitive)
- Extract the bare name (drop the
- Flag any runtime/architectural dependency NOT mentioned:
"Dependency \{name}` not documented in ARCHITECTURE.md"`
Do NOT flag:
- Type-only packages (
@types/*) and standard-library imports - Tooling/dev dependencies (linters, formatters, test utils — across any language) — only flag deps that represent architectural choices
6. Check PR Scope (skip if no ticket)
Compare the final change set against the ticket's scope, out_of_scope, and done_when.
Use the best available diff:
- Active PR diff, when the user gave one.
- Otherwise branch diff against the upstream/default branch, plus uncommitted changes.
- If no base is knowable, inspect
git status --shortand the commits/files touched this session.
Flag any changed file or behavior that only serves a different outcome than the ticket. Required supporting cleanup is fine. Nice-to-have refactors, opportunistic fixes, drive-by docs edits, and follow-up discoveries are separate tickets/PRs.
If PR scope fails, do not collapse to "Ready to mark done." Put the concrete split/revert/follow-up action in Agent's next actions, or put the scope decision in Decisions needed when the user must decide whether to expand the ticket.
7. Write verify.md (skip if no ticket)
The done gate blocks on this artifact, not on your chat report: <ticket folder>/verify.md must exist and contain a **PR Scope:** line whose status is passing (done-gate.ts rejects ❌/piggybacked). Write the full Status checklist (step 8's Verify Checklist block, every line) to $NS_ROOT/tickets/{ID}-{slug}/verify.md, and once /audit has run, include its one-line result (Audit passed …) there too.
The all-green collapse in step 8 applies to the chat report only — verify.md always carries the full checklist, even when everything is green.
8. Report Results
Structure the report in three sections, in this order. Empty sections are hidden entirely — no "None" placeholders, no empty headers.
Status (the existing Verify Checklist — facts only)
The Status section uses the existing Verify Checklist format. Format with these EXACT patterns (the done-gate hook validates them):
## Verify Checklist
**Test Suite:** ✓ X/X tests pass (or ❌ N failures, or ⚠️ Local environment limitation: <reason>, or ⏭️ Skipped — no test suite)
**Gherkin:** ✅ Acceptance lane passes (or ❌ Failed, or ⚠️ Local environment limitation: <reason>, or ⏭️ Skipped — no acceptance lane detected)
**Build:** ✅ Success (or ❌ Failed, or ⏭️ Skipped — no build step)
**Lint:** ✅ Clean (or ❌ N errors)
**Typecheck:** ✅ Clean (or ❌ N errors, or ⏭️ Skipped — no typed source changed)
**Scenarios:** All N scenarios marked complete (or ❌ X/Y complete, or ⏭️ Skipped — no ticket)
**Refactor:** ✅ Completed — <sha/summary> (or ✅ No change warranted — <reason>, or ⏭️ Skipped — <reason>)
**PR Scope:** ✅ Diff matches ticket scope (or ❌ Piggybacked changes: <paths/behaviors>, or ⏭️ Skipped — no ticket/diff)
**Dep Drift:** ✅ Clean (or ⚠️ N undocumented deps, or ⏭️ Skipped — no ARCHITECTURE.md/package.json)
**Parent Epic:** {id} (siblings: X/Y done) or N/A
**Reconcile:** ✅ No pattern deviation (or ⚠️ N deviations, M missing uplevel ticket — soft, never blocks)
**Experience:** ✅ No new friction (or ⚠️ N friction points / dulled peak, or ⏭️ N/A — not persona-facing) — soft, never blocks
**Surface Evidence:** ✅ N/N affected surfaces have recorded proof (or ⚠️ N unproven/limited, or ⏭️ N/A — no affected surfaces)
**Evidence limits:** ✅ None (or ⚠️ <local limitation>; affected failures are not product evidence until reproduced outside the limit)
Refactor reports the ticket's recorded cross-scenario refactor disposition. It does not require a formal $refactor skill invocation: report the refactor commit or summary when performed, No change warranted when review found no beneficial structural change, or Skipped with the recorded reason when the workflow did not require it.
PR Scope is the final "one purpose" guard. It blocks the all-green collapse: if it is ❌, the ticket is not ready to mark done until the unrelated work is reverted, split into another ticket/PR, or explicitly accepted as a scope change and reflected in the ticket artifacts.
Reconcile is soft — it never blocks the done gate. If the work introduced a pattern that diverges from existing siblings, confirm the ticket carries a reconcile record and every deviation has an uplevel follow-up ticket; flag any that don't. Use N/A when the work conformed or introduced no new pattern.
Experience is soft — it never blocks the done gate (no done-gate evidence pattern; a ⚠️ never hard-blocks done). Run it for persona-facing work; use N/A for internal/plumbing. You are grading your own work here, so the walk-artifact below is mandatory — a bare ✅ or "feels clean" is exactly the self-rating it exists to defeat. Two lenses:
- Friction (every persona-facing feature): did this add a step, a wait, a re-entry, or a dead-end the persona didn't have before? Walk the changed flow as the persona, inspect its ending specifically, and record the walk as evidence, not a verdict:
Walked <persona> through <flow>; worst step = <the one most likely to make them bounce>; new steps vs before = <n>. Name the worst step, not a tidy summary. - Peak (only when the ticket or its parent declared a
## Killer Demoinspec.md): walk the Payoff as the Audience persona, from the Starting state through the Action — does it still land, and did this work advance or endanger it? Record the walk, not a verdict:Walked <Audience> from <Starting state> through <Action>; Payoff = <landed / degraded / not reachable yet>; Proof = <the observable named in the demo, and whether it held>. Cite the@demoscenario when one exists; a passing@demois evidence the mechanics work, never evidence the Payoff lands — that judgment is this walk. A peak that quietly degraded is a finding even when every test is green. For a child, resolve the demo from the parentspec.mdnamed inParent References; useN/Awhen neither declares one.
A ⚠️ Experience finding routes to Agent's next actions if you'll fix it now, or to Decisions needed if it's a scope/value call for the user. It is never a reason to hold done on its own.
Surface evidence records what actually ran, not just scenario tags. For each
affected surface in spec.md, add a compact matrix row with Affected
surface, proof command or manual check, and result. Use the real
runtime/client/protocol/deployment boundary where available; otherwise record a
skip: <reason> and carry the limitation into Evidence limits. This matrix is
the input quality review uses to challenge parity.
Done-gate evidence patterns (the stop hook validates these literal phrases — do not move or rename):
✓ X/X tests pass— proves test suite ran**Gherkin:**— proves the acceptance lane ran or was explicitly skippedAll N scenarios marked complete— proves scenarios checked**PR Scope:**— proves the final diff was checked against ticket scopeAudit passed— proves /audit ran (run /audit separately)
Without the required patterns in Status, the done phase will hard block.
Decisions needed (spec / scope / value)
Only include this section when there are spec, scope, or value questions the USER must answer.
Implementation-path questions (which approach, which pattern, which library) do NOT go here — they belong in "Agent's next actions" because the agent owns implementation choices.
Borderline classification examples:
- "Should we use NextAuth or Lucia for auth?" → implementation-path → goes in Actions (agent picks one with reasoning, user can override).
- "Should this endpoint be at /v1/projects or /v2/projects?" → value decision (API contract) → goes in Decisions.
- "Is R5.x in scope for this slice or punt to slice 4?" → scope decision → goes in Decisions.
- "Should we extract this helper or inline it?" → implementation-path → goes in Actions.
- "Is 4xx the right HTTP status for this error?" → spec decision (if the spec exists, look it up; otherwise it's a value call) → goes in Decisions.
Hard cap of 5 items per section. If more exist, list the top 5 (most load-bearing) and add:
- N others, see test-definitions.md
Decisions section is hidden when empty — no "None" placeholder. Do not surface the section at all if zero decisions exist.
Agent's next actions
Only include this section when there are concrete forward actions the agent will take. Each action must be concrete and falsifiable — not vague exploration ("look into X"), but a specific verb + object the agent will execute ("add integration test for R7.3 covering 404-on-uncovered-PATCH").
Hard cap of 5 items per section. If more exist, list the top 5 and add:
- N others, see test-definitions.md
Actions section is hidden when empty — no "None" placeholder.
All-green collapse
When all Status checks pass, PR Scope is ✅/skipped for a valid reason, Evidence limits is ✅ None, AND zero decisions AND zero actions, collapse the entire report to a single-line verdict:
Ready to mark done.
No sections, no ceremony. Single line.
Summary
This command verifies ticket criteria (the done gate). Use it before marking any feature ticket complete. It also works without a ticket for quick project health checks (tests + build + lint + dep drift).