fix(#2617): project verification next_command onto the runtime's command surface (#2700)

* fix(#2617): project verification next_command onto the runtime's command surface

`src/verification.cts` stored and synthesized hard-coded `/gsd:…` command strings
with no runtime context, and `phase complete` relayed that raw field straight into
its verification-blocked error. On a Codex project the suggested next step was
`/gsd:execute-phase`, a surface Codex does not install — it installs
`$gsd-execute-phase`.

The colon form is wrong twice over: `runtime-slash.cts` documents that "the colon
form is never emitted", so EVERY runtime — not just Codex — was being handed a
deprecated shape.

Fixed at the one routing seam rather than per caller:

- The routing table now stores BARE command names (`execute-phase`), never a
  prefixed literal. A prefixed literal in the table is what leaked.
- A single `projectNextCommand(bare, runtime, tail)` helper runs every return path
  through `formatGsdSlash`, preserving the argument tail (`01 --gaps`) untouched.
  An empty command stays empty, so "no next step" never becomes a bare prefix.
- `readVerificationStatus` accepts `opts.runtime`; `cmdVerificationStatus` and
  `phase complete` pass `resolveRuntime(cwd)`. The default is `claude`, which
  yields the canonical `/gsd-` hyphen form.

All four routed states are covered: missing, unknown, gaps_found, stale.

`init.cts` keeps its own projector deliberately. It already formats correctly, and
its command CONTENT differs from the router's on purpose (it appends the phase
number to `execute-phase`, and routes `human_needed` to `verify-work`).
Consolidating them would silently change `init`'s user-visible output, which this
issue did not ask for — so the divergence is left intact and the new tests instead
pin the property that matters on both surfaces: no raw colon form escapes.

Failing-first record: `origin/next:src/verification.cts` carried the four `/gsd:`
literals (lines 101, 108, 382, 392), and 11 existing assertions in
tests/verification-status.test.cjs asserted the colon form. Those 11 are corrected
in this commit — they passed before the fix and fail after it, which is precisely
the regression this closes.

Tests are folded into the module's primary suite rather than added as a third file
(`lint-test-file-count` caps the `verification` module at two, and consolidating is
its documented remedy — growing the allowlist is not). The `phase complete`
assertion reads `res.error`, not `res.stderr`: `runGsdTools` exposes a clean
non-zero exit's stderr as `error`, and reading the wrong field yields '' and makes
the whole check vacuous — which is how this user-visible path stayed untested.

Closes #2617

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QT3ibz5qJuDuGqpTGRYVGf

* test(#2617): scope the new hooks to their describes; cover gaps_found through the CLI

Two findings from the orthogonal review of the first commit, both in the tests
this change added.

1. The folded block's `beforeEach`/`afterEach` were declared at MODULE scope.
   node:test applies module-scope hooks to every test in the file, so hooks added
   for the #2617 suites also wrapped the ~40 pre-existing tests in
   verification-status.test.cjs — making an unrelated block a single point of
   failure for them (currently benign, but a throwing hook would have failed
   suites it has nothing to do with). They now install inside their own describes
   via a small `useProjectionPhaseDir()` helper, with a comment recording why.

2. The live-CLI `phase complete` test exercised only the `missing` state, so a
   regression in any other routed branch would have shown up in the router's
   return object but not in the text a user actually reads. Added a `gaps_found`
   case per runtime, asserting the projected `plan-phase <N> --gaps` reaches the
   blocked-completion error.

Whole file verified green: 48 tests, 48 pass — the ~40 pre-existing ones included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QT3ibz5qJuDuGqpTGRYVGf

* test(#2617): correct the last colon-form assertion in phase.test.cjs

The remote run surfaced one more stale assertion outside
tests/verification-status.test.cjs: the `phase complete` canonical-gate suite
matched the blocked-completion message against `/\/gsd:verify-work 0?1/`.

That project fixture configures no runtime, so it takes the `claude` default,
which now yields the canonical `/gsd-verify-work 01` hyphen form. The colon form
this asserted is exactly the deprecated shape #2617 removes — `runtime-slash.cts`
documents that "the colon form is never emitted".

Like the eleven corrected in the first commit, this assertion passed before the
fix and fails after it, which is the regression record rather than a test being
loosened: the surrounding assertions (failure reason, `stale` wording, and that
neither ROADMAP.md nor STATE.md was mutated) are untouched.

Verified against the real CLI: the emitted message is now
"Phase 1 verification is incomplete: Verification is stale. Re-run verify-work
before transition. Next: /gsd-verify-work 01".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QT3ibz5qJuDuGqpTGRYVGf

* fix(#2617): collapse the two verification projectors into one seam

The orthogonal review found that `init.cts` carried a second, independently
maintained `verificationNextCommand()` that had drifted from the router's table
in CONTENT, not just formatting:

  state          router (before)          init.cts
  missing        execute-phase            execute-phase <N>
  unknown        execute-phase            execute-phase <N>
  human_needed   ""  (no command)         verify-work <N>

The `human_needed` row is the sharp one: two GSD surfaces disagreed about whether
a next command existed at all, and the router's own next_action told the user to
"re-run the verify step until status is passed" while naming no command to run.

init's answers were the useful ones, so the router adopts them and init now
delegates to it — satisfying the issue's "keep one verification-routing seam"
direction. `verificationNextCommand()` is deleted.

Appending the phase number surfaced a trap the old bare commands hid.
`extractPhaseToken` also returns project-code forms (`PROJ-07`), which are
indistinguishable by shape from an ordinary directory name — `gsd-651-parent`
yields `gsd-651` — so deriving the argument blindly emits
`execute-phase gsd-651`. The number is therefore appended only when it is
unambiguously numeric, or when the caller supplies it explicitly. `init` does
supply it: its `phaseDir` is unresolved in several branches, where the router
could not derive one at all.

  dir `01-example`      -> $gsd-execute-phase 01, $gsd-verify-work 01
  dir `gsd-651-parent`  -> $gsd-execute-phase,    $gsd-verify-work

Suites verified green against the built lib: verification-status 50/50,
phase 268/268, init 143/143, init-manager 40/40. `npm run lint:ci` clean.

User-visible change beyond the reported bug, as agreed: `query verification.status`
and `phase complete` now append the phase number for missing/unknown, and emit
`verify-work <N>` for human_needed where they previously emitted nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QT3ibz5qJuDuGqpTGRYVGf

* chore(#2617): backfill changeset PR number (#2700)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-27 10:18:10 -04:00
committed by GitHub
parent 1008aabd31
commit 27c2279a39
6 changed files with 350 additions and 50 deletions

View File

@@ -165,22 +165,6 @@ interface PhaseCompletionProjection {
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,
@@ -201,8 +185,14 @@ function buildPhaseCompletionProjection(
): PhaseCompletionProjection {
const implementationComplete = planCount > 0 && summaryCount >= planCount;
const phaseFullDir = phaseDir ? path.join(cwd, phaseDir) : '';
// #2617: ONE verification-routing seam. init used to re-derive next_command
// from the status with its own projector, which had drifted from the router's
// table — it appended the phase number and answered `human_needed`; the table
// did neither. The router now owns both the content and the runtime
// projection, and init passes the phase number it already knows (its phaseDir
// is unresolved in some branches, where the router could not derive one).
const verificationStatus = implementationComplete
? readVerificationStatus(phaseFullDir)
? readVerificationStatus(phaseFullDir, { runtime: slashRuntime, phaseNumber })
: { status: 'not_required', next_action: '', next_command: '' };
const projectedVerificationStatus = verificationStatus.status;
const projectedVerificationAction = verificationStatus.next_action;
@@ -216,11 +206,7 @@ function buildPhaseCompletionProjection(
phase_complete: phaseComplete,
completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
verification_next_action: projectedVerificationAction,
verification_next_command: verificationNextCommand(
projectedVerificationStatus,
phaseNumber,
slashRuntime,
),
verification_next_command: verificationStatus.next_command,
};
}

View File

@@ -1760,7 +1760,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
let isLastPhase = true;
const verificationBlocked = withPlanningLock(cwd, () => {
const verificationStatus = readVerificationStatus(phaseFullDir);
// #2617: pass the project's runtime so the blocked-completion error below
// suggests the command surface this runtime actually installs
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: resolveRuntime(cwd) });
if (verificationStatus.status !== 'passed') {
return verificationStatus;
}

View File

@@ -38,6 +38,7 @@ 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');
import { execGit } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
const { output, error } = io;
const { extractPhaseToken } = phaseId;
@@ -70,6 +71,12 @@ interface VerificationRoute {
*
* For 'gaps_found', next_command is built at call time in readVerificationStatus
* by substituting the phase number — it is NOT stored as a function in the table.
*
* #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
* prefixed one. Every return path projects it through `formatGsdSlash` with the
* caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen runtimes
* see `/gsd-execute-phase`. Storing a prefixed literal is what leaked the
* hard-coded (and deprecated) `/gsd:` colon form to every runtime.
*/
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
passed: {
@@ -86,7 +93,12 @@ const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
human_needed: {
status: 'human_needed',
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: '',
// #2617: was '' — next_action told the user to "re-run the verify step" but
// named no command, while init.cts's parallel projector emitted
// `verify-work <N>` for this same state. The two surfaces disagreed on
// whether a next command existed at all; init's answer was the useful one,
// and init now delegates here rather than re-deriving it.
next_command: 'verify-work',
},
stale: {
status: 'stale',
@@ -98,17 +110,31 @@ const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
missing: {
status: 'missing',
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
next_command: '/gsd:execute-phase',
next_command: 'execute-phase',
},
// INTERNAL SENTINEL: constructed when the file has a status value not in
// VERIFIER_STATUSES. Never emitted by the verifier.
unknown: {
status: 'unknown',
next_action: '', // filled in dynamically with the raw value
next_command: '/gsd:execute-phase',
next_command: 'execute-phase',
},
};
/**
* Project a BARE command name (plus optional argument tail) into the surface the
* given runtime actually installs (#2617).
*
* `formatGsdSlash` owns the per-runtime shape (`$gsd-<cmd>` for shell-var
* runtimes like Codex, `/gsd-<cmd>` otherwise) and is idempotent, so passing an
* already-prefixed string is safe. An empty command stays empty — "no next
* command" must not become a bare prefix.
*/
function projectNextCommand(bare: string, runtime: string, tail = ''): string {
if (!bare) return '';
return `${formatGsdSlash(bare, runtime) as string}${tail}`;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface FsLike {
@@ -235,12 +261,12 @@ function defaultPhaseCleanCommitTimesMs(
* Used for two early-return paths: no *-VERIFICATION.md file found, and
* file present but no parseable frontmatter status.
*/
function missingResult(): VerificationStatusResult {
function missingResult(runtime: string, phaseArg: string): VerificationStatusResult {
const route = VERIFICATION_ROUTING_TABLE['missing'];
return {
status: route.status,
next_action: route.next_action,
next_command: route.next_command,
next_command: projectNextCommand(route.next_command, runtime, phaseArg),
};
}
@@ -250,6 +276,20 @@ interface ReadVerificationStatusOptions {
fs?: FsLike;
/** Injectable per-phase clean-commit-time resolver for the staleness clock (#2348). */
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
/**
* Runtime whose command surface `next_command` is projected into (#2617).
* Callers that have a cwd should pass `resolveRuntime(cwd)`. Defaults to
* `'claude'`, which yields the canonical `/gsd-<cmd>` hyphen form — never the
* deprecated `/gsd:` colon form this field used to hard-code.
*/
runtime?: string;
/**
* Phase number appended to the routed command (#2617). Defaults to the token
* parsed from `phaseDir`, but only when that token is unambiguously numeric.
* Callers that already know the number pass it explicitly — `init` reaches
* this with `phaseDir` unresolved in some branches.
*/
phaseNumber?: string;
}
interface VerificationStatusResult {
@@ -321,6 +361,8 @@ function findStaleVerificationSummary(
*
* @param phaseDir - Absolute path to the phase directory.
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
* `opts.runtime` selects the command surface `next_command` is
* projected into (#2617).
*/
function readVerificationStatus(
phaseDir: string,
@@ -329,11 +371,20 @@ function readVerificationStatus(
const fsImpl: FsLike = opts.fs ?? fs;
const phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn =
opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
const runtime = opts.runtime ?? 'claude';
// Phase token for the gaps_found command
const baseName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(baseName);
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
const derivedPhaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
// #2617: the phase number becomes a COMMAND ARGUMENT, so it is appended only
// when it is unambiguously one. extractPhaseToken also returns project-code
// forms (`PROJ-07`), which are indistinguishable by shape from an ordinary
// directory name — `gsd-651-parent` yields `gsd-651` — and emitting
// `execute-phase gsd-651` is worse than emitting no argument at all. Callers
// that already know the number (init) pass it explicitly and always get it.
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
// 1. Find *-VERIFICATION.md
let verificationFile: string | null = null;
@@ -347,7 +398,7 @@ function readVerificationStatus(
}
if (!verificationFile) {
return missingResult();
return missingResult(runtime, phaseArg);
}
// 2. Read and parse frontmatter using the shared parser.
@@ -369,7 +420,7 @@ function readVerificationStatus(
}
if (!rawStatus) {
return missingResult();
return missingResult(runtime, phaseArg);
}
// gaps_found takes priority over stale — gap closure is the correct next
@@ -379,7 +430,7 @@ function readVerificationStatus(
return {
status: entry.status,
next_action: entry.next_action,
next_command: `/gsd:plan-phase ${phaseNumber} --gaps`,
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
};
}
@@ -389,7 +440,7 @@ function readVerificationStatus(
return {
status: entry.status,
next_action: entry.next_action,
next_command: `/gsd:verify-work ${phaseNumber}`,
next_command: projectNextCommand('verify-work', runtime, phaseArg),
};
}
@@ -406,7 +457,7 @@ function readVerificationStatus(
return {
status: entry.status,
next_action: entry.next_action,
next_command: entry.next_command,
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
};
}
@@ -415,7 +466,7 @@ function readVerificationStatus(
return {
status: unknownRoute.status,
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
next_command: unknownRoute.next_command,
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
};
}
@@ -433,7 +484,7 @@ function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw
return;
}
const phaseDir = path.resolve(cwd, phaseDirArg);
const result = readVerificationStatus(phaseDir);
const result = readVerificationStatus(phaseDir, { runtime: resolveRuntime(cwd) });
output(result, raw);
}