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 specsMarks 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-reviewpasses 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-specfile) - The spec's
specs/[N]-feature/state.yamlmust exist
Invocation¶
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:
.active-specfile (if exists in project root):- Read the file content (single line: e.g.,
SPEC-012) -
Use this as the active spec
-
Git branch name (if no
.active-spec): - Run:
git branch --show-current - If branch matches
feature/spec-NNN-*: extract NNN → SPEC-NNN → continue to Step 2 - If branch matches
fix/bug-NNN-*: extract NNN → BUG-NNN → go to Step 1.5 (Bug Completion) - Example:
feature/spec-012-design-state-split→ SPEC-012 -
Example:
fix/bug-007-login-safari→ BUG-007 -
Fallback: No active spec or bug found
- Display: "No active spec or bug found. Use /vt-d-activate to start working on a spec or bug."
- Exit gracefully
Step 1.5: Bug Completion (for fix/bug-NNN-* branches)¶
- Extract NNN from the branch name:
fix/bug-007-login-safari→007 - Glob
bugs/007-*/state.yaml(replace007with the extracted number) to find the bug directory - If not found:
- Display: "BUG-007 directory not found in bugs/. Cannot mark as verified."
- Exit gracefully
- Read
bugs/NNN-slug/state.yaml - If
status: verifiedalready: - Display: "BUG-NNN is already verified."
- Exit without changes
- Set the status with the single writer:
spec-status-write.pywritesbugs/NNN-slug/state.yamland everybugchen_statusblock that carries the bug, so the registry cannot keep the old status:
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
$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:
9. Exit — do not continue to Step 2 (spec completion flow does not apply)
Step 2: Read Spec State¶
- Read
specs/[N]-feature/state.yamlfor the active spec - If
state.yamldoes not exist: - Display: "No state file found for SPEC-NNN. Use /vt-d-activate to properly activate a spec first."
- 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:
and each gate is then read from that file:
plan_gate—"$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" plan_gatebuild_gate—"$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" build_gatereview_gate—"$CLAUDE_PLUGIN_ROOT/../vt-base/bin/status-vocab.sh" accepts-file "$f" review_gatefinalize_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
notessay 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 recordCOMPLETElegitimately. 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.notessay verbatim "All 8 selected reviewers returned (COMPLETE)", naming the axis in the record's own word. It entered the corpus through the 2026-10-07origin/mainmerge 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 theorigin/mainmerge the same day, and nothing announced it. The finding named one subject; re-measuring found two. Eachorigin/mainmerge 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>= 50and 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_runnon-empty andcompletenessabsent → 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_runempty 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: aPASSwith novalidators_runalready triggers the SPEC-189 FR-4 provenance notice, so buying silence on this axis costs you that one.completeness: COMPLETEwith ANY of the four uncovered lists non-empty → BLOCKING (pass 14, data-integrity H-1). MEASURED:spec-schema-checkACCEPTS a record that pairs an acceptedstatuswithcompleteness: COMPLETEandreviewers_failed: [security-sentinel]— rc=0, no violation — whilevalidate-review-gate.shREFUSES the same shape, because it derivesuncoveredover all four states. So the rule is enforced on.review-gate.md, which is gitignored and overwritten every run, and absent fromstate.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'sASSERTIONSimplementsnotbut neitherallOfnoranyOf, andreview_gate's singlenotslot 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:
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]
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
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:
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.
-
Check for state matrix existence: Look for
01-docs/deliverables/implementation-readiness/ui-state-matrix.mdin the project. If the file does not exist → skip to Step 5 silently. -
Get changed files for this spec:
If the git command fails → skip to Step 5 silently. -
Grep for state-affecting patterns in changed file names and content:
- Filename patterns:
*state*,*State*,*guard*,*route* -
Content patterns:
enum.*State,interface.*State,type.*State,ngIf,ngSwitch,@if,@switch,[hidden],canActivate -
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) ───────────────────────────────────────────────────────────────── -
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:
- Read the spec's artifacts:
specs/[N]-feature/spec.md— problem and goalspecs/[N]-feature/plan.md— key architecture decisions- Review findings from
specs/[N]-feature/review-triage.md(if exists) -
Session journal entries from
docs/vt-c-journal/(if any for this spec) -
Identify compound-worthy learnings:
- Non-obvious solutions (the fix wasn't what you'd expect)
- Architecture decisions with rationale (chose X over Y because Z)
- Patterns applicable beyond this spec (reusable across projects)
-
Gotchas or pitfalls discovered during implementation
-
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} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -
Decide yourself and print one decision line:
- Capture (default) — the summary holds a reusable insight → write the compound doc using
/vt-c-compoundformat -
Skip — the summary is generic (no insight beyond "the spec was implemented") → proceed to Step 5
-
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:
specs/[N]-feature/state.yaml→status: completed.design-state.yaml→specs_status.<SPEC-NNN>.status, in every block that carries the idspecs/[N]-feature/spec.md→ the frontmatterstatus:
⚠ 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:
--createwith the real directory is refused as adirdrift (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:
- Check if the completed spec has a
docs_pagefield in itsstate.yaml, OR if a matching feature page exists indocs/user-manual/docs/features/ - If a matching page is found or
docs_pageexists: Run/vt-d-user-manual-updatefor this spec without asking (the recommended path); skip only if the user said so. - 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:
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¶
- Read
.design-state.yamlfor static metadata (dependencies, priorities) - Scan all
specs/[N]-feature/state.yamlfiles for current status - 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:
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-finalizefor the final governance audit.