* 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:
5
.changeset/rapid-cats-glide.md
Normal file
5
.changeset/rapid-cats-glide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2700
|
||||
---
|
||||
**Verification-status next-step commands now use the command surface each runtime actually installs** — on a Codex project, a phase blocked on verification suggested `/gsd:execute-phase`, which Codex does not install; the correct form is `$gsd-execute-phase`. The routing table stored hard-coded, deprecated colon-form strings with no runtime context, so `phase complete` and `query verification.status` relayed them verbatim to every runtime. All four routed states (missing, unknown, gaps_found, stale) now project through the shared runtime formatter. (#2617)
|
||||
30
src/init.cts
30
src/init.cts
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2832,7 +2832,11 @@ describe('phase complete canonical verification gate (#1522)', () => {
|
||||
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/);
|
||||
// #2617: the blocked-completion message now projects next_command onto the
|
||||
// runtime's installed surface. This project has no runtime configured, so it
|
||||
// takes the `claude` default — the canonical `/gsd-` hyphen form. The colon
|
||||
// form this previously asserted is the deprecated shape #2617 removed.
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -101,7 +101,7 @@ describe('verification-status', () => {
|
||||
result.next_command.includes('--gaps'),
|
||||
`next_command should include --gaps; got: ${result.next_command}`,
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:plan-phase 03 --gaps');
|
||||
assert.equal(result.next_command, '/gsd-plan-phase 03 --gaps');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -114,7 +114,9 @@ describe('verification-status', () => {
|
||||
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'human_needed');
|
||||
assert.equal(result.next_command, '');
|
||||
// #2617: human_needed now names the command the next_action describes.
|
||||
// This fixture's dir is not phase-shaped, so no number is appended.
|
||||
assert.equal(result.next_command, '/gsd-verify-work');
|
||||
assert.ok(result.next_action.length > 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
@@ -129,7 +131,7 @@ describe('verification-status', () => {
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'missing');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
assert.equal(result.next_command, '/gsd-execute-phase');
|
||||
assert.ok(result.next_action.includes('verify step never completed'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
@@ -143,7 +145,7 @@ describe('verification-status', () => {
|
||||
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'unknown');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
assert.equal(result.next_command, '/gsd-execute-phase');
|
||||
assert.ok(
|
||||
result.next_action.includes('bogus'),
|
||||
`next_action should mention the raw value; got: ${result.next_action}`,
|
||||
@@ -295,7 +297,7 @@ describe('verification-status', () => {
|
||||
const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now());
|
||||
const result = readVerificationStatus(nonexistent);
|
||||
assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
assert.equal(result.next_command, '/gsd-execute-phase');
|
||||
});
|
||||
|
||||
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort)
|
||||
@@ -335,7 +337,7 @@ describe('verification-status', () => {
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.match(result.next_action, /stale/i);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 01');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -355,7 +357,7 @@ describe('verification-status', () => {
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'gaps_found');
|
||||
assert.equal(result.next_command, '/gsd:plan-phase 01 --gaps');
|
||||
assert.equal(result.next_command, '/gsd-plan-phase 01 --gaps');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -378,7 +380,7 @@ describe('verification-status', () => {
|
||||
// git times unavailable → mtime-fallback path (#2348).
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 01');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -462,7 +464,7 @@ describe('verification-status', () => {
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, '/gsd:verify-work 02');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 02');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -527,7 +529,7 @@ describe('verification-status', () => {
|
||||
'stale',
|
||||
'a dirty summary edited after the verification must stale it via mtime, not be shadowed by an equal/earlier commit time',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 02');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 02');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
@@ -645,7 +647,7 @@ describe('verification-status', () => {
|
||||
'stale',
|
||||
'summary committed after the verification must read stale on the real git clock, and the dash-named file must resolve through the `--` pathspec guard',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 01');
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
@@ -694,7 +696,7 @@ describe('verification-status', () => {
|
||||
'stale',
|
||||
'a committed-then-edited (dirty) summary must read stale via mtime, not be shadowed by its now-stale commit time',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
assert.equal(result.next_command, '/gsd-verify-work 01');
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
@@ -799,3 +801,252 @@ describe('verification-status', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── #2617: next_command runtime projection ──────────────────────────────────
|
||||
//
|
||||
// Regression tests for #2617 — verification-status `next_command` bypassed the
|
||||
// runtime command-surface projection.
|
||||
//
|
||||
// `src/verification.cts` stored and synthesized hard-coded `/gsd:…` 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`, which Codex does not install — the surface there is
|
||||
// `$gsd-execute-phase`. The colon form is doubly wrong: `runtime-slash.cts`
|
||||
// documents that "the colon form is never emitted", so every runtime was getting
|
||||
// a deprecated shape. (The 11 `/gsd-…` assertions above were `/gsd:…` before this
|
||||
// fix — they are the failing-first record.)
|
||||
//
|
||||
// The fix keeps ONE routing seam and makes its emitted command runtime-aware:
|
||||
// the table stores bare command names and every return path projects through
|
||||
// `formatGsdSlash`, with callers passing `resolveRuntime(cwd)`.
|
||||
//
|
||||
// Coverage is the matrix the issue asked for — missing, unknown, gaps_found and
|
||||
// stale, against Codex (`$gsd-…`) and a slash-hyphen runtime (`/gsd-…`) — plus
|
||||
// the `phase complete` error path, not merely the router's return object.
|
||||
|
||||
/** Codex installs `$gsd-<cmd>`; every other shipped runtime installs `/gsd-<cmd>`. */
|
||||
const RUNTIMES = [
|
||||
{ id: 'codex', prefix: '$gsd-' },
|
||||
{ id: 'cursor', prefix: '/gsd-' },
|
||||
];
|
||||
|
||||
// NOTE: deliberately NOT file-scope beforeEach/afterEach. node:test applies
|
||||
// module-scope hooks to EVERY test in the file, so hooks added here for the
|
||||
// #2617 suites would also wrap the ~40 pre-existing tests above — making this
|
||||
// block a single point of failure for suites it has nothing to do with. Each
|
||||
// test allocates and releases its own phase dir instead.
|
||||
let projBaseDir;
|
||||
let projPhaseDir;
|
||||
|
||||
/**
|
||||
* Install the #2617 temp-phase-dir lifecycle INSIDE the calling describe.
|
||||
* node:test scopes hooks to their enclosing describe, so this keeps them off the
|
||||
* ~40 pre-existing tests in this file.
|
||||
*/
|
||||
function useProjectionPhaseDir() {
|
||||
beforeEach(() => {
|
||||
projBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2617-'));
|
||||
projPhaseDir = path.join(projBaseDir, '01-example');
|
||||
fs.mkdirSync(projPhaseDir, { recursive: true });
|
||||
});
|
||||
afterEach(() => cleanup(projBaseDir));
|
||||
}
|
||||
|
||||
const verificationPath = () => path.join(projPhaseDir, '01-VERIFICATION.md');
|
||||
|
||||
function writeStatus(status) {
|
||||
fs.writeFileSync(verificationPath(), `---\nstatus: ${status}\n---\n\n# Verification\n`);
|
||||
}
|
||||
|
||||
function removeVerification() {
|
||||
try { fs.unlinkSync(verificationPath()); } catch { /* already absent */ }
|
||||
}
|
||||
|
||||
/** Make the verification file older than a summary → the stale branch. */
|
||||
function makeStale() {
|
||||
const summaryPath = path.join(projPhaseDir, '01-01-SUMMARY.md');
|
||||
fs.writeFileSync(summaryPath, '# Summary\n');
|
||||
fs.utimesSync(verificationPath(), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'));
|
||||
fs.utimesSync(summaryPath, new Date('2026-01-01T00:01:00Z'), new Date('2026-01-01T00:01:00Z'));
|
||||
}
|
||||
|
||||
// git times unavailable → mtime-fallback path (#2348). Injected so the staleness
|
||||
// clock stays hermetic regardless of the tmpdir's repo state.
|
||||
const NO_GIT = { phaseCleanCommitTimesMs: () => new Map() };
|
||||
|
||||
function read(runtime, extra = {}) {
|
||||
return readVerificationStatus(projPhaseDir, { runtime, ...extra });
|
||||
}
|
||||
|
||||
for (const { id, prefix } of RUNTIMES) {
|
||||
describe(`#2617: next_command uses the ${id} command surface`, () => {
|
||||
useProjectionPhaseDir();
|
||||
|
||||
test('missing verification', () => {
|
||||
removeVerification();
|
||||
assert.equal(read(id).next_command, `${prefix}execute-phase 01`);
|
||||
});
|
||||
|
||||
test('unparseable/absent frontmatter status is also "missing"', () => {
|
||||
fs.writeFileSync(verificationPath(), '# Verification\n\nNo frontmatter here.\n');
|
||||
assert.equal(read(id).next_command, `${prefix}execute-phase 01`);
|
||||
});
|
||||
|
||||
test('unknown status value', () => {
|
||||
writeStatus('not-a-real-status');
|
||||
const result = read(id);
|
||||
assert.equal(result.status, 'unknown');
|
||||
assert.equal(result.next_command, `${prefix}execute-phase 01`);
|
||||
});
|
||||
|
||||
test('gaps_found carries the phase number and --gaps flag through the projection', () => {
|
||||
writeStatus('gaps_found');
|
||||
const result = read(id);
|
||||
assert.equal(result.status, 'gaps_found');
|
||||
assert.equal(result.next_command, `${prefix}plan-phase 01 --gaps`);
|
||||
});
|
||||
|
||||
test('stale carries the phase number through the projection', () => {
|
||||
writeStatus('passed');
|
||||
makeStale();
|
||||
const result = read(id, NO_GIT);
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, `${prefix}verify-work 01`);
|
||||
});
|
||||
|
||||
test('passed has no next step and stays empty, not a bare prefix', () => {
|
||||
// Boundary: projecting an empty command must not emit `$gsd-` / `/gsd-`.
|
||||
writeStatus('passed');
|
||||
assert.equal(read(id).next_command, '',
|
||||
'passed has no next command and must project to the empty string');
|
||||
});
|
||||
|
||||
test('human_needed names the verify-work command its next_action describes', () => {
|
||||
// #2617 unification: the table used to return '' here while init.cts's
|
||||
// parallel projector returned `verify-work <N>` for the same state — the
|
||||
// two surfaces disagreed on whether a next command existed at all.
|
||||
writeStatus('human_needed');
|
||||
assert.equal(read(id).next_command, `${prefix}verify-work 01`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('#2617: no verification output suggests the deprecated colon form', () => {
|
||||
useProjectionPhaseDir();
|
||||
|
||||
test('across every state and runtime, and for the default runtime', () => {
|
||||
const runtimeIds = [...RUNTIMES.map((r) => r.id), undefined];
|
||||
let checked = 0;
|
||||
|
||||
for (const runtime of runtimeIds) {
|
||||
const opts = runtime === undefined ? { ...NO_GIT } : { runtime, ...NO_GIT };
|
||||
|
||||
removeVerification();
|
||||
const cases = [readVerificationStatus(projPhaseDir, opts)];
|
||||
|
||||
for (const status of ['not-a-real-status', 'gaps_found', 'passed', 'human_needed']) {
|
||||
writeStatus(status);
|
||||
cases.push(readVerificationStatus(projPhaseDir, opts));
|
||||
}
|
||||
writeStatus('passed');
|
||||
makeStale();
|
||||
cases.push(readVerificationStatus(projPhaseDir, opts));
|
||||
|
||||
for (const result of cases) {
|
||||
assert.ok(
|
||||
!result.next_command.includes('/gsd:'),
|
||||
`deprecated colon form leaked for runtime=${String(runtime)}: ${result.next_command}`,
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-vacuity: 3 runtimes x 6 states.
|
||||
assert.equal(checked, 18, 'expected every runtime x state combination to be checked');
|
||||
});
|
||||
|
||||
test('the default runtime yields the canonical hyphen form, not the colon form', () => {
|
||||
removeVerification();
|
||||
// No `runtime` option at all — the pre-fix default emitted `/gsd:execute-phase`.
|
||||
assert.equal(readVerificationStatus(projPhaseDir).next_command, '/gsd-execute-phase 01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2617: the phase-complete error path projects too', () => {
|
||||
// The issue is explicit that fixing only the router is insufficient: the
|
||||
// user-visible surface is `phase complete`, which relays next_command into its
|
||||
// blocked-completion error. Driven through the real CLI so the assertion is on
|
||||
// what a user actually sees.
|
||||
const { runGsdTools, createTempGitProject } = require('./helpers.cjs');
|
||||
|
||||
for (const { id, prefix } of RUNTIMES) {
|
||||
test(`phase complete on ${id} suggests ${prefix}execute-phase`, () => {
|
||||
const projectDir = createTempGitProject();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: id }, null, 2),
|
||||
);
|
||||
const phase = path.join(projectDir, '.planning', 'phases', '01-example');
|
||||
fs.mkdirSync(phase, { recursive: true });
|
||||
// No *-VERIFICATION.md → the completion gate blocks with reason "missing".
|
||||
|
||||
const res = runGsdTools(['phase', 'complete', '01'], projectDir);
|
||||
// The blocked-completion message goes to stderr, which runGsdTools
|
||||
// surfaces as `error` (NOT `stderr`) on a clean non-zero exit. Reading
|
||||
// the wrong field yields '' and makes every assertion below vacuous.
|
||||
const text = `${res.output || ''}${res.error || ''}`;
|
||||
|
||||
assert.equal(res.success, false, 'completion must be blocked with no verification report');
|
||||
assert.match(
|
||||
text,
|
||||
/verification is incomplete/i,
|
||||
`expected the blocked-completion error, got: ${text}`,
|
||||
);
|
||||
// Unconditional — a conditional check here passes when the command is
|
||||
// absent entirely, which is exactly how this path stayed untested.
|
||||
assert.ok(
|
||||
text.includes(`${prefix}execute-phase`),
|
||||
`phase complete must suggest ${prefix}execute-phase on ${id}, got: ${text}`,
|
||||
);
|
||||
assert.ok(
|
||||
!text.includes('/gsd:'),
|
||||
`phase complete must not surface the deprecated colon form: ${text}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
|
||||
test(`phase complete on ${id} projects the gaps_found command too`, () => {
|
||||
// Finding from review: the live-CLI check previously exercised only the
|
||||
// `missing` state, so a regression in any other routed branch would show
|
||||
// up in the router's return object but not in what a user actually reads.
|
||||
const projectDir = createTempGitProject();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: id }, null, 2),
|
||||
);
|
||||
const phase = path.join(projectDir, '.planning', 'phases', '01-example');
|
||||
fs.mkdirSync(phase, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phase, '01-VERIFICATION.md'),
|
||||
'---\nstatus: gaps_found\n---\n\n# Verification\n',
|
||||
);
|
||||
|
||||
const res = runGsdTools(['phase', 'complete', '01'], projectDir);
|
||||
const text = `${res.output || ''}${res.error || ''}`;
|
||||
|
||||
assert.equal(res.success, false, 'gaps_found must block completion');
|
||||
assert.ok(
|
||||
text.includes(`${prefix}plan-phase 01 --gaps`),
|
||||
`phase complete must suggest ${prefix}plan-phase 01 --gaps on ${id}, got: ${text}`,
|
||||
);
|
||||
assert.ok(!text.includes('/gsd:'), `deprecated colon form leaked: ${text}`);
|
||||
} finally {
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user