Skip to content

vt-d-complete

Mark the active specs as completed and advance to the next ready spec.

Plugin: vt-product-dev
Category: Other
Command: /vt-d-complete


/vt-d-complete — specs Completion

Workflow position: /vt-d-activate → /vt-d-2-plan → /vt-d-3-build → /vt-d-4-review → /vt-d-complete → next specs

Marks the current spec as done and offers to activate the next one.

Purpose

Completes the active specs and advances the execution sequence. This is the counterpart to /vt-d-activate — activation starts work, completion finishes it.

When to Use

  • After /vt-d-4-review passes and the feature is ready
  • When all implementation work for the current spec is done

Prerequisites

  • A spec must be currently in_progress (derived from branch name or .active-spec file)
  • The spec's specs/[N]-feature/state.yaml must exist

Invocation

/vt-d-complete                     # Complete the active spec

Execution Steps

Calling the status vocabulary. Every status-vocab.sh call in this skill is written in the cross-plugin form "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh". The ../vt-base/ hop is deliberate: the CLI lives in the base plugin, while $CLAUDE_PLUGIN_ROOT names this skill's own plugin. If $CLAUDE_PLUGIN_ROOT is unset or that file does not exist, run the copy relative to this skill file's directory instead, ../../../vt-base/bin/status-vocab.sh. If neither exists, stop the step and report both paths you tried; never classify from memory or from a list written here. Do not shorten the call to a path under the toolkit's plugins/ directory: that resolves only when the working directory is the toolkit itself, so the step could not read the vocabulary in a product repo. Never paste a PATH or a VALUE read out of a file into a command: a status and a file name are both repo content, and a $(…) in one runs when it reaches a shell (review pass 4, SEC-20). Single quotes are NOT the remedy — that advice shipped here and was measured wrong (review pass 6, SEC6-3): they stop $(…), but a directory committed as 999-x'$(touch PWNED)' CLOSES the quote and executes. Let the shell expand the glob and bind the loop variable, and paste only a number, which cannot carry a quote. classify-file and accepts-file take the file and read the value themselves.

An exit 2 is not an unknown literal. exit 3 says the vocabulary was read and this value is not in it — warn, and never default it. exit 2 says the vocabulary could not be read AT ALL, or that the artifact was refused before being scanned: a missing or unparseable data file, a key this reader will not identify, a duplicate declaration of the same field, or a file over the size bound (review pass 7, SEC7-4). Report it and stop; do not carry on as though the answer were empty and do not treat the value as acceptable. Stated once here rather than in each step, because it is the contract of every call this file makes.

Step 1: Derive Active Spec

Determine the active spec using this priority order:

  1. .active-spec file (if exists in project root):
  2. Read the file content (single line: e.g., SPEC-012)
  3. Use this as the active spec

  4. Git branch name (if no .active-spec):

  5. Run: git branch --show-current
  6. If branch matches feature/spec-NNN-*: extract NNN → SPEC-NNN → continue to Step 2
  7. If branch matches fix/bug-NNN-*: extract NNN → BUG-NNN → go to Step 1.5 (Bug Completion)
  8. Example: feature/spec-012-design-state-split → SPEC-012
  9. Example: fix/bug-007-login-safari → BUG-007

  10. Fallback: No active spec or bug found

  11. Display: "No active spec or bug found. Use /vt-d-activate to start working on a spec or bug."
  12. Exit gracefully

Step 1.5: Bug Completion (for fix/bug-NNN-* branches)

  1. Extract NNN from the branch name: fix/bug-007-login-safari → 007
  2. Glob bugs/007-*/state.yaml (replace 007 with the extracted number) to find the bug directory
  3. If not found:
  4. Display: "BUG-007 directory not found in bugs/. Cannot mark as verified."
  5. Exit gracefully
  6. Read bugs/NNN-slug/state.yaml
  7. If status: verified already:
  8. Display: "BUG-NNN is already verified."
  9. Exit without changes
  10. Set the status with the single writer: spec-status-write.py writes bugs/NNN-slug/state.yaml and every bugchen_status block that carries the bug, so the registry cannot keep the old status:
    python3 "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/spec-status-write.py" --cwd "$(git rev-parse --show-toplevel)" \
      --kind bug --status verified BUG-NNN
    

If $CLAUDE_PLUGIN_ROOT is not set, locate the writer relative to this skill file at ../../../vt-base/bin/spec-status-write.py, resolving the skill file's real path first (deployed skills are symlinks into the toolkit checkout). Never call it by a repo-relative plugins/vt-base/… path (BUG-342): this skill runs in the project, not the toolkit, so that path exists only when the project is the toolkit. If the writer does not resolve, that is a toolkit defect — report the path tried and stop.

Exit 3 with "not in the registry" means the bug was never triaged into .design-state.yaml: stop and report it, do not add the entry here — reason: /vt-d-triage-bugs owns a bug's registry entry and its title, severity and domain, which this step does not know. Exit 3 with "(registry-only)" means the bug IS registered but its entry has no dir: key, or one naming another directory than the one found in step 2: nothing was written. Stop and report it the same way, naming the directory step 2 found — reason: the missing or wrong dir: key is part of the entry /vt-d-triage-bugs owns, and marking the bug verified around it would leave the registry pointing nowhere. Then add verified_at: [ISO-8601 now] to bugs/NNN-slug/state.yaml — a lifecycle field the registry does not mirror (BUG-190), so it is written here and not by the writer. Never set status: in that file by hand — reason: the registry then keeps the old status, which is the drift guard 104 reports. 7. Reconcile the .active-spec pointer (BUG-017) — same idempotency guard as the spec path (Step 5.1), so a completed bug is not left as the active pointer:

bash "$CLAUDE_PLUGIN_ROOT/skills/complete/scripts/reconcile_active_spec.sh" \
  --completed-id BUG-NNN
If $CLAUDE_PLUGIN_ROOT is not set, run scripts/reconcile_active_spec.sh relative to this skill file's directory, resolving the skill file's real path first (deployed skills are symlinks into the toolkit checkout). Never call it by a repo-relative plugins/… path (BUG-347): this skill runs in the project, so that path exists only when the project is the toolkit. Clears .active-spec only if it names BUG-NNN; a pointer naming a different in-flight item is left untouched. 8. Display:
✓ BUG-NNN marked as verified
  Branch: fix/bug-NNN-slug
  State: bugs/NNN-slug/state.yaml → verified
9. Exit — do not continue to Step 2 (spec completion flow does not apply)

Step 2: Read Spec State

  1. Read specs/[N]-feature/state.yaml for the active spec
  2. If state.yaml does not exist:
  3. Display: "No state file found for SPEC-NNN. Use /vt-d-activate to properly activate a spec first."
  4. Exit gracefully

Step 2.5: Verify Workflow Gates

Read specs/[N]-feature/state.yaml and check all 4 phase gates. Display a dashboard:

Workflow Gate Check:
  [✓/✗] Plan     — [status or NOT RUN]
  [✓/✗] Build    — [status or NOT RUN]
  [✓/✗] Review   — [status or NOT RUN][, INCOMPLETE: <n> reviewer(s) uncovered]
  [✓/✗] Finalize — [status or NOT RUN]

⚠ The Review row must carry completeness, not only status (pass 13, H-6). The gate-check logic below treats an accepted status carrying completeness: INCOMPLETE as blocking, exactly as a failing status is — but the row printed only status, so on precisely the shape this rule exists for, the operator saw [✗] Review — PASS: a cross and the word PASS side by side, with the reason nowhere on screen. Against spec.md's requirement that the gate be legible without the spec, that line was not legible with it. Name the uncovered reviewers from reviewers_outstanding + reviewers_unmeasured + reviewers_deferred + reviewers_failed, and when degraded_reason is present print it verbatim — it is the field that says what to do next.

Gate check logic:

Ask the declaration for each gate; do not match a set written here. The writers and this matcher were built separately and their vocabularies diverged (BUG-089): measured on main, plan_gate was written PASS in 27 specs, against a matcher that accepted only COMPLETE — and 5 specs carried a with-caution finalize across two spellings. Every one of those had to be completed through gate_bypass, which drains the signal from the bypass record: a bypass then means "vocabulary mismatch" far more often than it means "we skipped a phase".

Pass the FILE, never the token. A status is repo content, and a $(…) in one ran when it was pasted into a double-quoted argument (review pass 4, SEC-20); accepts-file reads <gate>.status out of the artifact itself, and the declared field rows say which files carry which gate.

⚠ And never paste the PATH either. This step used to say "quote the path in single quotes: a path is repo content too" — that advice was wrong, and it was measured wrong (review pass 6, SEC6-3). Single quotes do stop $(…); they do not stop a directory committed as 999-x'$(touch PWNED)', which CLOSES the quote and executes. Let the shell expand the glob, and paste only the spec NUMBER, which cannot carry a quote:

$f is bound by the shell, never by the agent:

for f in specs/<NNN>-*/state.yaml; do [ -e "$f" ] || continue; ...; done

and each gate is then read from that file:

  • plan_gate — "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" plan_gate
  • build_gate — "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" build_gate
  • review_gate — "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" review_gate
  • finalize_gate — "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" finalize_gate

⚠ These four lines stay OUTSIDE the fence on purpose. Guard 88 derives its gate-read census with a fence-blanking reader, so a sanctioned reader moved inside a ```bash block disappears from that census — measured here: the region floor went 13 → 12 and guard 88 failed. A security fix must not buy itself a coverage loss somewhere else.

Exit code 0 → ✓. Exit code 1 → ✗, and the class printed on stdout says why it failed. An exit 3 means the token is declared nowhere: that is ✗ as well, and it is REPORTED — a gate is never passed on a token nobody can classify. An exit 5 means this file is not a declared artifact for that gate: report it and treat the gate as ✗; never fall back to reading the token yourself.

Each gate must also EXIST. A gate block that is absent — or a state.yaml that is not there at all — classifies as missing and exits 1, so a missing gate is ✗ through the same path as a failing one rather than through a branch written only here.

⚠ review_gate IS THE ONE GATE WITH A SECOND AXIS (SPEC-192). A pass that did not finish is not a pass. completeness: INCOMPLETE means a dispatched reviewer is uncovered — outstanding, unmeasured, deferred or failed — and an INCOMPLETE pass does not count as passing: it grades as a failure, or explicitly ungraded. accepts-file classifies the status token and nothing else, so this axis is a SECOND read of the same file: take review_gate.completeness alongside the exit code, and treat an accepted status carrying completeness: INCOMPLETE as ✗, exactly as a blocking class is.

Until pass 12 this skill matched on status alone. The combination was refused by validate-review-gate.sh — but only on .review-gate.md, which is gitignored and overwritten every run. state.yaml is the record that survives, so the rule was enforced on the artifact that does not last and absent from the one that does. spec-state.schema.json now also refuses the combination structurally, so this step and the schema fail the same shape independently.

⚠ An ABSENT completeness is not a COMPLETE one (pass 13, data-integrity H-2). Matching on "not INCOMPLETE" is satisfied by omitting the key, and so is the schema: its refusal is a not whose subschema carries required: [status, completeness], so the rule fires only when the key is PRESENT. Measured with spec-schema-check on a real record: PASS + INCOMPLETE → rc=1 (code: not); PASS + completeness absent → rc=0. A rule evaded by leaving a field out is this repo's recurring high-severity shape — a control that trains its own bypass.

The discriminator is validators_run, and it comes from the corpus, not from taste. Re-measured over specs/*/state.yaml on 2026-10-07, after the three live hits were resolved: 65 of 85 review_gate records are PASS with completeness absent — but 64 of those 65 have an empty or absent validators_run, so they were written before the axis existed, and for them absence is history rather than evasion. One record has a non-empty validators_run and no completeness:

Record validators_run spec status Blockable?
specs/212-* 4 completed no — already complete; this is the record the rule was written from, and its roster had just dropped from 9 to 4 with nothing recording that

So:

⚠ RE-MEASURE THIS PARAGRAPH BEFORE QUOTING IT, AND KNOW THAT NOTHING WILL TELL YOU TO. The figures above read "64 of 78 … 63 of those 64 … exactly one" until pass 14, measured on 2026-10-05; merging origin/main brought five more specs and made all four numbers false the same day. The suite's own corpus row (test-review-gates.sh, DI) asserts only that the legacy group is >= 50 and the validator-aware group is <= 3, so it stayed GREEN across the whole drift — by design, because pinning exact corpus counts here would red the suite on every unrelated spec. The consequence is that this is prose a reader must verify, not a figure the gate maintains.

⚠ It rotted a THIRD time on the next merge, and that time only ONE of the four figures moved — which is the harder shape to catch. The 2026-10-07 origin/main merge brought SPEC-217, a new live hit. Measured before resolving it: 85 records, 66 PASS-with-absent, 64 legacy, 2 validator-aware. Resolving 217 moved it out of the PASS-with-absent group altogether, so three of the four figures — 65, "64 of those 65", "one record" — came back numerically identical to the pre-merge text, and only the denominator was wrong (84 → 85). A reader spot-checking two or three numbers would have confirmed a paragraph that was false. Check every figure, or none of them. The DI row also moved 1 → 2 against its own <= 3 bound: two more live hits and the suite reds, which is the bound doing its job, not slack to spend.

⚠ This rule DID block three in-flight specs, and all three were resolved by RECORDING the axis — not by weakening the rule (owner decision, 2026-10-06; applied again to the third on 2026-10-07). specs/218-* (6 reviewers), specs/215-* (2 reviewers) and specs/217-* (8 reviewers) were each in_progress with a GO finalize gate, a non-empty validators_run and no completeness. In all three cases the axis was derivable from the record's own words, so none needed a re-review and none needed a gate_bypass:

  • 218 — its notes say verbatim "6 of 6 returned", with per-reviewer C/H/M/L for all six. Every dispatched reviewer returned and was measured → COMPLETE.
  • 215 — its pass-12 triage says "Reduced set for the fix-pass-11 diff … security-sentinel and tdd-compliance-reviewer … both clean at grading". Two were dispatched and two returned, so nothing was dispatched and lost → COMPLETE. The reduction is relative to earlier passes' rosters, not to pass 12's own dispatch, and this rule's text keys on "a roster smaller than the one DISPATCHED". ⚠ Note what that means: nothing compares a pass's roster to the spec's earlier rosters, so a fix-pass may narrow 8 → 2 and record COMPLETE legitimately. That is defensible for a narrow diff; it is recorded here because it is not obvious.
  • 217 — the clearest of the three, and it arrived last: its review_gate.notes say verbatim "All 8 selected reviewers returned (COMPLETE)", naming the axis in the record's own word. It entered the corpus through the 2026-10-07 origin/main merge already carrying its own answer, which is the case for leaving this rule strict: the information the rule needs is usually already written down, just not in the field the rule reads.

⚠ Recording an axis can UNBLOCK a spec whose gate is stale, and nothing would notice. 218's notes also say "All ten Mediums fixed in c354ef12, which was not re-reviewed", so its gate grades a tree ten Medium-fixes behind the code. Measured over all four review_gate consumers: only workflow-5-finalize step 4b compares head to HEAD, and it only warns; validate-review-gate.sh, promote and this skill do not check it at all. The roster axis was still recorded, because withholding a true fact is not a control — the head axis is filed as its own finding at intake/pending/spec-192-followup-review-gate-head-staleness-is-not-gated.md.

⚠ specs/215-* was NOT in the pass-14 finding — it arrived with the origin/main merge the same day, and nothing announced it. The finding named one subject; re-measuring found two. Each origin/main merge can add a hit, and the suite's corpus row cannot see it: test-review-gates.sh (DI) asserts only that the legacy group is >= 50 and the validator-aware group is <= 3, so a third live hit landed INSIDE the bound and stayed green. At 4 it would red — the bound doing its job, not a regression. Re-measure this list before acting on it; do not trust the count above to still be one.

⚠ The axis could NOT be recorded on main, and that is the same rule as Decision 8. Measured: completeness appears zero times in main's spec-state.schema.json, and spec-schema-check refuses both edits there with additional-property on /review_gate/completeness. The field is admitted only on this branch, which is where its writer lives — so the two record changes had to travel WITH the schema that admits them, exactly as Decision 8 kept the five fields off main ahead of their writer. A record change that looks like pure bookkeeping is still a schema-bound change.

  • validators_run non-empty and completeness absent → BLOCKING. The writer knew the field and did not write it, which is the evasion. Print [✗] Review — PASS but completeness NOT RECORDED (validators_run names <n>) → re-run /vt-d-4-review to record the axis.
  • validators_run empty or absent → accept, as today. Retro-mutating dated records is forbidden and so is baselining them, so the rule has to describe the corpus. This is not a free dodge: a PASS with no validators_run already triggers the SPEC-189 FR-4 provenance notice, so buying silence on this axis costs you that one.
  • completeness: COMPLETE with ANY of the four uncovered lists non-empty → BLOCKING (pass 14, data-integrity H-1). MEASURED: spec-schema-check ACCEPTS a record that pairs an accepted status with completeness: COMPLETE and reviewers_failed: [security-sentinel] — rc=0, no violation — while validate-review-gate.sh REFUSES the same shape, because it derives uncovered over all four states. So the rule is enforced on .review-gate.md, which is gitignored and overwritten every run, and absent from state.yaml, which is the record that survives. That is verbatim the asymmetry this spec exists to close, reopened one axis over. It cannot go in the schema: spec_schema.py's ASSERTIONS implements not but neither allOf nor anyOf, and review_gate's single not slot is already spent on PASS+INCOMPLETE — the same constraint as the paragraph below, and the filed intake record is extended to name this second rule too. Print [✗] Review — completeness: COMPLETE but <n> reviewer(s) uncovered (<names>) → re-run /vt-d-4-review.
  • COMPLETE_FOR_REDUCED_ROSTER → BLOCKING, and this goes one step past H-2's text on the same reviewer's observation: the value means "complete for a roster smaller than the one dispatched", which is what this spec calls INCOMPLETE. The schema lists it so one SPEC-193 record stays valid, explicitly "not as a value new passes should write", and that record is already closed — so blocking it changes nothing in flight and removes a schema-blessed uncovered-roster PASS.

⚠ Why this rule lives here and not in the schema. spec_schema.py's ASSERTIONS implements not but neither allOf nor anyOf, and review_gate already spends its single not slot on the PASS+INCOMPLETE rule — two independent cross-field rules on one object cannot be expressed in that subset. Widening the validator belongs to SPEC-212, not here; it is filed at intake/pending/spec-212-schema-needs-allof-for-a-second-cross-field-rule.md rather than left as a comment, because routing around a defect obliges a record.

⚠ THE BLOCKING TOKENS ARE UNCHANGED. Anything of class blocking still blocks, and widening never turns a negative verdict into a pass. What widened is only the set of tokens that already meant success in the writer that emitted them.

⚠ A with-caution finalize is ACCEPTED, deliberately. /vt-d-5-finalize emits it for a passing run that carries advisories; it is a pass with notes, not a soft refusal.

⚠ The hyphenated with-caution spelling is retained DEFENSIVELY, not because a writer emits it. Measured over the full history: no skill or script has ever written it. It reached two state.yaml files by hand, and both have since been normalised — the corpus is now one spelling. It survives as a declared ALIAS of the canonical token, because the route that produced it, a human typing the token directly, is still open, and dropping it would re-block the next hand-written record for a verdict that plainly succeeded. A tolerance for hand entry, declared once, is not a second vocabulary.

⚠ Everything that is neither accepted nor blocking is ✗ and left to gate_bypass on purpose — the placeholder and qualified classes. Each asserts something weaker than the gate it stands in: a phase skipped, a phase unfinished, or a pass with a condition on it. Quietly accepting those would be the amnesty this check exists to prevent. If one of them should count, that is a separate decision with its own evidence, and it is made in the declaration.

If all 4 gates pass:

✓ Workflow gates verified: plan ✓, build ✓, review ✓, finalize ✓
Continue to Step 3.

If any gate is missing or failing:

BLOCKED: Workflow gates incomplete.

Missing/failing gates:
  [✗] Review  — NOT RUN → Run /vt-d-4-review
  [✗] Review  — PASS but INCOMPLETE (<n> reviewer(s) uncovered: <names>) → see the per-state
                 remedy below; do NOT print one remedy for all four states
  [✗] Review  — PASS but completeness NOT RECORDED (validators_run names <n>) → re-run
                 /vt-d-4-review so the pass records which reviewers it covered
  [✗] Finalize — NOT RUN → Run /vt-d-5-finalize

⚠ THE REMEDY DEPENDS ON WHICH STATE MADE THE PASS INCOMPLETE (pass 14, document-quality M1). This sample used to read (<names> did not measure) → grant the rule named in the gate's degraded_reason, which is the remedy for exactly ONE of the four states this file itself defines INCOMPLETE over. For the other three there is no rule to grant, and sending the operator to look for a permission problem that does not exist is the defect, not a wording nicety. workflow-5-finalize/SKILL.md step 2b is the authority and already splits them; print the part that applies, in its words:

  unmeasured  — returned without measuring → grant the rule named in degraded_reason
                (verbatim, never a paraphrase), then re-run /vt-d-4-review
  outstanding — never returned → re-run /vt-d-4-review; there is no report to grade
  deferred    — stood down for this pass → dispatch them and re-run, or record the
                decision to grade without them
  failed      — dispatched and did not complete → re-run for these reviewers;
                there is no rule to grant here

Complete the missing phases before marking this spec done.

Do not ask — run the missing phase (reason: running it is the only non-emergency answer): print "→ Decision: running [/missing-phase] first — gate missing", invoke the first missing phase, then re-run /vt-d-complete.

Emergency bypass — only when the user explicitly asks to skip the gate check. Never choose it yourself — reason: it records a permanent bypass of review/finalize in the spec's history. Take the reason from the user's request (ask for it only if they gave none). Then write to state.yaml:

gate_bypass:
  date: "YYYY-MM-DD"
  reason: "[user-provided reason]"
  skipped_gates: [list of missing gates]
This creates a permanent, committed record of the bypass. Continue to Step 3.

Backward compatibility: If state.yaml has no gate fields at all (old spec completed before SPEC-095), skip this check silently — do not retroactively enforce on specs that predate the gate system.

Provenance check (SPEC-189 FR-4) — attached to the PASS path only. For each gate above, read validator alongside status. If it is not agent — including absent, which reads as unknown and never as agent (FR-6) — display:

⚠ This gate's <status> was NOT produced by its prescribed validator (validator: <value>).
  Did not run: <prescribed − validators_run>, from the writing skill's `requires_agents`.
  When no prescribed set is declared, name what DID run instead and say so.

⚠ This is a BLOCKING region (BLOCKED: Workflow gates incomplete), so the notice carries no "Continuing" line. FR-5 restated: the notice changes no branch — it must never be read as licence to soften a block, and a self-assessed FAIL still blocks.

Step 3: Check Already Completed

Classify the spec's own state.yaml — the file rather than the status value (SEC-20), and with the shell expanding the path rather than the agent pasting it (SEC6-3), whose last field is the class:

for f in specs/<NNN>-*/state.yaml; do          # <NNN> = the spec number, nothing read off disk
  [ -e "$f" ] || continue
  "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" classify-file "$f"
done
— and if that class is end, the spec is already finished, whichever end state it reached: - Display: "SPEC-NNN is already finished ({status}). Use /vt-d-activate to pick the next spec." - Exit without modifying state - An exit 3 — a literal nobody declares — is reported as ⚠ unknown spec status "{value}" in {file} and the run CONTINUES: a spec is never treated as finished on the strength of a status nobody declared.

Step 4: Warn on Incomplete Steps

If steps: exists in state.yaml and any step is not completed:

⚠ Not all steps are completed:
  Step 5: US4 — PR template  (pending)

Do not ask and do not mark the spec complete: print "→ Decision: not completing — N step(s) still pending", list the pending steps, and exit without changes; the Next-actions block points to /vt-d-3-build to finish them (reason: completing with open steps hides unfinished work). Complete anyway only if the user explicitly asked for it → proceed to Step 4.5.

Step 4.5: State Documentation Check (advisory)

Check whether this spec's changes may have affected UI state documentation.

  1. Check for state matrix existence: Look for 01-docs/deliverables/implementation-readiness/ui-state-matrix.md in the project. If the file does not exist → skip to Step 5 silently.

  2. Get changed files for this spec:

    git diff --name-only $(git merge-base HEAD main) HEAD
    
    If the git command fails → skip to Step 5 silently.

  3. Grep for state-affecting patterns in changed file names and content:

  4. Filename patterns: *state*, *State*, *guard*, *route*
  5. Content patterns: enum.*State, interface.*State, type.*State, ngIf, ngSwitch, @if, @switch, [hidden], canActivate

  6. If matches found (advisory, non-blocking):

    State documentation advisory:
    ─────────────────────────────────────────────────────────────────
    This spec changed {N} file(s) with UI state logic. Consider updating:
    • 01-docs/deliverables/implementation-readiness/ui-state-matrix.md
    • Test dataset (if permutations changed)
    ─────────────────────────────────────────────────────────────────
    

  7. Proceed to Step 4.6 regardless (advisory only).

Step 4.6: Compound Knowledge Capture

Before marking the spec done, check whether it produced reusable learnings worth documenting.

Auto-skip conditions (any ONE is sufficient to skip — proceed to Step 5 silently): - No plan.md exists for this spec (was a quick fix, not a planned feature) - Git diff from branch is small (<50 lines changed) AND all review findings were LOW or informational

If none of the auto-skip conditions are met, proceed with compound capture.

If compound capture is warranted:

  1. Read the spec's artifacts:
  2. specs/[N]-feature/spec.md — problem and goal
  3. specs/[N]-feature/plan.md — key architecture decisions
  4. Review findings from specs/[N]-feature/review-triage.md (if exists)
  5. Session journal entries from docs/vt-c-journal/ (if any for this spec)

  6. Identify compound-worthy learnings:

  7. Non-obvious solutions (the fix wasn't what you'd expect)
  8. Architecture decisions with rationale (chose X over Y because Z)
  9. Patterns applicable beyond this spec (reusable across projects)
  10. Gotchas or pitfalls discovered during implementation

  11. If learnings found, present a preview:

    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    Compound Knowledge Capture
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    
    From SPEC-{NNN}: {title}
    
    Learning: {1-2 sentence summary of the reusable insight}
    
    Problem: {from spec problem section}
    Solution: {from plan key decisions}
    Prevention: {how this pattern prevents future issues}
    Tags: {extracted from spec and review}
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    

  12. Decide yourself and print one decision line:

  13. Capture (default) — the summary holds a reusable insight → write the compound doc using /vt-c-compound format
  14. Skip — the summary is generic (no insight beyond "the spec was implemented") → proceed to Step 5

  15. If "Capture": invoke the compound-docs skill's writing logic — the doc is written to the appropriate docs/solutions/{category}/ subdirectory using the compound-docs YAML frontmatter schema (symptoms, root_cause, resolution_type, severity, module, problem_type, date, tags). Reference SPEC-NNN in the body.

Step 5: Mark as Completed

Spec status lives in three files, and all three must agree or the repo's own suite goes red. status in .design-state.yaml is written only by spec-status-write.py, the single writer — so invoke it rather than editing the three files one at a time. completed_at is this step's job and not the writer's, and it is CHECKED before the writer runs, so a refusal leaves all three files as they were (BUG-324, second review of PR #501: the check used to run after the writer, so the status had already moved when it refused):

STATE="specs/<N>-<slug>/state.yaml"
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
completed_at_edit() {  # ${1} = check | write
  python3 - "${1}" "$STATE" "$NOW" "$CLAUDE_PLUGIN_ROOT/../vt-base/lib" <<'EOF'
import os, re, sys
mode, p, now = sys.argv[1], sys.argv[2], sys.argv[3]
sys.path.insert(0, sys.argv[4])       # SPEC-212's stdlib reader, shipped in vt-base/lib
import spec_yaml
try:
    text = open(p, "rb").read().decode("utf-8")
except UnicodeDecodeError:
    sys.exit("%s is not UTF-8 - nothing written" % p)
lines = text.split("\n")
hits = [i for i, l in enumerate(lines) if re.match(r"[\"']?completed_at[\"']?[ \t]*:", l)]
if len(hits) > 1:
    sys.exit("more than one completed_at line in %s - remove the duplicate, then re-run" % p)
if hits and not lines[hits[0]].startswith("completed_at"):
    sys.exit("%s spells completed_at as a quoted key - rewrite it plainly, then re-run" % p)
new = 'completed_at: "%s"' % now
if hits:
    lines[hits[0]] = new
elif lines and lines[-1] == "":
    lines.insert(len(lines) - 1, new)
else:
    lines.append(new)
out = "\n".join(lines)
try:
    got = spec_yaml.load(out).get("completed_at")
except Exception as exc:
    sys.exit("%s: the edit would not be readable YAML (%s) - nothing written" % (p, exc))
if str(got) != now:
    sys.exit("%s: the line edited is not the completed_at YAML reads - nothing written" % p)
if mode == "write":
    open(p, "w", encoding="utf-8").write(out)
EOF
}
completed_at_edit check || exit 1            # refused here: nothing at all has been written
python3 "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/spec-status-write.py" \
  --cwd "$(git rev-parse --show-toplevel)" \
  --status completed <SPEC-NNN>              # non-zero: report it, do not stamp completed_at
completed_at_edit write

The writer sets, in this order:

  1. specs/[N]-feature/state.yaml → status: completed
  2. .design-state.yaml → specs_status.<SPEC-NNN>.status, in every block that carries the id
  3. specs/[N]-feature/spec.md → the frontmatter status:

⚠ The edit is verified with a YAML reader before it is written (BUG-324). Exactly one completed_at line is rewritten. A second one (SPEC-170 carries one, and completed_at : with a space counts) stops the step instead of rewriting both. A rewrite that YAML would not read as the new value is not written: a forged completed_at: line inside a multi-line quoted value made the earlier version rewrite the wrong line and leave the file unparseable. A refusal from the check run means nothing at all was written — report it. A refusal from the write run (the file changed between the two) leaves the status written and completed_at unstamped — report that, and re-run the step. The same routine backs complete/scripts/sync_spec_status.sh. spec_yaml is resolved beside the writer, $CLAUDE_PLUGIN_ROOT/../vt-base/lib, the same way the writer call above is (BUG-342).

⚠ Why completed_at is here and not in the writer. The writer mirrors the fields the registry and the frontmatter share; completed_at exists only in state.yaml and has no mirror to keep in agreement, so putting it in the writer would give it a field it can never verify (BUG-190). The division is deliberate: the writer owns what must agree across files, this step owns what belongs to the lifecycle.

⚠ WHY ALL THREE, and why this is not best-effort. Guard 54 (54-spec-status-is-declared-and-agrees.sh) holds (2) against (1); guard 89 (89-spec-frontmatter-status-ratchet.sh) holds (3) against (2) as a shrink-only ratchet. Measured 2026-09-20 completing SPEC-203: writing (1) alone failed guard 54, and fixing (2) — the obvious next move — then failed guard 89, whose own message says the registry is authoritative and forbids adding the spec to the baseline. Only after (3) did the suite pass.

Unlike Steps 5.1 and 5.7, a failure here is reported and non-zero. A stale .active-spec or a leftover override is untidy; a half-written status turns the suite red far from the skill that caused it. If the writer exits non-zero, surface its message — do not mark the spec done and move on. Exit 5 means a file is ahead of the others and re-running the same command completes it; exit 6 means the post-image was rejected, nothing is left changed, and it should be reported as a writer or library defect rather than retried.

Exit 3 with "(registry-only)" means the registry entry does not lead to a spec directory: it has no directory key, or its keys name a directory that does not exist. Nothing was written. Stop, report the entry and what ls specs/ shows for its number, and let the user decide — reason: the repairs mean different things, and only the user knows which is true:

  • the entry has no directory key and its directory exists → re-run with --status completed --create <SPEC-NNN> --dir <N>-<slug> naming that directory; the writer adds the keys and completes all three files;
  • its keys name a directory that is really gone → the same call with exactly the directory the keys name marks the registry alone completed (it is the entry's only record) and creates nothing;
  • its keys name a missing directory while the spec lives under another name → the writer has no repair for this: --create with the real directory is refused as a dir drift (exit 3), because it never rewrites a directory key. Report it; correcting the key is the user's edit.

Never guess the directory from the number alone — reason: a --dir naming another spec's directory is refused before anything is written (exit 3, dir drift) when the writer can tell - another entry resolves to it, or its frontmatter id: names another spec - and a guess that slips past both writes the status into the wrong spec.

Use --check-only to see what disagrees without writing anything. It is write-free by construction — every file write in the writer goes through one function, and that path never reaches it — and it exits 1 when fields disagree, 3 when an entry is refused outright.

The old helper scripts/sync_spec_status.sh still exists and still works: it now delegates to the same writer. It is deprecated and will be removed a release after this one, so new callers use the writer directly.

Step 5.1: Reconcile the .active-spec Pointer (BUG-017)

After the spec is marked completed, clear the project-root .active-spec pointer so a finished spec is never left as the "active" one. This runs independently of Step 7 — completing without immediately activating a next spec must not leave a stale pointer that mis-targets all .active-spec-derived tooling (both this skill's Step 1 and /vt-d-activate read it as priority-1).

bash "$CLAUDE_PLUGIN_ROOT/skills/complete/scripts/reconcile_active_spec.sh" \
  --completed-id <SPEC-NNN>

If $CLAUDE_PLUGIN_ROOT is not set, run scripts/reconcile_active_spec.sh relative to this skill file's real directory, as in Step 1.5. Not by a repo-relative plugins/… path (BUG-347).

The helper is best-effort and idempotent: it clears .active-spec only when it still names the just-completed SPEC-NNN, and leaves a pointer that names a different, in-flight spec untouched — never clobber a pointer that points elsewhere, because a concurrent spec may legitimately be active. If the user then activates a next spec in Step 7, /vt-d-activate sets .active-spec to that spec; this clear-then-set ordering is intentional.

Step 5.5: User Manual Update (optional)

If docs/user-manual/mkdocs.yml exists:

  1. Check if the completed spec has a docs_page field in its state.yaml, OR if a matching feature page exists in docs/user-manual/docs/features/
  2. If a matching page is found or docs_page exists:
    User manual page found for this feature.
    
    Run /vt-d-user-manual-update for this spec without asking (the recommended path); skip only if the user said so.
  3. If no match found: skip silently (feature may not have a manual page)

If docs/user-manual/ does not exist: skip this step silently.

Step 5.7: Override Cleanup (SPEC-122 / Decision 3)

After the spec is marked completed, remove any matching entry from pm-priority-overrides/<Domain>.yaml. PM-confirmed priority overrides are only meaningful for in-flight specs — once a spec ships, the override becomes drift signal that pollutes future dashboard runs.

Invoke the helper:

bash "$CLAUDE_PLUGIN_ROOT/skills/complete/scripts/cleanup_override.sh" \
  --spec-id <SPEC-NNN>

If $CLAUDE_PLUGIN_ROOT is not set, run scripts/cleanup_override.sh relative to this skill file's real directory. Not by a repo-relative plugins/… path (BUG-347).

The helper resolves the SPEC's domain via the lookup chain: 1. Read .design-state.yaml#specs_status.SPEC-NNN.dir → relative spec dir 2. Absolute spec dir = <toolkit_root>/<spec.dir> 3. Longest-prefix match against intake/projects.yaml#projects[].path 4. Read .domain field from the matched project entry 5. Target file: pm-priority-overrides/<DomainName>.yaml

Then delegates to write_override.sh --delete --domain <D> --spec-id <SPEC-NNN>.

Cleanup is best-effort — every lookup failure (missing specs_status entry, no project owns the dir, project lacks domain:, override file absent) logs a WARN to stderr and exits 0. Cleanup MUST NOT undo a successful /vt-d-complete — if metadata is wrong, the override entry stays in place and the PM can hand-edit pm-priority-overrides/<Domain>.yaml.

Runs LAST, after Steps 2.5 + 5 close the workflow gates. If completion itself fails (e.g. Step 2.5 finds a missing gate), this step is unreached and the override survives until the next completion attempt.

Step 6: Identify Next Ready specs

  1. Read .design-state.yaml for static metadata (dependencies, priorities)
  2. Scan all specs/[N]-feature/state.yaml files for current status
  3. Find the ready specs by class. The shell expands the glob; a directory name is repo content and is never pasted into the command (SEC6-3). The file, never the status value (SEC-20); the last field is the class:
for f in specs/*/state.yaml; do
  [ -e "$f" ] || continue
  printf '%s\t' "$f"
  "$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" classify-file "$f"
done

a candidate's own class is active and its literal is not in_progress (that one is already being worked), and every dependency's class is end. An exit 3 means the literal is declared nowhere: report ⚠ unknown spec status "{value}" in {file} and leave that spec out of the ready list rather than guessing which side of the partition it falls on. 4. Sort by priority (P0 first), then by numeric ID

Step 7: Offer Next Spec or Show Completion

If next ready specs exist:

SPEC-002: Dashboard Layout completed!
Next: SPEC-003: Data Import. Activating.

Do not ask — delegate to /vt-d-activate SPEC-003 (the next ready spec; with several, the highest-priority one). Show the dashboard (/vt-d-activate --status) instead only if the user said not to continue.

If all specs completed:

All specs completed!

Progress: 6/6 completed
Run /vt-d-5-finalize for final governance audit and deployment.

Next actions

After marking the spec complete, offer the next move:

  • Next ready spec exists → activate it with /vt-d-activate SPEC-NNN.
  • No spec staged → show the dashboard with /vt-d-activate --status.
  • All specs done → run /vt-d-5-finalize for the final governance audit.