From 77c7b4fc9d002178719bababae67554ae425224b Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 24 Jun 2026 12:19:10 -0500 Subject: [PATCH] fix(#1522): enforce canonical verification before phase transition (#1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent Co-authored-by: Codesmith Co-authored-by: Claude Opus 4.6 Co-authored-by: Tom Boucher --- .changeset/kind-moles-hop.md | 5 + CONTEXT.md | 3 + gsd-core/workflows/autonomous.md | 99 ++++---- gsd-core/workflows/complete-milestone.md | 35 ++- gsd-core/workflows/manager.md | 24 +- gsd-core/workflows/progress.md | 41 +++- gsd-core/workflows/transition.md | 26 +- gsd-core/workflows/ui-review.md | 8 +- gsd-core/workflows/verify-work.md | 44 ++++ src/init.cts | 229 ++++++++++++++--- src/io.cts | 1 + src/phase.cts | 24 +- src/plan-scan.cts | 4 +- src/uat-predicate.cts | 18 +- src/verification.cts | 89 ++++++- tests/247-phase-uat-passed.test.cjs | 37 +++ .../4-phase-complete-cjs-regression.test.cjs | 48 ++-- tests/autonomous-converge.test.cjs | 100 ++++++++ ...2769-requirements-header-variants.test.cjs | 4 + ...added-id-against-unpadded-roadmap.test.cjs | 4 + tests/bug-3588-npm-audit-clean.test.cjs | 15 +- ...ug-853-bg-dispatch-runtime-gating.test.cjs | 20 ++ tests/concurrency-safety.test.cjs | 8 + tests/feat-3262-scan-phase-plans.test.cjs | 2 + tests/init-manager.test.cjs | 118 ++++++++- tests/init.test.cjs | 29 +++ tests/milestone-summary.test.cjs | 34 +++ tests/phase.test.cjs | 231 ++++++++++++++++-- tests/progress-forensic.test.cjs | 39 ++- tests/state.test.cjs | 11 + tests/transition-verification-gate.test.cjs | 19 ++ tests/uat-predicate.test.cjs | 21 +- tests/ui-review-next-guidance.test.cjs | 50 ++++ tests/verification-status.test.cjs | 68 ++++++ tests/verify-work-auto-transition.test.cjs | 41 ++++ tests/workflow-size-baseline.json | 14 +- 36 files changed, 1354 insertions(+), 209 deletions(-) create mode 100644 .changeset/kind-moles-hop.md create mode 100644 tests/transition-verification-gate.test.cjs create mode 100644 tests/ui-review-next-guidance.test.cjs diff --git a/.changeset/kind-moles-hop.md b/.changeset/kind-moles-hop.md new file mode 100644 index 000000000..1b4f60704 --- /dev/null +++ b/.changeset/kind-moles-hop.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1548 +--- +**Phase transitions now require fresh canonical verification** - implementation-complete phases no longer advance when verification is missing, gap-bearing, human-pending, or stale relative to phase summaries. diff --git a/CONTEXT.md b/CONTEXT.md index b427b8661..0b057ae16 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,9 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali ### Phase Lifecycle Module Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.) +### Verification Module +Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). `findStaleVerificationSummary` flags a SUMMARY newer than the VERIFICATION file (status `stale`). Both honor a no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale) and an injectable `opts.fs` seam. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`). + ### Phase Locator Module Module owning phase-directory search and location: active-phase discovery against the `.planning/phases/` tree (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem. Depends only on leaf modules (`phase-id` for token/name matching, `core-utils` for fs-scan/path helpers, `planning-workspace` for `planningDir`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-locator.cjs` (generated from `src/phase-locator.cts`). diff --git a/gsd-core/workflows/autonomous.md b/gsd-core/workflows/autonomous.md index cfcd5d13a..3e259f6c9 100644 --- a/gsd-core/workflows/autonomous.md +++ b/gsd-core/workflows/autonomous.md @@ -61,7 +61,7 @@ fi When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies. -When `--interactive` is set, discuss runs inline with questions (not auto-answered). On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. +When `--interactive` is set, discuss runs inline with questions. On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. When `PLAN_STRATEGY=converge`, the planning step MUST invoke the plan-review convergence workflow instead of `gsd-plan-phase`. `--cross-ai` is an alias for `--converge`. Forward `CONVERGENCE_ARGS` exactly as parsed so reviewer flags and `--max-cycles N` retain the same meaning as they have on `/gsd:plan-review-convergence`. @@ -123,18 +123,19 @@ If `PLAN_STRATEGY` is `converge`, display: `Planning: Plan-review convergence en Run phase discovery: ```bash -ROADMAP=$(gsd_run query roadmap.analyze) +INIT_MANAGER=$(gsd_run query init.manager) +if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi ``` Parse the JSON `phases` array. -**Filter to incomplete phases:** Keep only phases where `disk_status !== "complete"` OR `roadmap_complete === false`. +**Filter to incomplete phases:** Keep `phase_complete !== true`, including implemented phases with `verification_status !== "passed"`. -**Apply `--from N` filter:** If `FROM_PHASE` was provided, additionally filter out phases where `number < FROM_PHASE` (use numeric comparison — handles decimal phases like "5.1"). +**Apply `--from N`:** If set, filter out phases where `number < FROM_PHASE` (numeric compare; handles "5.1"). -**Apply `--to N` filter:** If `TO_PHASE` was provided, additionally filter out phases where `number > TO_PHASE` (use numeric comparison). This limits execution to phases up through the target phase. +**Apply `--to N`:** If set, filter out phases where `number > TO_PHASE` (numeric compare). -**Apply `--only N` filter:** If `ONLY_PHASE` was provided, additionally filter OUT phases where `number != ONLY_PHASE`. This means the phase list will contain exactly one phase (or zero if already complete). +**Apply `--only N`:** If set, filter out phases where `number != ONLY_PHASE`. **If `TO_PHASE` is set and no phases remain** (all phases up to N are already completed): @@ -477,58 +478,51 @@ Skill(skill="gsd-code-review", args="${PHASE_NUM} --fix --auto") **3d. Post-Execution Routing** -**If `INTERACTIVE` is set:** Wait for the execute agent to complete before reading verification results. - -After execute-phase returns (or the execute agent completes), read the verification result: +After execute, read canonical verification: ```bash -VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') +VERIFY_STATUS=$(gsd_run query verification.status "${PHASE_DIR}" 2>/dev/null | jq -r '.status//empty') ``` -Where `PHASE_DIR` comes from the `init phase-op` call already made in step 3a. If the variable is not in scope, re-fetch: +If `PHASE_DIR` is absent, re-fetch `init.phase-op ${PHASE_NUM}` and parse `phase_dir`. -```bash -PHASE_STATE=$(gsd_run query init.phase-op ${PHASE_NUM}) -``` - -Parse `phase_dir` from the JSON. - -**If VERIFY_STATUS is empty** (no VERIFICATION.md or no status field): - -Go to handle_blocker: "Execute phase ${PHASE_NUM} did not produce verification results." +If `VERIFY_STATUS` is empty, handle_blocker: "No verification results for phase ${PHASE_NUM}." **If `passed`:** -Display: -``` -Phase ${PHASE_NUM} ✅ ${PHASE_NAME} — Verification passed -``` +Display `Phase ${PHASE_NUM} ✅ ${PHASE_NAME} — Verification passed`, run `@~/.claude/gsd-core/workflows/transition.md`, then Proceed to iterate step. -Proceed to iterate step. +**If `stale`:** handle_blocker: "Stale verification for phase ${PHASE_NUM}." **If `human_needed`:** -Read the human_verification section from VERIFICATION.md to get the count and items requiring manual testing. - - -**Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in `$ARGUMENTS` OR `text_mode` from init JSON is `true`. When TEXT_MODE is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is not available. -Display the items, then ask user via AskUserQuestion: +Read `human_verification` items. In text mode (`--text` or init `text_mode=true`), replace AskUserQuestion with a numbered list and typed choice. Otherwise display items and ask: - **question:** "Phase ${PHASE_NUM} has items needing manual verification. Validate now or continue to next phase?" - **options:** "Validate now" / "Continue without validation" -On **"Validate now"**: Present the specific items from VERIFICATION.md's human_verification section. After user reviews, ask: +On **"Validate now"**: Present items, then ask: - **question:** "Validation result?" - **options:** "All good — continue" / "Found issues" -On "All good — continue": Display `Phase ${PHASE_NUM} ✅ Human validation passed` and proceed to iterate step. +On "All good — continue": set VERIFICATION frontmatter `status: passed`, display `Phase ${PHASE_NUM} ✅ Human validation passed`, run `@~/.claude/gsd-core/workflows/transition.md`, then iterate. On "Found issues": Go to handle_blocker with the user's reported issues as the description. -On **"Continue without validation"**: Display `Phase ${PHASE_NUM} ⏭ Human validation deferred` and proceed to iterate step. +On **"Continue without validation"**: record an explicit deferred state and stop autonomous mode: + +```markdown +## Deferred Verification + +| Phase | State | Resume | +|-------|-------|--------| +| ${PHASE_NUM} | verification_deferred_human | /gsd:verify-work ${PHASE_NUM} | +``` + +Append/update this STATE.md section, display `Phase ${PHASE_NUM} ⏭ verification_deferred_human — resume with /gsd:verify-work ${PHASE_NUM}`, then handle_blocker: "Human verification deferred for phase ${PHASE_NUM}." **If `gaps_found`:** -Read gap summary from VERIFICATION.md (score and missing items). Display: +Read gap score/items from VERIFICATION.md. Display: ``` ⚠ Phase ${PHASE_NUM}: ${PHASE_NAME} — Gaps Found Score: {N}/{M} must-haves verified @@ -538,13 +532,13 @@ Ask user via AskUserQuestion: - **question:** "Gaps found in phase ${PHASE_NUM}. How to proceed?" - **options:** "Run gap closure" / "Continue without fixing" / "Stop autonomous mode" -On **"Run gap closure"**: Execute gap closure cycle (limit: 1 attempt): +On **"Run gap closure"**: one gap-closure attempt: ``` Skill(skill="gsd-plan-phase", args="${PHASE_NUM} --gaps") ``` -Verify gap plans were created — re-run `init phase-op ${PHASE_NUM}` and check `has_plans`. If no new gap plans → go to handle_blocker: "Gap closure planning for phase ${PHASE_NUM} did not produce plans." +Re-run `init phase-op ${PHASE_NUM}`; if `has_plans` is false, handle_blocker: "Gap closure planning for phase ${PHASE_NUM} did not produce plans." Re-execute: ``` @@ -553,27 +547,39 @@ Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition") Re-read verification status: ```bash -VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') +VERIFY_STATUS=$(gsd_run query verification.status "${PHASE_DIR}" 2>/dev/null | jq -r '.status//empty') ``` -If `passed` or `human_needed`: Route normally (continue or ask user as above). +If `passed` or `human_needed`: route normally. + +If `stale`: handle_blocker: "Stale verification for phase ${PHASE_NUM}." If still `gaps_found` after this retry: Display "Gaps persist after closure attempt." and ask via AskUserQuestion: - **question:** "Gap closure did not fully resolve issues. How to proceed?" - **options:** "Continue anyway" / "Stop autonomous mode" -On "Continue anyway": Proceed to iterate step. +On "Continue anyway": record `verification_deferred_gaps` using the table below, display `Phase ${PHASE_NUM} ⏭ verification_deferred_gaps — resume with /gsd:plan-phase ${PHASE_NUM} --gaps`, then handle_blocker: "Verification gaps deferred for phase ${PHASE_NUM}." On "Stop autonomous mode": Go to handle_blocker. -This limits gap closure to 1 automatic retry to prevent infinite loops. +This limits gap closure to 1 retry. -On **"Continue without fixing"**: Display `Phase ${PHASE_NUM} ⏭ Gaps deferred` and proceed to iterate step. +On **"Continue without fixing"**: record an explicit deferred state and stop autonomous mode: + +```markdown +## Deferred Verification + +| Phase | State | Resume | +|-------|-------|--------| +| ${PHASE_NUM} | verification_deferred_gaps | /gsd:plan-phase ${PHASE_NUM} --gaps | +``` + +Append/update this STATE.md section, display `Phase ${PHASE_NUM} ⏭ verification_deferred_gaps — resume with /gsd:plan-phase ${PHASE_NUM} --gaps`, then handle_blocker: "Verification gaps deferred for phase ${PHASE_NUM}." On **"Stop autonomous mode"**: Go to handle_blocker with "User stopped — gaps remain in phase ${PHASE_NUM}". **3d.5. UI Review (Frontend Phases)** -> Run after any successful execution routing (passed, human_needed accepted, or gaps deferred/accepted) — before proceeding to the iterate step. +> Run only after `passed` or human verification was updated to `passed`. Resolve the active post-verification hooks and the UI-SPEC gate: @@ -632,16 +638,17 @@ Read and execute: `$HOME/.claude/gsd-core/references/autonomous-smart-discuss.md Resume with: /gsd:autonomous --from ${next_incomplete_phase} ``` -Proceed directly to lifecycle step (which handles partial completion — skips audit/complete/cleanup since not all phases are done). Exit cleanly. +Proceed to lifecycle step (partial completion skips audit/complete/cleanup). Exit cleanly. -**Otherwise:** After each phase completes, re-read ROADMAP.md to catch phases inserted mid-execution (decimal phases like 5.1): +**Otherwise:** After each phase, re-read manager projection: ```bash -ROADMAP=$(gsd_run query roadmap.analyze) +INIT_MANAGER=$(gsd_run query init.manager) +if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi ``` Re-filter incomplete phases using the same logic as discover_phases: -- Keep phases where `disk_status !== "complete"` OR `roadmap_complete === false` +- Keep phases where `phase_complete !== true` or `verification_status !== "passed"` - Apply `--from N` filter if originally provided - Apply `--to N` filter if originally provided - Sort by number ascending diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 7abf9db13..4b61b6022 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -72,27 +72,44 @@ If user chooses [A] (Acknowledge): ... ``` Sanitize all slug and status values via `sanitizeForDisplay()` before writing. Never inject raw file content into STATE.md. -3. Record in MILESTONES.md entry: `Known deferred items at close: {count} (see STATE.md Deferred Items)` +3. Set `closeout_type=override_closeout` and record `Known verification overrides: {count} (see STATE.md Deferred Items)` in the MILESTONES.md entry. 4. Proceed with milestone close. -If output shows all clear (no open items): print `All artifact types clear.` and proceed. +If output shows all clear (no open items): set `closeout_type=verified_closeout`, print `All artifact types clear.`, and proceed. SECURITY: Audit JSON output is structured data from the `audit-open` query handler (same JSON contract as legacy `gsd-tools.cjs audit-open`) — validated and sanitized at source. When writing to STATE.md, item slugs and descriptions are sanitized via `sanitizeForDisplay()` before inclusion. Never inject raw user-supplied content into STATE.md without sanitization. -**Use `roadmap analyze` for comprehensive readiness check:** +**Use `init.manager` for canonical readiness check:** ```bash -ROADMAP=$(gsd_run query roadmap.analyze) +INIT_MANAGER=$(gsd_run query init.manager) +if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi ``` -This returns all phases with plan/summary counts and disk status. Use this to verify: +This returns all phases with implementation and verification projection. Use this to verify: - Which phases belong to this milestone? -- All phases complete (all plans have summaries)? Check `disk_status === 'complete'` for each. +- `all_phases_verified`: all milestone phases have `phase_complete === true` and `verification_status === 'passed'`. - `progress_percent` should be 100%. +Compute readiness from `INIT_MANAGER`, not from roadmap counts: + +```bash +ALL_PHASES_VERIFIED=$(printf '%s' "$INIT_MANAGER" | jq -r '[ + .phases[] | select((.number | tostring | test("^999(\\.|$)") | not)) + | (.phase_complete == true and .verification_status == "passed") +] | all') +``` + +If not all_phases_verified, verified_closeout must not proceed. Set `closeout_type=override_closeout`, show each phase whose `phase_complete !== true` or `verification_status !== 'passed'`, and require an explicit user choice: +1. **Proceed anyway** — record verification overrides in MILESTONES.md/STATE.md +2. **Run verification first** — `/gsd:verify-work {phase}` or `/gsd:execute-phase {phase}` +3. **Abort** — return to development + +Only set `closeout_type=verified_closeout` when `ALL_PHASES_VERIFIED` is `true`. + **Requirements completion check (REQUIRED before presenting):** Parse REQUIREMENTS.md traceability table: @@ -110,7 +127,9 @@ Includes: - Phase 3: Core Features (3/3 plans complete) - Phase 4: Polish (1/1 plan complete) -Total: {phase_count} phases, {total_plans} plans, all complete +Total: {phase_count} phases, {total_plans} plans +Verification: {all_phases_verified ? "all phases verified" : "override needed"} +Closeout type: {closeout_type} Requirements: {N}/{M} v1 requirements checked off ``` @@ -128,7 +147,7 @@ MUST present 3 options: 2. **Run audit first** — `/gsd:audit-milestone` to assess gap severity 3. **Abort** — return to development -If user selects "Proceed anyway": note incomplete requirements in MILESTONES.md under `### Known Gaps` with REQ-IDs and descriptions. +If user selects "Proceed anyway": set `closeout_type=override_closeout`; note incomplete requirements in MILESTONES.md under `### Known Gaps` with REQ-IDs and descriptions. diff --git a/gsd-core/workflows/manager.md b/gsd-core/workflows/manager.md index 4e534269a..92a30bf68 100644 --- a/gsd-core/workflows/manager.md +++ b/gsd-core/workflows/manager.md @@ -72,6 +72,7 @@ Build dashboard from JSON. Symbols: `✓` done, `◆` active, `○` pending, `· **Status mapping** (disk_status → D P E Status): - `complete` → `✓ ✓ ✓` `✓ Complete` +- `executed` → `✓ ✓ ◆` `◆ Verification required` - `partial` → `✓ ✓ ◆` `◆ Executing...` - `planned` → `✓ ✓ ○` `○ Ready to execute` - `discussed` → `✓ ○ ·` `○ Ready to plan` @@ -135,7 +136,7 @@ If `all_complete` is true: ║ MILESTONE COMPLETE ║ ╚══════════════════════════════════════════════════════════════╝ -All {phase_count} phases done. Ready for final steps: +All {phase_count} phases verified complete. Ready for final steps: → /gsd:verify-work — run acceptance testing → /gsd:complete-milestone — archive and wrap up ``` @@ -158,8 +159,9 @@ Handle responses: **Building options:** 1. Collect all background actions (execute and plan recommendations) — there can be multiple of each. -2. Collect the inline action (discuss recommendation, if any — there will be at most one since discuss is sequential). -3. Build compound options: +2. Collect verification actions (`verify`) for implementation-complete phases whose canonical verification has not passed. +3. Collect the inline action (discuss recommendation, if any — there will be at most one since discuss is sequential). +4. Build compound options: **If there are ANY recommended actions (background, inline, or both):** Create ONE primary "Continue" option that dispatches ALL of them together: @@ -169,10 +171,11 @@ Handle responses: Continue: → Execute Phase 32 (background) → Plan Phase 34 (background) + → Verify Phase 33 → Discuss Phase 35 (inline) ``` - - This dispatches all background agents first, then runs the inline discuss (if any). - - If there is no inline discuss, the dashboard refreshes after spawning background agents. + - This dispatches all background agents first, runs verification actions inline, then runs the inline discuss (if any). + - If there is no inline discuss, the dashboard refreshes after spawning background agents and inline verification. **Important:** The Continue option must include EVERY action from `recommended_actions` — not just 2. If there are 3 actions, list 3. If there are 5, list 5. @@ -221,8 +224,15 @@ Go to exit step. When the user selects a compound option, behavior depends on the runtime — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query config-get runtime`: -- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run the inline discuss; the background agents continue while you discuss. -- **Otherwise (Claude Code or any other non-Codex runtime):** a backgrounded agent cannot reliably nest the pipeline's subagents, so run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run the inline discuss. There is no overlap. +- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss. +- **On Claude Code or any other non-Codex runtime:** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap. + +Inline verification: + +For each verification recommendation, dispatch by the recommended action's `command`: +- If `command` contains `execute-phase`, run `Skill(skill="gsd-execute-phase", args="{PHASE_NUM} {manager_flags.execute}")`. +- If `command` contains `verify-work`, run `Skill(skill="gsd-verify-work", args="{PHASE_NUM}")`. +- If `command` is missing or unrecognized, stop and show the recommendation row instead of guessing. Inline discuss: diff --git a/gsd-core/workflows/progress.md b/gsd-core/workflows/progress.md index 735911b98..fbf5e5efa 100644 --- a/gsd-core/workflows/progress.md +++ b/gsd-core/workflows/progress.md @@ -273,7 +273,7 @@ This is a WARNING, not a blocker — routing proceeds normally. The debt is visi **Step 1.7: Check verification status for the current phase** -A phase whose verification ended `gaps_found` or `human_needed` is NOT complete, even when every PLAN.md has a matching SUMMARY.md. The count-based status (`roadmap.analyze`) only sees plans/summaries, so without this check such a phase is reported complete and routing skips straight to the next phase. When the phase appears count-complete (`summaries = plans AND plans > 0`), consult the verification report (the same `verification.status` gate `ship` and `execute-phase` use, from #651): +A phase whose verification is missing, unknown, `gaps_found`, or `human_needed` is NOT complete, even when every PLAN.md has a matching SUMMARY.md. The count-based status (`roadmap.analyze`) only sees plans/summaries, so without this check such a phase is reported complete and routing skips straight to the next phase. When the phase appears count-complete (`summaries = plans AND plans > 0`), consult the verification report (the same `verification.status` gate `ship` and `execute-phase` use, from #651): ```bash PHASE_DIR=".planning/phases/[current-phase-dir]" @@ -282,7 +282,7 @@ VERIFICATION_STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null VERIFICATION_NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "") ``` -Track: `verification_status` — the `.status` field (`passed | gaps_found | human_needed | missing | unknown`). The query already handles a missing VERIFICATION.md (returns `missing`) and unexpected values, so no per-status file probing is needed. `passed`, `missing` (not yet verified), and `unknown` route as complete (Step 3) — `missing` with an advisory that the phase is unverified; `gaps_found` and `human_needed` route back to close the verification debt (Step 2). +Track: `verification_status` — the `.status` field (`passed | stale | gaps_found | human_needed | missing | unknown`). The query/projection handles a missing VERIFICATION.md (`missing`), unexpected values, and stale verification (`stale`, when summaries are newer than verification). Only `passed` routes as phase complete (Step 3); every other status routes back to close verification debt (Step 2). **Step 2: Route based on counts** @@ -291,12 +291,15 @@ Track: `verification_status` — the `.status` field (`passed | gaps_found | hum | uat_partial > 0 | UAT testing incomplete | Go to **Route E.2** | | uat_with_gaps > 0 | UAT gaps need fix plans | Go to **Route E** | | summaries < plans | Unexecuted plans exist | Go to **Route A** | +| summaries = plans AND plans > 0 AND verification_status = missing | Phase executed; verification report missing | Go to **Route V.missing** | +| summaries = plans AND plans > 0 AND verification_status = unknown | Phase executed; verification status unknown | Go to **Route V.unknown** | +| summaries = plans AND plans > 0 AND verification_status = stale | Phase executed; verification is stale | Go to **Route V.stale** | | summaries = plans AND plans > 0 AND verification_status = gaps_found | Phase executed; verification found gaps | Go to **Route V.gaps** | | summaries = plans AND plans > 0 AND verification_status = human_needed | Phase executed; awaiting human verification | Go to **Route V.human** | -| summaries = plans AND plans > 0 | Phase complete (verification passed, missing, or n/a) | Go to Step 3 | +| summaries = plans AND plans > 0 AND verification_status = passed | Phase complete (verification passed) | Go to Step 3 | | plans = 0 | Phase not yet planned | Go to **Route B** | -Rows are evaluated top to bottom; the first matching row wins. The two `verification_status` rows must precede the general `summaries = plans` row so a non-`passed` verification is not reported as complete. +Rows are evaluated top to bottom; the first matching row wins. The `verification_status` rows must precede the passed row so non-`passed` verification is not reported as complete. --- @@ -448,6 +451,36 @@ UAT.md exists with `status: partial` — testing session ended before all items --- +**Route V.missing: verification report missing** + +All plans have summaries, but canonical verification has not passed. The phase is implementation-complete, not phase-complete. + +``` +`/gsd:execute-phase {phase} ${GSD_WS}` — re-run execution verification +``` + +--- + +**Route V.unknown: verification status unknown** + +VERIFICATION.md has an unexpected status. The phase is implementation-complete, not phase-complete. + +``` +`/gsd:execute-phase {phase} ${GSD_WS}` — regenerate verification +``` + +--- + +**Route V.stale: verification is stale** + +VERIFICATION.md has `status: passed`, but one or more SUMMARY.md files are newer than the verification report. The phase is implementation-complete, not phase-complete. + +``` +`/gsd:verify-work {phase} ${GSD_WS}` — re-run verification against the latest summaries +``` + +--- + **Route V.gaps: verification found gaps (gaps_found)** VERIFICATION.md exists with `status: gaps_found` — verification identified gaps that need fix plans. The phase is NOT complete. diff --git a/gsd-core/workflows/transition.md b/gsd-core/workflows/transition.md index ab90d53c6..1bfc1864e 100644 --- a/gsd-core/workflows/transition.md +++ b/gsd-core/workflows/transition.md @@ -77,26 +77,28 @@ cat .planning/config.json 2>/dev/null || true **Check for verification debt in this phase:** ```bash -# Count outstanding items in current phase -OUTSTANDING="" -for f in .planning/phases/XX-current/*-UAT.md .planning/phases/XX-current/*-VERIFICATION.md; do - [ -f "$f" ] || continue - grep -q "result: pending\|result: blocked\|status: partial\|status: human_needed\|status: diagnosed" "$f" && OUTSTANDING="$OUTSTANDING\n$(basename $f)" -done +# Run a preliminary frontmatter check via awk — the runtime launcher is not yet +# defined at this step, so avoid any runtime tool calls here. +# awk extracts only the status: field between the two --- fences to avoid +# false positives from historical body text (e.g. previous_status: gaps_found). +VERIFY_STATUS=$(awk 'NR==1&&/^---$/{in_fm=1;next}in_fm&&/^---$/{exit}in_fm&&/^status: /{print $2}' \ + .planning/phases/XX-current/*-VERIFICATION.md 2>/dev/null | head -1) ``` -**If OUTSTANDING is not empty:** +**If VERIFY_STATUS is not `passed`:** -Append to the completion confirmation message (regardless of mode): +Stop before confirming: ``` -Outstanding verification items in this phase: -{list filenames} +Verification incomplete: ${VERIFY_STATUS:-missing} -These will carry forward as debt. Review: `/gsd:audit-uat` +Resolve before transition. Review: `/gsd:audit-uat` ``` -This does NOT block transition — it ensures the user sees the debt before confirming. +This preliminary check blocks obviously unresolved verification before the +launcher is available. `gsd-tools.cjs query phase.complete` remains the +authoritative stale-aware gate and fail-closes unless canonical verification +status is `passed`. **If all plans complete:** diff --git a/gsd-core/workflows/ui-review.md b/gsd-core/workflows/ui-review.md index 738e5673c..6ba04e0f3 100644 --- a/gsd-core/workflows/ui-review.md +++ b/gsd-core/workflows/ui-review.md @@ -143,13 +143,9 @@ Full review: {path to UI-REVIEW.md} ## ▶ Next -`/clear` then one of: +`/clear` then: -- `/gsd:verify-work {N}` — UAT testing -- `/gsd:plan-phase {N+1}` — plan next phase - -- `/gsd:verify-work {N}` — UAT testing -- `/gsd:plan-phase {N+1}` — plan next phase +- `/gsd:verify-work {N}` — UAT testing before phase completion ─────────────────────────────────────────────────────────────── ``` diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index 8e31fad1f..b6a99efba 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -527,6 +527,50 @@ If an active secure-phase step hook exists AND `SECURITY_FILE` exists: check fro If no active secure-phase step hook exists OR (`SECURITY_FILE` exists AND `threats_open` is `0`): +If execution verification is waiting only on human UAT and this session recorded zero issues, canonicalize the report before the shared completion predicate: + +```bash +PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty') +VERIFICATION_FILE=$(ls "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1) +VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null) +VERIFICATION_STATUS_VALUE=$(printf '%s' "$VERIFICATION_STATUS" | jq -r '.status // empty' 2>/dev/null || echo "") +PHASE_VERIFICATION_STATUS="$VERIFICATION_STATUS_VALUE" +if [ "$VERIFICATION_STATUS_VALUE" = "human_needed" ]; then + gsd_run query frontmatter.set "$VERIFICATION_FILE" --field status --value passed +fi +``` + +If `PHASE_VERIFICATION_STATUS` is `stale`, stop before phase advancement and present: + +``` +All UAT tests passed, but phase advancement is blocked until canonical verification is fresh. + +Blocking completion: +verification is stale + +- `/gsd:verify-work {phase}` — re-run verification against the latest summaries +``` + +Otherwise, check the shared UAT-plus-verification completion predicate before transition: + +```bash +PHASE_COMPLETE=$(gsd_run phase uat-passed "{phase}" --require-verification) +PHASE_COMPLETE_PASSED=$(printf '%s' "$PHASE_COMPLETE" | jq -r '.passed' 2>/dev/null || echo "false") +PHASE_COMPLETE_BLOCKERS=$(printf '%s' "$PHASE_COMPLETE" | jq -r '.blockers[]?' 2>/dev/null || true) +``` + +If `PHASE_COMPLETE_PASSED` is not `true`, stop before phase advancement and present: + +``` +All UAT tests passed, but phase advancement is blocked until canonical verification passes. + +Blocking completion: +{PHASE_COMPLETE_BLOCKERS} + +- `/gsd:execute-phase {phase}` — regenerate execution verification +- `/gsd:verify-work {phase}` — resume UAT if blockers remain +``` + **Auto-transition: mark phase complete in ROADMAP.md and STATE.md** Execute the transition workflow inline (do NOT use Task — the orchestrator context already holds the UAT results and phase data needed for accurate transition): diff --git a/src/init.cts b/src/init.cts index 5974be9bd..00e3da153 100644 --- a/src/init.cts +++ b/src/init.cts @@ -40,6 +40,10 @@ import { validatePath, loadTrustedGlobalRoots } from './security.cjs'; import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module import frontmatterMod = require('./frontmatter.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module +import verificationMod = require('./verification.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module +import uatPredicateMod = require('./uat-predicate.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module import agentInstallCheck = require('./agent-install-check.cjs'); const { checkAgentsInstalled } = agentInstallCheck; @@ -72,6 +76,8 @@ const { const { determinePhaseStatus } = commandsMod; const { extractFrontmatter } = frontmatterMod; +const { readVerificationStatus } = verificationMod; +const { evaluateUatPassed } = uatPredicateMod; // Unused but imported for structural parity void stripShippedMilestones; @@ -87,6 +93,75 @@ function listPhasePlanFiles(phaseDir: string): string[] { return (scanPhasePlans(phaseDir) as unknown as Record)['planFiles']; } +interface PhaseCompletionProjection { + implementation_complete: boolean; + verification_status: string; + verification_passed: boolean; + phase_complete: boolean; + completion_status: string; + verification_next_action: string; + verification_next_command: string; +} + +function verificationNextCommand( + status: string, + phaseNumber: string, + slashRuntime: string, +): string { + if (status === 'gaps_found') { + return `${formatGsdSlash('plan-phase', slashRuntime) as string} ${phaseNumber} --gaps`; + } + if (status === 'human_needed' || status === 'stale') { + return `${formatGsdSlash('verify-work', slashRuntime) as string} ${phaseNumber}`; + } + if (status === 'missing' || status === 'unknown') { + return `${formatGsdSlash('execute-phase', slashRuntime) as string} ${phaseNumber}`; + } + return ''; +} + +function projectCompletionStatus( + implementationComplete: boolean, + verificationPassed: boolean, +): string { + if (implementationComplete && verificationPassed) return 'complete'; + if (implementationComplete) return 'executed'; + return 'incomplete'; +} + +function buildPhaseCompletionProjection( + cwd: string, + phaseNumber: string, + phaseDir: string | null, + planCount: number, + summaryCount: number, + slashRuntime: string, +): PhaseCompletionProjection { + const implementationComplete = planCount > 0 && summaryCount >= planCount; + const phaseFullDir = phaseDir ? path.join(cwd, phaseDir) : ''; + const verificationStatus = implementationComplete + ? readVerificationStatus(phaseFullDir) + : { status: 'not_required', next_action: '', next_command: '' }; + const projectedVerificationStatus = verificationStatus.status; + const projectedVerificationAction = verificationStatus.next_action; + const verificationPassed = projectedVerificationStatus === 'passed'; + const phaseComplete = implementationComplete && verificationPassed; + + return { + implementation_complete: implementationComplete, + verification_status: projectedVerificationStatus, + verification_passed: verificationPassed, + phase_complete: phaseComplete, + completion_status: projectCompletionStatus(implementationComplete, verificationPassed), + verification_next_action: projectedVerificationAction, + verification_next_command: verificationNextCommand( + projectedVerificationStatus, + phaseNumber, + slashRuntime, + ), + }; +} + function getLatestCompletedMilestone(cwd: string): { version: string; name: string } | null { const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md'); const content = platformReadSync(milestonesPath); @@ -802,6 +877,7 @@ function cmdInitVerifyWork(cwd: string, phase: string, raw: boolean): void { } const config = loadConfig(cwd); + const _slashRuntime = resolveRuntime(cwd); let phaseInfo = findPhaseInternal(cwd, phase) as unknown as Record | null; if (phaseInfo?.['archived']) { @@ -833,6 +909,23 @@ function cmdInitVerifyWork(cwd: string, phase: string, raw: boolean): void { } } + const phaseDir = (phaseInfo?.['directory'] as string | null | undefined) || null; + const planCount = (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0; + const summaryCount = (phaseInfo?.['summaries'] as unknown[] | undefined)?.length || 0; + const completion = buildPhaseCompletionProjection( + cwd, + (phaseInfo?.['phase_number'] as string | undefined) || phase, + phaseDir, + planCount, + summaryCount, + _slashRuntime, + ); + const uatReport = phaseDir + ? evaluateUatPassed(path.join(cwd, phaseDir), { + policy: { requireVerification: true }, + }) + : null; + const result: Record = { planner_model: resolveModelInternal(cwd, 'gsd-planner'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), @@ -840,11 +933,17 @@ function cmdInitVerifyWork(cwd: string, phase: string, raw: boolean): void { commit_docs: config.commit_docs, phase_found: !!phaseInfo, - phase_dir: phaseInfo?.['directory'] || null, + phase_dir: phaseDir, phase_number: phaseInfo?.['phase_number'] || null, phase_name: phaseInfo?.['phase_name'] || null, has_verification: phaseInfo?.['has_verification'] || false, + phase_completion: { + ...completion, + uat_passed: uatReport?.passed ?? false, + uat_blockers: uatReport?.blockers ?? [], + ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false), + }, }; output(withProjectRoot(cwd, result), raw); @@ -1279,6 +1378,14 @@ function cmdInitManager(cwd: string, raw: boolean): void { let hasResearch = false; let lastActivity: string | null = null; let isActive = false; + let completion = buildPhaseCompletionProjection( + cwd, + phaseNum, + null, + planCount, + summaryCount, + _slashRuntime, + ); try { const dirs = _phaseDirEntries.filter(isDirInMilestone); @@ -1286,6 +1393,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { if (dirMatch) { const fullDir = path.join(phasesDir, dirMatch); + const phaseDirRel = toPosixPath(path.relative(cwd, fullDir)); const phaseFiles = fs.readdirSync(fullDir); planCount = listPhasePlanFiles(fullDir).length; summaryCount = listPhaseSummaryFiles(fullDir).length; @@ -1293,8 +1401,17 @@ function cmdInitManager(cwd: string, raw: boolean): void { hasResearch = phaseFiles.some( (f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md', ); + completion = buildPhaseCompletionProjection( + cwd, + phaseNum, + phaseDirRel, + planCount, + summaryCount, + _slashRuntime, + ); - if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; + if (completion.phase_complete) diskStatus = 'complete'; + else if (completion.implementation_complete) diskStatus = 'executed'; else if (summaryCount > 0) diskStatus = 'partial'; else if (planCount > 0) diskStatus = 'planned'; else if (hasResearch) diskStatus = 'researched'; @@ -1321,7 +1438,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { } const roadmapComplete = _checkboxStates.get(phaseNum) || false; - if (roadmapComplete && diskStatus !== 'complete') { + if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') { diskStatus = 'complete'; } @@ -1336,6 +1453,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { plan_count: planCount, summary_count: summaryCount, roadmap_complete: roadmapComplete, + ...completion, last_activity: lastActivity, is_active: isActive, }); @@ -1351,24 +1469,44 @@ function cmdInitManager(cwd: string, raw: boolean): void { } } + function normalizePhaseNumber(value: string): string { + return value + .split('.') + .map((part) => { + const match = /^(\d+)([A-Z]?)$/i.exec(part); + if (!match) return part; + return `${Number(match[1])}${match[2].toUpperCase()}`; + }) + .join('.'); + } + const completedNums = new Set( - phases.filter((p) => p['disk_status'] === 'complete').map((p) => p['number'] as string), + phases + .filter((p) => p['phase_complete'] === true) + .map((p) => normalizePhaseNumber(p['number'] as string)), ); + const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p])); const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; let _allMatch: RegExpExecArray | null; while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) { - completedNums.add(_allMatch[1]); + const phaseNum = normalizePhaseNumber(_allMatch[1]); + const phase = phaseMap.get(phaseNum); + if (!phase || phase['phase_complete'] === true) { + completedNums.add(phaseNum); + } } - const phaseMap = new Map(phases.map((p) => [p['number'] as string, p])); - function reaches(from: string, to: string, visited = new Set()): boolean { - if (visited.has(from)) return false; - visited.add(from); - const p = phaseMap.get(from); + const normalizedFrom = normalizePhaseNumber(from); + const normalizedTo = normalizePhaseNumber(to); + if (visited.has(normalizedFrom)) return false; + visited.add(normalizedFrom); + const p = phaseMap.get(normalizedFrom); if (!p || !p['dep_phases'] || (p['dep_phases'] as string[]).length === 0) return false; - if ((p['dep_phases'] as string[]).includes(to)) return true; + if ((p['dep_phases'] as string[]).some((dep) => normalizePhaseNumber(dep) === normalizedTo)) { + return true; + } return (p['dep_phases'] as string[]).some((dep) => reaches(dep, to, visited)); } @@ -1383,8 +1521,8 @@ function cmdInitManager(cwd: string, raw: boolean): void { ) { phase['deps_satisfied'] = true; } else { - const depNums = (phase['depends_on'] as string).match(/\d+(?:\.\d+)*/g) || []; - phase['deps_satisfied'] = depNums.every((n) => completedNums.has(n)); + const depNums = (phase['depends_on'] as string).match(/\d+[A-Z]?(?:\.\d+)*/gi) || []; + phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n))); phase['dep_phases'] = depNums; } } @@ -1418,7 +1556,15 @@ function cmdInitManager(cwd: string, raw: boolean): void { if (phase['disk_status'] === 'complete') continue; if (/^999(?:\.|$)/.test(phase['number'] as string)) continue; - if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) { + if (phase['disk_status'] === 'executed') { + recommendedActions.push({ + phase: phase['number'], + phase_name: phase['name'], + action: 'verify', + reason: `Implementation complete; verification ${phase['verification_status'] as string}`, + command: phase['verification_next_command'], + }); + } else if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) { recommendedActions.push({ phase: phase['number'], phase_name: phase['name'], @@ -1477,7 +1623,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { }); const nonBacklogPhases = phases.filter((p) => !/^999(?:\.|$)/.test(p['number'] as string)); - const completedCount = nonBacklogPhases.filter((p) => p['disk_status'] === 'complete').length; + const completedCount = nonBacklogPhases.filter((p) => p['phase_complete'] === true).length; const sanitizeFlags = (rawVal: unknown): string => { const val = typeof rawVal === 'string' ? rawVal : ''; @@ -1511,7 +1657,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { phase_count: phases.length, completed_count: completedCount, in_progress_count: phases.filter((p) => - ['partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'] as string), + ['executed', 'partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'] as string), ).length, recommended_actions: filteredActions, waiting_signal: waitingSignal, @@ -1534,6 +1680,7 @@ function cmdInitProgress(cwd: string, raw: boolean): void { } const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd) as unknown as Record; + const _slashRuntime = resolveRuntime(cwd); const phasesDir = path.join(planningDir(cwd), 'phases'); const phases: Record[] = []; @@ -1593,31 +1740,43 @@ function cmdInitProgress(cwd: string, raw: boolean): void { const hasResearch = phaseFiles.some( (f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md', ); + const phaseDirRel = toPosixPath( + path.relative(cwd, path.join(planningDir(cwd), 'phases', dir)), + ); + const completion = buildPhaseCompletionProjection( + cwd, + phaseNumber, + phaseDirRel, + plans.length, + summaries.length, + _slashRuntime, + ); const status = - summaries.length >= plans.length && plans.length > 0 + completion.phase_complete ? 'complete' - : plans.length > 0 - ? 'in_progress' - : hasResearch - ? 'researched' - : 'pending'; + : completion.implementation_complete + ? 'executed' + : plans.length > 0 + ? 'in_progress' + : hasResearch + ? 'researched' + : 'pending'; const phaseInfo: Record = { number: phaseNumber, name: phaseName, - directory: toPosixPath( - path.relative(cwd, path.join(planningDir(cwd), 'phases', dir)), - ), + directory: phaseDirRel, status, plan_count: plans.length, summary_count: summaries.length, has_research: hasResearch, + ...completion, }; phases.push(phaseInfo); - if (!currentPhase && (status === 'in_progress' || status === 'researched')) { + if (!currentPhase && (status === 'executed' || status === 'in_progress' || status === 'researched')) { currentPhase = phaseInfo; } if (!nextPhase && status === 'pending') { @@ -1634,7 +1793,15 @@ function cmdInitProgress(cwd: string, raw: boolean): void { const checkboxComplete = roadmapCheckboxStates.get(num) === true || roadmapCheckboxStates.get(stripped) === true; - const status = checkboxComplete ? 'complete' : 'not_started'; + const completion = buildPhaseCompletionProjection( + cwd, + num, + null, + 0, + 0, + _slashRuntime, + ); + const status = 'not_started'; const phaseInfo: Record = { number: num, name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), @@ -1643,9 +1810,11 @@ function cmdInitProgress(cwd: string, raw: boolean): void { plan_count: 0, summary_count: 0, has_research: false, + roadmap_complete: checkboxComplete, + ...completion, }; phases.push(phaseInfo); - if (!nextPhase && !currentPhase && status !== 'complete') { + if (!nextPhase && !currentPhase && !checkboxComplete) { nextPhase = phaseInfo; } } @@ -1674,7 +1843,9 @@ function cmdInitProgress(cwd: string, raw: boolean): void { phases, phase_count: phases.length, completed_count: phases.filter((p) => p['status'] === 'complete').length, - in_progress_count: phases.filter((p) => p['status'] === 'in_progress').length, + in_progress_count: phases.filter((p) => + ['executed', 'in_progress'].includes(p['status'] as string), + ).length, current_phase: currentPhase, next_phase: nextPhase, diff --git a/src/io.cts b/src/io.cts index 11386a0ea..0df3f5fd5 100644 --- a/src/io.cts +++ b/src/io.cts @@ -172,6 +172,7 @@ const ERROR_REASON = Object.freeze({ SDK_MISSING_ARG: 'sdk_missing_arg', // workflow / phase PHASE_NOT_FOUND: 'phase_not_found', + PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete', SUMMARY_NO_PLANNING: 'summary_no_planning', // graphify GRAPHIFY_NO_GRAPH: 'graphify_no_graph', diff --git a/src/phase.cts b/src/phase.cts index 50eb51334..4e0a3ae4a 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -56,6 +56,9 @@ import { realClock } from './clock.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module import uatPredicate = require('./uat-predicate.cjs'); const { evaluateUatPassed } = uatPredicate; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module +import verificationMod = require('./verification.cjs'); +const { readVerificationStatus } = verificationMod; const { planningDir, withPlanningLock } = planningWorkspace; const { extractFrontmatter } = frontmatterMod; @@ -1389,8 +1392,9 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { let requirementsUpdated = false; const warnings: string[] = []; + const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string); + try { - const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string); const phaseFiles = fs.readdirSync(phaseFullDir); for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) { @@ -1424,7 +1428,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { let nextPhaseName: string | null = null; let isLastPhase = true; - withPlanningLock(cwd, () => { + const verificationBlocked = withPlanningLock(cwd, () => { + const verificationStatus = readVerificationStatus(phaseFullDir); + if (verificationStatus.status !== 'passed') { + return verificationStatus; + } + const runPhaseCompleteTransaction = () => { const writes: WriteSpec[] = []; let roadmapContent: string | null = null; @@ -1771,8 +1780,19 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { } else { runPhaseCompleteTransaction(); } + return null; }); + if (verificationBlocked) { + const nextStep = verificationBlocked.next_command + ? ` Next: ${verificationBlocked.next_command}` + : ''; + error( + `Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}`, + ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, + ); + } + let autoPruned = false; try { const configPath = path.join(planningDir(cwd), 'config.json'); diff --git a/src/plan-scan.cts b/src/plan-scan.cts index 8918f1511..a9a671813 100644 --- a/src/plan-scan.cts +++ b/src/plan-scan.cts @@ -73,8 +73,8 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult { if (existsSync(nestedDir)) { try { const nestedFiles = readdirSync(nestedDir); - nestedPlanFiles = nestedFiles.filter(isNestedPlanFile); - nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile); + nestedPlanFiles = nestedFiles.filter(isNestedPlanFile).map((file) => `plans/${file}`); + nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile).map((file) => `plans/${file}`); hasNestedPlans = nestedPlanFiles.length > 0; } catch { /* ignore unreadable nested layout */ } } diff --git a/src/uat-predicate.cts b/src/uat-predicate.cts index c519120fc..6074b6772 100644 --- a/src/uat-predicate.cts +++ b/src/uat-predicate.cts @@ -19,6 +19,9 @@ const { extractFrontmatter } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports import markdownSectionizer = require('./markdown-sectionizer.cjs'); const { stripFencedCode } = markdownSectionizer; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import verification = require('./verification.cjs'); +const { readVerificationStatus } = verification; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -52,10 +55,8 @@ const BLOCKING_UAT_FM_STATUSES = new Set([ // UAT file frontmatter `result` values that indicate failure const BLOCKING_UAT_FM_RESULTS = new Set(['pending', 'blocked', 'failed']); -// VERIFICATION file frontmatter `status` values that indicate passing -const PASSING_VERIFICATION_STATUSES = new Set([ - 'complete', 'verified', 'passed', 'human_passed', -]); +// Canonical VERIFICATION frontmatter `status` value that indicates passing. +const PASSING_VERIFICATION_STATUSES = new Set(['passed']); // VERIFICATION file frontmatter `status` values that explicitly block const BLOCKING_VERIFICATION_FM_STATUSES = new Set([ @@ -309,8 +310,13 @@ function evaluateUatPassed( } // ── Policy: requireVerification ─────────────────────────────────────────── - if (requireVerification && !hasPassingVerification) { - blockers.push('policy: verification required but no passing *-VERIFICATION.md found'); + if (requireVerification) { + const verificationStatus = readVerificationStatus(phaseFullDir).status; + if (verificationStatus === 'stale') { + blockers.push('policy: verification status=stale'); + } else if (verificationStatus !== 'passed' || !hasPassingVerification) { + blockers.push('policy: verification required but no passing *-VERIFICATION.md found'); + } } // ── Determine no_uat_artifacts and passed ───────────────────────────────── diff --git a/src/verification.cts b/src/verification.cts index 5d6ff36dc..e53f7f146 100644 --- a/src/verification.cts +++ b/src/verification.cts @@ -24,6 +24,8 @@ import io = require('./io.cjs'); import phaseId = require('./phase-id.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module import frontmatterMod = require('./frontmatter.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module +import scanPhasePlans = require('./plan-scan.cjs'); const { output, error } = io; const { extractPhaseToken } = phaseId; @@ -74,6 +76,11 @@ const VERIFICATION_ROUTING_TABLE: Record = { next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.", next_command: '', }, + stale: { + status: 'stale', + next_action: 'Verification is stale. Re-run verify-work before transition.', + next_command: '', + }, // INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when // the file has no parseable frontmatter status. Never emitted by the verifier. missing: { @@ -95,6 +102,12 @@ const VERIFICATION_ROUTING_TABLE: Record = { interface FsLike { readdirSync(dir: string): string[]; readFileSync(filePath: string, encoding: 'utf-8'): string; + statSync(filePath: string): { mtimeMs: number }; +} + +interface StaleVerificationInfo { + verificationFile: string; + summaryFile: string; } /** @@ -123,6 +136,39 @@ interface VerificationStatusResult { next_command: string; } +function findStaleVerificationSummary(phaseDir: string, fsImpl: FsLike = fs): StaleVerificationInfo | null { + // FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync; + // unreadable dir; broken symlink; file->dir swap) must degrade to "not stale" rather + // than throw uncaught into callers that are NOT under the planning lock + // (init.manager / init.progress / uat-predicate). Mirrors readVerificationStatus's + // no-throw contract; `fsImpl` threads the same injectable-fs seam for parity/testing. + // (Review B1 on #1548.) + try { + const phaseFiles = fsImpl.readdirSync(phaseDir); + const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0]; + if (!verificationFile) return null; + + const verificationMtimeMs = fsImpl.statSync(path.join(phaseDir, verificationFile)).mtimeMs; + let newestStaleSummary: { summaryFile: string; mtimeMs: number } | null = null; + const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles; + for (const summaryFile of summaryFiles.sort()) { + const summaryMtimeMs = fsImpl.statSync(path.join(phaseDir, summaryFile)).mtimeMs; + if (summaryMtimeMs <= verificationMtimeMs) continue; + if (!newestStaleSummary || summaryMtimeMs > newestStaleSummary.mtimeMs) { + newestStaleSummary = { summaryFile, mtimeMs: summaryMtimeMs }; + } + } + + if (!newestStaleSummary) return null; + return { + verificationFile, + summaryFile: newestStaleSummary.summaryFile, + }; + } catch { + return null; + } +} + /** * Read the verification status from the first `*-VERIFICATION.md` file in * phaseDir and return the routing result. @@ -186,19 +232,41 @@ function readVerificationStatus( return missingResult(); } - // 3. Route — exclude internal sentinels from raw-file lookup (they are - // constructed internally above, never written by the verifier). - if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') { - const entry = VERIFICATION_ROUTING_TABLE[rawStatus]; - // gaps_found: build the phase-specific command here rather than in the table. - const next_command = - rawStatus === 'gaps_found' - ? `/gsd:plan-phase ${phaseNumber} --gaps` - : entry.next_command; + // gaps_found takes priority over stale — gap closure is the correct next + // step regardless of whether summaries are newer than the verification file. + if (rawStatus === 'gaps_found') { + const entry = VERIFICATION_ROUTING_TABLE['gaps_found']; return { status: entry.status, next_action: entry.next_action, - next_command, + next_command: `/gsd:plan-phase ${phaseNumber} --gaps`, + }; + } + + const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl); + if (staleVerification) { + const entry = VERIFICATION_ROUTING_TABLE['stale']; + return { + status: entry.status, + next_action: entry.next_action, + next_command: `/gsd:verify-work ${phaseNumber}`, + }; + } + + // 3. Route — exclude internal sentinels from raw-file lookup (they are + // constructed internally above, never written by the verifier). + if ( + rawStatus in VERIFICATION_ROUTING_TABLE && + rawStatus !== 'missing' && + rawStatus !== 'unknown' && + rawStatus !== 'stale' && + rawStatus !== 'gaps_found' + ) { + const entry = VERIFICATION_ROUTING_TABLE[rawStatus]; + return { + status: entry.status, + next_action: entry.next_action, + next_command: entry.next_command, }; } @@ -232,6 +300,7 @@ function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw export = { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, + findStaleVerificationSummary, readVerificationStatus, cmdVerificationStatus, }; diff --git a/tests/247-phase-uat-passed.test.cjs b/tests/247-phase-uat-passed.test.cjs index 338b60462..357c99b51 100644 --- a/tests/247-phase-uat-passed.test.cjs +++ b/tests/247-phase-uat-passed.test.cjs @@ -44,6 +44,10 @@ function writeUatFile(phaseDir, filename, content) { fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8'); } +function setMtime(filePath, time) { + fs.utimesSync(filePath, time, time); +} + function makePassingUat() { return [ '---', @@ -207,6 +211,39 @@ describe('phase uat-passed — --require-verification flag', () => { assert.strictEqual(out.passed, true); assert.strictEqual(out.policy.require_verification, true); }); + + test('--require-verification with stale passed verification → passed:false', () => { + writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); + const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md'); + const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md'); + writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.'); + writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n'); + const now = new Date(); + setMtime(verificationPath, new Date(now.getTime() - 60_000)); + setMtime(summaryPath, now); + + const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.passed, false); + assert.ok( + out.blockers.some(b => /verification status=stale/i.test(b)), + `Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`, + ); + }); + + test('--require-verification with non-canonical complete verification → passed:false', () => { + writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); + writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.'); + const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.passed, false); + assert.ok(out.blockers.some(b => /verification required/i.test(b)), + `Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`); + }); }); // ─── Error cases ────────────────────────────────────────────────────────────── diff --git a/tests/4-phase-complete-cjs-regression.test.cjs b/tests/4-phase-complete-cjs-regression.test.cjs index 3f6495b7c..709e0aa64 100644 --- a/tests/4-phase-complete-cjs-regression.test.cjs +++ b/tests/4-phase-complete-cjs-regression.test.cjs @@ -38,6 +38,17 @@ const { cleanup, runGsdTools } = require('./helpers.cjs'); const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); const { cmdPhaseComplete } = phaseModule; +function writePassedVerificationFile(phaseDir, phase = '01') { + fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [ + '---', + 'status: passed', + '---', + '', + '# Verification', + '', + ].join('\n')); +} + // ── Fixture builder ────────────────────────────────────────────────────────── /** @@ -120,6 +131,7 @@ function createFixture(prefix = 'gsd-4-regression-') { fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); // Phase 02 directory (needed for "next phase" detection) fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); @@ -443,6 +455,7 @@ function create4ColFixture(existingDate, alreadyComplete = true) { fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); @@ -506,6 +519,7 @@ function create5ColFixture(existingDate, alreadyComplete = true) { fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); @@ -823,34 +837,26 @@ describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not t ); test( - '#1159-A-2 (boundary): status:gaps_found in frontmatter → DOES emit "has unresolved gaps" warning', + '#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion', () => { tmpDir = createVerificationFixture('gaps_found'); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w)); - assert.ok( - gapWarnings.length > 0, - `#1159-A-2 FAILED: expected a gap warning when frontmatter status=gaps_found but got none.\n` + - `Warnings: ${JSON.stringify(warnings)}`, - ); + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + assert.equal(result.success, false, 'gaps_found verification must block phase completion'); + const parsed = JSON.parse(result.error); + assert.equal(parsed.reason, 'phase_verification_incomplete'); + assert.match(parsed.message, /Gaps found/i); }, ); test( - '#1159-A-3 (boundary): status:human_needed in frontmatter → DOES emit "needs human verification" warning', + '#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion', () => { tmpDir = createVerificationFixture('human_needed'); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const humanWarnings = warnings.filter((w) => /human verification/i.test(w)); - assert.ok( - humanWarnings.length > 0, - `#1159-A-3 FAILED: expected human-verification warning when frontmatter status=human_needed.\n` + - `Warnings: ${JSON.stringify(warnings)}`, - ); + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + assert.equal(result.success, false, 'human_needed verification must block phase completion'); + const parsed = JSON.parse(result.error); + assert.equal(parsed.reason, 'phase_verification_incomplete'); + assert.match(parsed.message, /Human verification required/i); }, ); }); @@ -939,6 +945,7 @@ function createDeferredReqFixture({ includeMissingActive = false } = {}) { fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); return tmpDir; } @@ -1075,6 +1082,7 @@ describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigg fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n'); + writePassedVerificationFile(phase01Dir); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); diff --git a/tests/autonomous-converge.test.cjs b/tests/autonomous-converge.test.cjs index 45db37a3d..1cb96f342 100644 --- a/tests/autonomous-converge.test.cjs +++ b/tests/autonomous-converge.test.cjs @@ -109,3 +109,103 @@ describe('autonomous --converge flag (#711)', () => { assert.match(howTo, /\/gsd-autonomous --only 4 --converge/, 'how-to should show single-phase converge usage'); }); }); + +describe('autonomous verification deferral contract', () => { + test('workflow records explicit deferred states instead of silently advancing (#1525)', () => { + const workflow = read(WORKFLOW_PATH); + + assert.match(workflow, /verification_deferred_human/); + assert.match(workflow, /verification_deferred_gaps/); + assert.match(workflow, /Deferred Verification/); + assert.match(workflow, /gsd:verify-work \$\{PHASE_NUM\}/); + assert.match(workflow, /gsd:plan-phase \$\{PHASE_NUM\} --gaps/); + assert.match( + workflow, + /\| \$\{PHASE_NUM\} \| verification_deferred_human \| \/gsd:verify-work \$\{PHASE_NUM\} \|/, + 'human deferral must persist the exact deferred STATE row', + ); + assert.match( + workflow, + /\| \$\{PHASE_NUM\} \| verification_deferred_gaps \| \/gsd:plan-phase \$\{PHASE_NUM\} --gaps \|/, + 'gap deferral must persist the exact deferred STATE row', + ); + assert.doesNotMatch( + workflow, + /Human validation deferred` and proceed to iterate step/, + 'human-needed deferral must not silently proceed to the next phase', + ); + assert.doesNotMatch( + workflow, + /Gaps deferred` and proceed to iterate step/, + 'gap deferral must not silently proceed to the next phase', + ); + }); + + test('workflow runs normal transition post-processing after passed verification (#1526)', () => { + const workflow = read(WORKFLOW_PATH); + const passedIdx = workflow.indexOf('**If `passed`:**'); + const transitionIdx = workflow.indexOf('transition.md', passedIdx); + const iterateIdx = workflow.indexOf('Proceed to iterate step', passedIdx); + + assert.ok(transitionIdx > passedIdx, 'passed verification must invoke transition.md'); + assert.ok( + transitionIdx < iterateIdx, + 'normal transition post-processing must run before autonomous iterates', + ); + }); + + test('workflow reads canonical verification status before human-needed promotion (#1522)', () => { + const workflow = read(WORKFLOW_PATH); + const waitIdx = workflow.indexOf('After execute, read canonical verification'); + const humanNeededIdx = workflow.indexOf('**If `human_needed`:**', waitIdx); + const promoteIdx = workflow.indexOf('set VERIFICATION frontmatter `status: passed`', humanNeededIdx); + const section = workflow.slice(waitIdx, humanNeededIdx); + + assert.ok(waitIdx !== -1, 'workflow must document the post-execution verification read'); + assert.ok(humanNeededIdx > waitIdx, 'human_needed branch must follow verification status read'); + assert.ok(promoteIdx > humanNeededIdx, 'human_needed branch must contain the promotion action'); + assert.match( + section, + /VERIFY_STATUS=\$\(gsd_run query verification\.status "\$\{PHASE_DIR\}" 2>\/dev\/null \| jq -r '\.status\/\/empty'\)/, + 'autonomous must route human validation through canonical verification.status', + ); + assert.match( + section, + /jq -r '\.status\/\/empty'/, + 'autonomous must parse the projected canonical status value', + ); + assert.doesNotMatch( + section, + /grep "\^status:"/, + 'autonomous must not route stale human_needed reports from raw frontmatter', + ); + }); + + test('workflow discovers incomplete phases from canonical verification projection (#1522)', () => { + const workflow = read(WORKFLOW_PATH); + const discoverStart = workflow.indexOf(''); + const discoverEnd = workflow.indexOf('', discoverStart); + const iterateStart = workflow.indexOf(''); + const iterateEnd = workflow.indexOf('', iterateStart); + const discoverStep = workflow.slice(discoverStart, discoverEnd); + const iterateStep = workflow.slice(iterateStart, iterateEnd); + + assert.match(discoverStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/); + assert.ok( + discoverStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'), + 'autonomous discovery must dereference large init.manager payloads before parsing', + ); + assert.match(discoverStep, /phase_complete !== true/); + assert.match(discoverStep, /verification_status !== "passed"/); + assert.doesNotMatch(discoverStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/); + assert.doesNotMatch(discoverStep, /disk_status !== "complete"/); + + assert.match(iterateStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/); + assert.ok( + iterateStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'), + 'autonomous iteration must dereference large init.manager payloads before parsing', + ); + assert.match(iterateStep, /phase_complete !== true/); + assert.match(iterateStep, /verification_status !== "passed"/); + }); +}); diff --git a/tests/bug-2769-requirements-header-variants.test.cjs b/tests/bug-2769-requirements-header-variants.test.cjs index 2fc861797..69ed4db90 100644 --- a/tests/bug-2769-requirements-header-variants.test.cjs +++ b/tests/bug-2769-requirements-header-variants.test.cjs @@ -61,6 +61,10 @@ describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants path.join(phasesDir, '01-1-SUMMARY.md'), ['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'), ); + fs.writeFileSync( + path.join(phasesDir, '01-VERIFICATION.md'), + ['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'), + ); const roadmap = [ '# Roadmap', diff --git a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs index e4ebe2200..97f4e2e9c 100644 --- a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs +++ b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs @@ -91,6 +91,10 @@ function setupFixture(tmpDir, opts = {}) { path.join(phaseDir, `${paddedId}-01-SUMMARY.md`), '---\nstatus: complete\n---\n# Summary\nDone.' ); + fs.writeFileSync( + path.join(phaseDir, `${paddedId}-VERIFICATION.md`), + '---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n' + ); const extra = extraPhases .map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`) diff --git a/tests/bug-3588-npm-audit-clean.test.cjs b/tests/bug-3588-npm-audit-clean.test.cjs index 13b0e3cbc..88adb394c 100644 --- a/tests/bug-3588-npm-audit-clean.test.cjs +++ b/tests/bug-3588-npm-audit-clean.test.cjs @@ -27,8 +27,13 @@ const { execFileSync } = require('node:child_process'); const ROOT = path.resolve(__dirname, '..'); const SDK = path.join(ROOT, 'sdk'); +const AUDIT_TIMEOUT_MS = 180_000; +const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000; function auditProductionVulns(cwd) { + if (!fs.existsSync(path.join(cwd, 'package.json'))) { + return null; // signal "skip" to caller + } if (!fs.existsSync(path.join(cwd, 'node_modules'))) { return null; // signal "skip" to caller } @@ -46,7 +51,7 @@ function auditProductionVulns(cwd) { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], - timeout: 60_000, + timeout: AUDIT_TIMEOUT_MS, shell: isWindows, } ); @@ -76,10 +81,10 @@ function auditProductionVulns(cwd) { } describe('#3588: npm audit --omit=dev reports zero advisories', () => { - test('root workspace production tree has no advisories', { timeout: 90_000 }, (t) => { + test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => { const vulns = auditProductionVulns(ROOT); if (vulns === null) { - t.skip('node_modules/ not present — run `npm install` before this test'); + t.skip('auditable npm package not present or node_modules/ missing'); return; } assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`); @@ -91,10 +96,10 @@ describe('#3588: npm audit --omit=dev reports zero advisories', () => { assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`); }); - test('sdk/ production tree has no advisories', { timeout: 90_000 }, (t) => { + test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => { const vulns = auditProductionVulns(SDK); if (vulns === null) { - t.skip('sdk/node_modules/ not present — run `npm ci` inside sdk/ before this test'); + t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing'); return; } assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`); diff --git a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs index 2794befb8..5fdad00e0 100644 --- a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs +++ b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs @@ -45,6 +45,26 @@ describe('bug-853 — manager/autonomous gate background dispatch by runtime', ( ); }); + test('manager.md compound actions only background plan/execute on Codex', () => { + const compoundActionSection = MANAGER.match( + /### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/, + ); + assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch'); + + assert.match( + compoundActionSection[0], + /On Codex:[\s\S]{0,260}?Spawn all background agents first[\s\S]{0,220}?plan\/execute/, + ); + assert.match( + compoundActionSection[0], + /On Claude Code or any other non-Codex runtime:[\s\S]{0,260}?inline/, + ); + assert.doesNotMatch( + compoundActionSection[0], + /On other runtimes:[\s\S]{0,260}?Spawn all background agents first/, + ); + }); + test('autonomous.md gates interactive background dispatch by runtime', () => { const autoRuntimeMatches = AUTONOMOUS.match(/config-get runtime/g) || []; assert.ok(autoRuntimeMatches.length >= 2, 'autonomous.md must resolve runtime in both 3b (plan) and 3c (execute) interactive branches'); diff --git a/tests/concurrency-safety.test.cjs b/tests/concurrency-safety.test.cjs index 9dff9ecc0..a4e48a3fc 100644 --- a/tests/concurrency-safety.test.cjs +++ b/tests/concurrency-safety.test.cjs @@ -126,6 +126,10 @@ describe('planning lock integration', () => { fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); + fs.writeFileSync( + path.join(p1, '01-VERIFICATION.md'), + '---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n', + ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runGsdTools('phase complete 1', tmpDir); @@ -637,6 +641,10 @@ describe('stress tests with 50+ phases', () => { path.join(phase26Dir, '26-01-SUMMARY.md'), '# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n' ); + fs.writeFileSync( + path.join(phase26Dir, '26-VERIFICATION.md'), + '---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n', + ); const result = runGsdTools('phase complete 26', tmpDir); assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`); diff --git a/tests/feat-3262-scan-phase-plans.test.cjs b/tests/feat-3262-scan-phase-plans.test.cjs index ea3e82134..3723448a5 100644 --- a/tests/feat-3262-scan-phase-plans.test.cjs +++ b/tests/feat-3262-scan-phase-plans.test.cjs @@ -186,6 +186,8 @@ describe('scanPhasePlans — nested layout', () => { assert.strictEqual(result.planCount, 1); assert.strictEqual(result.summaryCount, 1); assert.strictEqual(result.completed, true); + assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']); + assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']); }); test('flat root + nested plans combined', () => { diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index b2ab6a49a..b2c3c5952 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -55,6 +55,13 @@ function scaffoldPhase(tmpDir, num, opts = {}) { return dir; } +function writePassedVerification(phaseDir, padded) { + fs.writeFileSync( + path.join(phaseDir, `${padded}-VERIFICATION.md`), + ['---', 'status: passed', '---', '', '# Verification', ''].join('\n') + ); +} + describe('init manager', () => { let tmpDir; @@ -110,8 +117,8 @@ describe('init manager', () => { { number: '5', name: 'Not Started' }, ]); - // Phase 1: complete (plans + matching summaries) - scaffoldPhase(tmpDir, 1, { slug: 'complete-phase', context: true, plans: 2, summaries: 2 }); + // Phase 1: complete (plans + matching summaries + passed verification) + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'complete-phase', context: true, plans: 2, summaries: 2 }), '01'); // Phase 2: planned (plans, no summaries) scaffoldPhase(tmpDir, 2, { slug: 'planned-phase', context: true, plans: 3 }); // Phase 3: discussed (context only) @@ -158,7 +165,7 @@ describe('init manager', () => { { number: '1', name: 'Foundation', complete: true }, { number: '2', name: 'Depends on 1', depends_on: 'Phase 1' }, ]); - scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }), '01'); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); @@ -240,7 +247,7 @@ describe('init manager', () => { { number: '5', name: 'Polish' }, ]); - scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }), '01'); scaffoldPhase(tmpDir, 2, { slug: 'api-layer', context: true, plans: 2 }); // planned scaffoldPhase(tmpDir, 3, { slug: 'auth', context: true }); // discussed @@ -269,7 +276,7 @@ describe('init manager', () => { { number: '4', name: 'Ready to Discuss' }, ]); - scaffoldPhase(tmpDir, 1, { slug: 'complete', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'complete', plans: 1, summaries: 1 }), '01'); scaffoldPhase(tmpDir, 2, { slug: 'ready-to-execute', context: true, plans: 2 }); scaffoldPhase(tmpDir, 3, { slug: 'ready-to-plan', context: true }); @@ -306,8 +313,8 @@ describe('init manager', () => { { number: '1', name: 'Done', complete: true }, { number: '2', name: 'Also Done', complete: true }, ]); - scaffoldPhase(tmpDir, 1, { slug: 'done', plans: 1, summaries: 1 }); - scaffoldPhase(tmpDir, 2, { slug: 'also-done', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'done', plans: 1, summaries: 1 }), '01'); + writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'also-done', plans: 1, summaries: 1 }), '02'); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); @@ -316,6 +323,93 @@ describe('init manager', () => { assert.strictEqual(output.recommended_actions.length, 0); }); + test('implementation-complete phase without passed verification is not all_complete', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Implemented', complete: true }, + ]); + scaffoldPhase(tmpDir, 1, { slug: 'implemented', plans: 1, summaries: 1 }); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.all_complete, false); + assert.strictEqual(output.completed_count, 0); + assert.strictEqual(output.phases[0].disk_status, 'executed'); + assert.strictEqual(output.phases[0].implementation_complete, true); + assert.strictEqual(output.phases[0].verification_status, 'missing'); + assert.strictEqual(output.phases[0].verification_passed, false); + assert.strictEqual(output.recommended_actions[0].action, 'verify'); + assert.match(output.recommended_actions[0].command, /execute-phase 1/); + }); + + test('stale passed verification is not projected as complete', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Implemented', complete: true }, + ]); + const phaseDir = scaffoldPhase(tmpDir, 1, { slug: 'implemented', plans: 1, summaries: 1 }); + writePassedVerification(phaseDir, '01'); + const verificationPath = path.join(phaseDir, '01-VERIFICATION.md'); + const summaryPath = path.join(phaseDir, '01-01-SUMMARY.md'); + const older = new Date('2025-01-01T00:00:00.000Z'); + const newer = new Date('2025-01-01T00:01:00.000Z'); + fs.utimesSync(verificationPath, older, older); + fs.utimesSync(summaryPath, newer, newer); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.all_complete, false); + assert.strictEqual(output.completed_count, 0); + assert.strictEqual(output.phases[0].disk_status, 'executed'); + assert.strictEqual(output.phases[0].implementation_complete, true); + assert.strictEqual(output.phases[0].verification_status, 'stale'); + assert.strictEqual(output.phases[0].verification_passed, false); + assert.strictEqual(output.phases[0].phase_complete, false); + assert.strictEqual(output.recommended_actions[0].action, 'verify'); + assert.match(output.recommended_actions[0].reason, /verification stale/); + assert.match(output.recommended_actions[0].command, /verify-work 1/); + }); + + test('checked unpadded roadmap token does not satisfy padded unverified dependency', () => { + writeState(tmpDir); + const roadmap = [ + '# Roadmap', + '', + '## Progress', + '', + '- [x] **Phase 1: Foundation**', + '- [ ] **Phase 02: Followup**', + '', + '### Phase 01: Foundation', + '', + '**Goal:** Build foundation', + '', + '### Phase 02: Followup', + '', + '**Goal:** Build followup', + '**Depends on:** Phase 1', + '', + ].join('\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const phase1 = output.phases.find(p => p.number === '01'); + const phase2 = output.phases.find(p => p.number === '02'); + + assert.ok(phase1, 'Phase 01 should be in the output'); + assert.strictEqual(phase1.phase_complete, false, 'Phase 01 is unverified and must not be complete'); + assert.ok(phase2, 'Phase 02 should be in the output'); + assert.strictEqual(phase2.deps_satisfied, false, 'Phase 02 dependency must wait for canonical Phase 01 verification'); + }); + test('WAITING.json detected when present', () => { writeState(tmpDir); writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]); @@ -564,9 +658,9 @@ describe('init manager', () => { ]); // Scaffold completed phases on disk - scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 2, summaries: 2 }); - scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }); - scaffoldPhase(tmpDir, 3, { slug: 'polish', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 2, summaries: 2 }), '01'); + writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }), '02'); + writePassedVerification(scaffoldPhase(tmpDir, 3, { slug: 'polish', plans: 1, summaries: 1 }), '03'); const result = runGsdTools('init manager', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -584,8 +678,8 @@ describe('init manager', () => { { number: '999.1', name: 'Backlog idea' }, ]); - scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 1, summaries: 1 }); - scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }); + writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 1, summaries: 1 }), '01'); + writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }), '02'); // Phase 3 has no directory — should trigger discuss recommendation const result = runGsdTools('init manager', tmpDir); diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 88ae54c45..bd9613c51 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -1082,6 +1082,13 @@ describe('cmdInitPhaseOp fallback', () => { describe('cmdInitProgress', () => { let tmpDir; + function writePassedVerification(phaseDir, phaseToken) { + fs.writeFileSync( + path.join(phaseDir, `${phaseToken}-VERIFICATION.md`), + ['---', 'status: passed', '---', '', '# Verification', ''].join('\n') + ); + } + beforeEach(() => { tmpDir = createFixture(); }); @@ -1108,6 +1115,7 @@ describe('cmdInitProgress', () => { fs.mkdirSync(phase1, { recursive: true }); fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary'); + writePassedVerification(phase1, '01'); // Phase 02: in_progress (has plan, no summary) const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api'); @@ -1161,6 +1169,7 @@ describe('cmdInitProgress', () => { fs.mkdirSync(phase1, { recursive: true }); fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary'); + writePassedVerification(phase1, '01'); const result = runGsdTools('init progress', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -1171,6 +1180,26 @@ describe('cmdInitProgress', () => { assert.strictEqual(output.next_phase, null); }); + test('implementation-complete phase without passed verification remains current work', () => { + const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phase1, { recursive: true }); + fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary'); + + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.completed_count, 0); + assert.strictEqual(output.in_progress_count, 1); + assert.strictEqual(output.has_work_in_progress, true); + assert.strictEqual(output.current_phase.number, '01'); + assert.strictEqual(output.current_phase.status, 'executed'); + assert.strictEqual(output.current_phase.implementation_complete, true); + assert.strictEqual(output.current_phase.verification_status, 'missing'); + assert.strictEqual(output.current_phase.verification_passed, false); + }); + test('paused_at detected from STATE.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), diff --git a/tests/milestone-summary.test.cjs b/tests/milestone-summary.test.cjs index 07a0e1171..53fd4a459 100644 --- a/tests/milestone-summary.test.cjs +++ b/tests/milestone-summary.test.cjs @@ -21,6 +21,14 @@ const repoRoot = path.resolve(__dirname, '..'); const commandPath = path.join(repoRoot, 'commands', 'gsd', 'milestone-summary.md'); const workflowPath = path.join(repoRoot, 'gsd-core', 'workflows', 'milestone-summary.md'); +function extractStep(content, stepName) { + const start = content.indexOf(``); + assert.ok(start !== -1, `${stepName} step must exist`); + const end = content.indexOf('', start); + assert.ok(end !== -1, `${stepName} step must close`); + return content.slice(start, end); +} + describe('milestone-summary command', () => { test('command file exists', () => { assert.ok(fs.existsSync(commandPath), 'commands/gsd/milestone-summary.md should exist'); @@ -405,6 +413,32 @@ describe('complete-milestone workflow has pre-close audit gate (#2158)', () => { completeMilestoneContent.includes('sanitiz') || completeMilestoneContent.includes('SECURITY'), ); }); + + test('complete-milestone distinguishes verified and override closeout (#1527)', () => { + assert.match(completeMilestoneContent, /all_phases_verified/); + assert.match(completeMilestoneContent, /closeout_type/); + assert.match(completeMilestoneContent, /verified_closeout/); + assert.match(completeMilestoneContent, /override_closeout/); + assert.match(completeMilestoneContent, /Known verification overrides/); + }); + + test('verified closeout uses init.manager canonical verification projection (#1522)', () => { + const readinessStep = extractStep(completeMilestoneContent, 'verify_readiness'); + + assert.match(readinessStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/); + assert.ok( + readinessStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'), + 'complete-milestone readiness must dereference large init.manager payloads before jq', + ); + assert.match(readinessStep, /select\(\(\.number \| tostring \| test\("\^999/); + assert.match(readinessStep, /\| not\)\)/); + assert.match(readinessStep, /phase_complete === true/); + assert.match(readinessStep, /verification_status === 'passed'/); + assert.match(readinessStep, /If not all_phases_verified/); + assert.match(readinessStep, /verified_closeout must not proceed/); + assert.doesNotMatch(readinessStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/); + assert.doesNotMatch(readinessStep, /disk_status === 'complete'/); + }); }); describe('verify-work workflow has phase artifact check (#2157)', () => { diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 204557c45..fc70420fc 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -24,6 +24,108 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); +function normalizePhaseToken(token) { + return String(token).replace(/\d+/g, (digits) => String(Number(digits))); +} + +function phaseTokenFromDirName(name) { + const match = name.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+[A-Z]?(?:\.\d+)*)/i); + return match ? match[1] : null; +} + +function writePassedVerificationForPhase(tmpDir, phase) { + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + const wanted = normalizePhaseToken(phase); + const phaseDirName = fs.readdirSync(phasesDir) + .find((name) => normalizePhaseToken(phaseTokenFromDirName(name) || '') === wanted); + + assert.ok(phaseDirName, `expected phase directory for Phase ${phase}`); + + const phaseDir = path.join(phasesDir, phaseDirName); + fs.writeFileSync( + path.join(phaseDir, `${phase}-VERIFICATION.md`), + ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), + ); +} + +function runVerifiedPhaseComplete(args, tmpDir, env) { + const argv = Array.isArray(args) + ? args + : (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) + .map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); + const completeIdx = argv.findIndex((token, index) => token === 'complete' && argv[index - 1] === 'phase'); + assert.notEqual(completeIdx, -1, `expected phase complete command, got ${argv.join(' ')}`); + const phase = argv[completeIdx + 1]; + assert.ok(phase, `expected phase number in command ${argv.join(' ')}`); + writePassedVerificationForPhase(tmpDir, phase); + return runGsdTools(args, tmpDir, env); +} + +function writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus) { + const planningDir = path.join(tmpDir, '.planning'); + const phase1Dir = path.join(planningDir, 'phases', '01-foundation'); + const phase2Dir = path.join(planningDir, 'phases', '02-api'); + fs.mkdirSync(phase1Dir, { recursive: true }); + fs.mkdirSync(phase2Dir, { recursive: true }); + + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [ ] Phase 1: Foundation', + '- [ ] Phase 2: API', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '**Plans:** 1 plans', + '', + '### Phase 2: API', + '**Goal:** Build API', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n'), + ); + + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '**Last Activity Description:** Working on phase 1', + '', + ].join('\n'), + ); + + fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n'); + + if (verificationStatus !== null) { + fs.writeFileSync( + path.join(phase1Dir, '01-VERIFICATION.md'), + [ + '---', + `status: ${verificationStatus}`, + '---', + '', + '# Verification', + '', + ].join('\n'), + ); + } +} + describe('phases list command', () => { let tmpDir; @@ -2098,6 +2200,82 @@ Plans: // phase complete command // ───────────────────────────────────────────────────────────────────────────── +describe('phase complete canonical verification gate (#1522)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const [name, verificationStatus, expectedMessage] of [ + ['missing verification report', null, /No verification report found/i], + ['unknown verification status', 'unexpected_value', /Unexpected verification status/i], + ['human-needed verification status', 'human_needed', /Human verification required/i], + ['gap-bearing verification status', 'gaps_found', /Gaps found/i], + ]) { + test(`blocks ${name} before mutating ROADMAP or STATE`, () => { + writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); + const beforeState = fs.readFileSync(statePath, 'utf-8'); + + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + + assert.equal(result.success, false, 'phase complete must fail when verification has not passed'); + const errorPayload = JSON.parse(result.error); + assert.equal(errorPayload.reason, 'phase_verification_incomplete'); + assert.match(errorPayload.message, expectedMessage); + assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); + assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); + }); + } + + test('allows passed verification to complete and advance the phase', () => { + writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); + + const result = runGsdTools(['phase', 'complete', '1'], tmpDir); + + assert.equal(result.success, true, `phase complete failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.equal(output.completed_phase, '1'); + assert.equal(output.next_phase, '02'); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(roadmap, /- \[x\] Phase 1: Foundation/); + assert.match(state, /\*\*Current Phase:\*\* 02/); + }); + + test('blocks stale passed verification when summaries changed later', () => { + writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-01-SUMMARY.md'); + const verificationPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-VERIFICATION.md'); + const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); + const beforeState = fs.readFileSync(statePath, 'utf-8'); + + const older = new Date('2025-01-01T00:00:00.000Z'); + const newer = new Date('2025-01-01T00:01:00.000Z'); + fs.utimesSync(verificationPath, older, older); + fs.utimesSync(summaryPath, newer, newer); + + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + + assert.equal(result.success, false, 'phase complete must fail when verification is stale'); + const errorPayload = JSON.parse(result.error); + assert.equal(errorPayload.reason, 'phase_verification_incomplete'); + assert.match(errorPayload.message, /stale/i); + assert.match(errorPayload.message, /\/gsd:verify-work 0?1/); + assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); + assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); + }); +}); describe('phase complete command', () => { let tmpDir; @@ -2137,7 +2315,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -2173,7 +2351,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -2238,7 +2416,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); @@ -2311,7 +2489,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); @@ -2367,7 +2545,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // REQUIREMENTS.md should be unchanged @@ -2398,7 +2576,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`); }); @@ -2440,7 +2618,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true'); @@ -2486,7 +2664,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); @@ -2538,7 +2716,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated @@ -2609,7 +2787,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 03.2.1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 03.2.1', tmpDir); assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); @@ -2644,7 +2822,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); @@ -2671,7 +2849,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); @@ -2715,7 +2893,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); @@ -2755,7 +2933,7 @@ describe('phase complete command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); @@ -2795,7 +2973,7 @@ Plans: fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); @@ -2833,7 +3011,7 @@ Plans: fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); @@ -3033,7 +3211,7 @@ describe('phase complete milestone-scoped next-phase', () => { // Phase 6 — next phase in milestone fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true }); - const result = runGsdTools('phase complete 5', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -3068,7 +3246,7 @@ describe('phase complete milestone-scoped next-phase', () => { fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); } - const result = runGsdTools('phase complete 5', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -3204,7 +3382,7 @@ describe('phase complete updates Performance Metrics', () => { `# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n` ); - const result = runGsdTools('phase complete 2', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); @@ -3229,7 +3407,7 @@ describe('phase complete updates Performance Metrics', () => { `# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n` ); - const result = runGsdTools('phase complete 1', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); @@ -3310,7 +3488,7 @@ describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => // Backlog stub on disk — this is what triggers the bug fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true }); - const result = runGsdTools('phase complete 2', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -3390,6 +3568,7 @@ describe('bug #1962: normalizePhaseName preserves letter suffix case', () => { * that may exit non-zero in these minimal fixtures. */ function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) { + writePassedVerificationForPhase(tmpDir, phase); try { return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], { cwd: tmpDir, @@ -4138,6 +4317,9 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c { function runSdkQuery(args, cwd) { + if (Array.isArray(args) && args[0] === 'phase.complete') { + writePassedVerificationForPhase(cwd, args[1]); + } const result = runGsdTools(args, cwd); if (!result.success) return { success: false, error: result.error }; try { @@ -4501,6 +4683,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => { const { planningDir } = setupPhase1316Project(tmpDir); + writePassedVerificationForPhase(tmpDir, '32'); const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], { cwd: tmpDir, @@ -4906,7 +5089,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c setupPhaseForAutoPrune(tmpDir, 6, 2); - const result = runGsdTools('phase complete 6', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); @@ -4946,7 +5129,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c setupPhaseForAutoPrune(tmpDir, 6, 2); - const result = runGsdTools('phase complete 6', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); @@ -4982,7 +5165,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c setupPhaseForAutoPrune(tmpDir, 6, 2); - const result = runGsdTools('phase complete 6', tmpDir); + const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); diff --git a/tests/progress-forensic.test.cjs b/tests/progress-forensic.test.cjs index 97f2d3c0f..87fe3a85a 100644 --- a/tests/progress-forensic.test.cjs +++ b/tests/progress-forensic.test.cjs @@ -160,18 +160,32 @@ describe('#1107: progress routing consults verification.status before reporting workflow.includes('verification_status'), 'progress workflow must track a verification_status value for routing' ); + assert.ok( + workflow.includes('stale verification'), + 'progress workflow must document that verification.status projects stale verification' + ); }); test('routing table has gaps_found and human_needed rows BEFORE the generic complete row', () => { const workflow = readWorkflow(); + const missingIdx = workflow.indexOf('verification_status = missing'); + const unknownIdx = workflow.indexOf('verification_status = unknown'); + const staleIdx = workflow.indexOf('verification_status = stale'); const gapsIdx = workflow.indexOf('verification_status = gaps_found'); const humanIdx = workflow.indexOf('verification_status = human_needed'); - const completeIdx = workflow.indexOf('Phase complete (verification passed'); + const completeIdx = workflow.indexOf('Phase complete (verification passed)'); + assert.ok(missingIdx > -1, 'routing table must have a missing verification row'); + assert.ok(unknownIdx > -1, 'routing table must have an unknown verification row'); + assert.ok(staleIdx > -1, 'routing table must have a stale verification row'); assert.ok(gapsIdx > -1, 'routing table must have a gaps_found row'); assert.ok(humanIdx > -1, 'routing table must have a human_needed row'); assert.ok(completeIdx > -1, 'routing table must keep a generic complete row'); assert.ok( - gapsIdx < completeIdx && humanIdx < completeIdx, + missingIdx < completeIdx && + unknownIdx < completeIdx && + staleIdx < completeIdx && + gapsIdx < completeIdx && + humanIdx < completeIdx, 'verification rows must precede the generic "summaries = plans" complete row (first-match-wins)' ); }); @@ -204,11 +218,26 @@ describe('#1107: progress routing consults verification.status before reporting ); }); - test('missing/passed verification still routes as complete (no false blocker)', () => { + test('stale verification routes to verify-work (Route V.stale)', () => { const workflow = readWorkflow(); + assert.ok(workflow.includes('**Route V.stale:'), 'must define a Route V.stale section'); + const route = workflow.slice( + workflow.indexOf('**Route V.stale:'), + workflow.indexOf('**Route V.gaps:') + ); assert.ok( - workflow.includes('Phase complete (verification passed, missing, or n/a)'), - 'the generic complete row must still cover passed/missing/unknown so unverified phases are not falsely blocked' + route.includes('verify-work'), + 'Route V.stale must route to /gsd:verify-work {phase}' ); }); + + test('missing and unknown verification do not route as complete', () => { + const workflow = readWorkflow(); + assert.ok( + workflow.includes('Phase complete (verification passed)'), + 'the generic complete row must only cover passed verification' + ); + assert.ok(!workflow.includes('verification passed, missing, or n/a'), + 'missing or unknown verification must not be documented as complete'); + }); }); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index c1167d360..6eb416003 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -14,6 +14,13 @@ const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { createFixture } = require('./fixtures/index.cjs'); +function writePassedVerification(tmpDir, phaseDirName, paddedPhase) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'phases', phaseDirName, `${paddedPhase}-VERIFICATION.md`), + ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), + ); +} + describe('state-snapshot command', () => { let tmpDir; @@ -1927,6 +1934,7 @@ describe('updatePerformanceMetricsSection', () => { fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), '# Plan 2\n'); fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary 1\n'); fs.writeFileSync(path.join(phaseDir, '03-02-SUMMARY.md'), '# Summary 2\n'); + writePassedVerification(tmpDir, '03-api', '03'); // Also need ROADMAP.md for phase complete fs.writeFileSync( @@ -1977,6 +1985,7 @@ describe('updatePerformanceMetricsSection', () => { fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '04-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'), '# Summary 1\n'); + writePassedVerification(tmpDir, '04-ui', '04'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -2018,6 +2027,7 @@ describe('updatePerformanceMetricsSection', () => { fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '05-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '05-01-SUMMARY.md'), '# Summary\n'); + writePassedVerification(tmpDir, '05-final', '05'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -2079,6 +2089,7 @@ describe('updatePerformanceMetricsSection', () => { fs.writeFileSync(path.join(phaseDir, '06-02-PLAN.md'), '# Plan 2\n'); fs.writeFileSync(path.join(phaseDir, '06-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '06-02-SUMMARY.md'), '# Summary 2\n'); + writePassedVerification(tmpDir, '06-lock', '06'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), diff --git a/tests/transition-verification-gate.test.cjs b/tests/transition-verification-gate.test.cjs new file mode 100644 index 000000000..b7cbc6bd9 --- /dev/null +++ b/tests/transition-verification-gate.test.cjs @@ -0,0 +1,19 @@ +// allow-test-rule: source-text-is-the-product see #1522 + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const transitionWorkflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'transition.md'); + +test('transition workflow treats unresolved verification as a blocking phase gate (#1522)', () => { + const content = fs.readFileSync(transitionWorkflowPath, 'utf-8'); + + assert.match(content, /preliminary check blocks obviously unresolved verification/i); + assert.match(content, /phase\.complete[\s\S]*fail-closes/i); + assert.match(content, /authoritative stale-aware gate/i); + assert.match(content, /canonical verification\s+status is `passed`/i); + assert.doesNotMatch(content, /does NOT block transition/i); + assert.doesNotMatch(content, /carry forward as debt/i); +}); diff --git a/tests/uat-predicate.test.cjs b/tests/uat-predicate.test.cjs index c1f0244f6..6a96edf89 100644 --- a/tests/uat-predicate.test.cjs +++ b/tests/uat-predicate.test.cjs @@ -531,19 +531,32 @@ describe('evaluateUatPassed — VERIFICATION files', () => { assert.strictEqual(report.passed, true); }); - test('VERIFICATION status complete satisfies --require-verification', () => { + test('VERIFICATION status complete does NOT satisfy --require-verification', () => { writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1)); writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: complete\n---\n\nAll good.'); const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } }); - assert.strictEqual(report.passed, true); + assert.strictEqual(report.passed, false); assert.strictEqual(report.policy.require_verification, true); + assert.ok(report.blockers.some(b => /verification required/i.test(b)), + `Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`); }); - test('VERIFICATION status verified satisfies --require-verification', () => { + test('VERIFICATION status verified does NOT satisfy --require-verification', () => { writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1)); writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: verified\n---\n\nAll good.'); const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } }); - assert.strictEqual(report.passed, true); + assert.strictEqual(report.passed, false); + assert.ok(report.blockers.some(b => /verification required/i.test(b)), + `Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`); + }); + + test('VERIFICATION status human_passed does NOT satisfy --require-verification', () => { + writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1)); + writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: human_passed\n---\n\nAll good.'); + const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } }); + assert.strictEqual(report.passed, false); + assert.ok(report.blockers.some(b => /verification required/i.test(b)), + `Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`); }); }); diff --git a/tests/ui-review-next-guidance.test.cjs b/tests/ui-review-next-guidance.test.cjs new file mode 100644 index 000000000..081c624f4 --- /dev/null +++ b/tests/ui-review-next-guidance.test.cjs @@ -0,0 +1,50 @@ +// allow-test-rule: source-text-is-the-product see #1528 +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const UI_REVIEW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ui-review.md'); +const MANAGER = path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'); + +describe('ui-review next guidance', () => { + test('prioritizes current-phase verification over next-phase planning (#1528)', () => { + const content = fs.readFileSync(UI_REVIEW, 'utf-8'); + const nextBlock = content.slice( + content.indexOf('## ▶ Next'), + content.indexOf('## Automated UI Verification'), + ); + + assert.match(nextBlock, /verify-work \{N\}/, 'ui-review must route to current-phase UAT'); + assert.doesNotMatch( + nextBlock, + /plan-phase \{N\+1\}/, + 'ui-review must not present next-phase planning before current-phase verification passes', + ); + assert.equal( + (nextBlock.match(/verify-work \{N\}/g) || []).length, + 1, + 'ui-review next block must not duplicate verify-work guidance', + ); + }); +}); + +describe('manager verify dispatch', () => { + test('dispatches verify recommendations through their command field (#1523)', () => { + const content = fs.readFileSync(MANAGER, 'utf-8'); + const compoundBlock = content.slice( + content.indexOf('### Compound Action'), + content.indexOf('### Discuss Phase N'), + ); + + assert.match(compoundBlock, /recommended action's `command`/); + assert.match(compoundBlock, /gsd-execute-phase/); + assert.match(compoundBlock, /gsd-verify-work/); + assert.doesNotMatch( + compoundBlock, + /Inline verification:\s*```[\s\S]*Skill\(skill="gsd-verify-work", args="\{PHASE_NUM\}"\)/, + ); + }); +}); diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs index f81a7f664..e44d32530 100644 --- a/tests/verification-status.test.cjs +++ b/tests/verification-status.test.cjs @@ -59,6 +59,11 @@ function writeVerificationMd(dir, filename, status, body = '') { fs.writeFileSync(path.join(dir, filename), frontmatter + body); } +function setMtime(filePath, iso) { + const time = new Date(iso); + fs.utimesSync(filePath, time, time); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('verification-status', () => { @@ -312,6 +317,69 @@ describe('verification-status', () => { } }); + test('passed verification older than a summary returns stale', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); + const dir = path.join(baseDir, '01-stale-passed'); + fs.mkdirSync(dir); + try { + const verificationPath = path.join(dir, '01-VERIFICATION.md'); + const summaryPath = path.join(dir, '01-01-SUMMARY.md'); + writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); + fs.writeFileSync(summaryPath, '# Summary'); + setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); + setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); + + const result = readVerificationStatus(dir); + assert.equal(result.status, 'stale'); + assert.match(result.next_action, /stale/i); + assert.equal(result.next_command, '/gsd:verify-work 01'); + } finally { + cleanup(baseDir); + } + }); + + test('gaps_found verification older than a summary still returns gaps_found (not stale)', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); + const dir = path.join(baseDir, '01-stale-gaps'); + fs.mkdirSync(dir); + try { + const verificationPath = path.join(dir, '01-VERIFICATION.md'); + const summaryPath = path.join(dir, '01-01-SUMMARY.md'); + writeVerificationMd(dir, '01-VERIFICATION.md', 'gaps_found'); + fs.writeFileSync(summaryPath, '# Summary'); + setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); + setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); + + const result = readVerificationStatus(dir); + assert.equal(result.status, 'gaps_found'); + assert.equal(result.next_command, '/gsd:plan-phase 01 --gaps'); + } finally { + cleanup(baseDir); + } + }); + + test('human_needed verification older than nested plans/SUMMARY-NN.md returns stale', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); + const dir = path.join(baseDir, '01-stale-human-nested'); + fs.mkdirSync(dir); + try { + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + const verificationPath = path.join(dir, '01-VERIFICATION.md'); + const summaryPath = path.join(plansDir, 'SUMMARY-01-manual.md'); + writeVerificationMd(dir, '01-VERIFICATION.md', 'human_needed'); + fs.writeFileSync(summaryPath, '# Summary'); + setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); + setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); + + const result = readVerificationStatus(dir); + assert.equal(result.status, 'stale'); + assert.equal(result.next_command, '/gsd:verify-work 01'); + } finally { + cleanup(baseDir); + } + }); + // ── Task 2 (B1): ship.md gate sentinel contract anchor ──────────────────── // // The deleted tests/ship-586-verification-routing.test.cjs was the only diff --git a/tests/verify-work-auto-transition.test.cjs b/tests/verify-work-auto-transition.test.cjs index 9083b5026..b21382b9a 100644 --- a/tests/verify-work-auto-transition.test.cjs +++ b/tests/verify-work-auto-transition.test.cjs @@ -78,6 +78,47 @@ describe('verify-work.md — auto-transition after UAT passes with 0 issues', () ); }); + test('auto-transition is gated by UAT plus canonical verification predicate', () => { + const content = fs.readFileSync(VERIFY_WORK, 'utf-8'); + const predicateIdx = content.indexOf('phase uat-passed'); + const requireVerificationIdx = content.indexOf('--require-verification'); + const transitionIdx = content.indexOf('transition.md'); + + assert.ok(predicateIdx !== -1, 'verify-work.md must call phase uat-passed before transition'); + assert.ok( + requireVerificationIdx > predicateIdx, + 'verify-work.md must require canonical verification in the UAT predicate' + ); + assert.ok( + predicateIdx < transitionIdx, + 'UAT-plus-verification predicate must run before transition.md' + ); + }); + + test('human_needed verification is promoted to passed only after successful human UAT', () => { + const content = fs.readFileSync(VERIFY_WORK, 'utf-8'); + const statusIdx = content.indexOf('VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR"'); + const humanNeededIdx = content.indexOf('if [ "$VERIFICATION_STATUS_VALUE" = "human_needed" ]; then'); + const setPassedIdx = content.indexOf('gsd_run query frontmatter.set "$VERIFICATION_FILE" --field status --value passed'); + const predicateIdx = content.indexOf('PHASE_COMPLETE=$(gsd_run phase uat-passed "{phase}" --require-verification)'); + + assert.ok(statusIdx !== -1, 'verify-work.md must inspect canonical verification status'); + assert.ok(humanNeededIdx > statusIdx, 'status=passed promotion must be restricted to human_needed'); + assert.ok(setPassedIdx > humanNeededIdx, 'human_needed verification must be promoted after status check'); + assert.ok(setPassedIdx < predicateIdx, 'verification must be canonicalized before the required predicate runs'); + }); + + test('stale verification blocks before phase transition', () => { + const content = fs.readFileSync(VERIFY_WORK, 'utf-8'); + const staleIdx = content.indexOf('If `PHASE_VERIFICATION_STATUS` is `stale`'); + const predicateIdx = content.indexOf('PHASE_COMPLETE=$(gsd_run phase uat-passed "{phase}" --require-verification)'); + const transitionIdx = content.indexOf('transition.md'); + + assert.ok(staleIdx !== -1, 'verify-work.md must stop on stale verification'); + assert.ok(staleIdx < predicateIdx, 'stale verification must be checked before the required predicate'); + assert.ok(staleIdx < transitionIdx, 'stale verification must be checked before transition'); + }); + test('transition is NOT suggested when security enforcement is enabled and no SECURITY.md exists', () => { const content = fs.readFileSync(VERIFY_WORK, 'utf-8'); // The workflow should suggest /gsd-secure-phase when security is enabled but no file exists diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 28990e49d..0ce71782c 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -8,12 +8,12 @@ "audit-fix.md": 10988, "audit-milestone.md": 17637, "audit-uat.md": 7425, - "autonomous.md": 42778, + "autonomous.md": 42675, "check-todos.md": 9431, "cleanup.md": 9897, "code-review-fix.md": 23890, "code-review.md": 31602, - "complete-milestone.md": 29987, + "complete-milestone.md": 31134, "debug.md": 13505, "diagnose-issues.md": 12820, "discovery-phase.md": 8651, @@ -40,7 +40,7 @@ "list-phase-assumptions.md": 4305, "list-seeds.md": 6943, "list-workspaces.md": 5655, - "manager.md": 26265, + "manager.md": 26966, "map-codebase.md": 20789, "milestone-summary.md": 11774, "mvp-phase.md": 13582, @@ -57,7 +57,7 @@ "plant-seed.md": 11741, "pr-branch.md": 15919, "profile-user.md": 20650, - "progress.md": 29387, + "progress.md": 30555, "quick.md": 48830, "reapply-patches.md": 20393, "remove-phase.md": 8469, @@ -79,13 +79,13 @@ "stats.md": 6718, "sync-skills.md": 6125, "thread.md": 12400, - "transition.md": 21787, + "transition.md": 22016, "ui-phase.md": 15477, - "ui-review.md": 11289, + "ui-review.md": 11172, "ultraplan-phase.md": 10468, "undo.md": 10431, "update.md": 21053, "validate-phase.md": 10745, "verify-phase.md": 38228, - "verify-work.md": 33436 + "verify-work.md": 35212 }