* 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 <codesmith-bot@users.noreply.github.com> * 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 <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * 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 <noreply@anthropic.com> * 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 <codesmith-bot@users.noreply.github.com> * 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 <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
229
src/init.cts
229
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<string, string[]>)['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<string, unknown> | 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<string, unknown> = {
|
||||
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<string>()): 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<string, unknown>;
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const phases: Record<string, unknown>[] = [];
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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<string, VerificationRoute> = {
|
||||
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<string, VerificationRoute> = {
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user