Merge remote-tracking branch 'upstream/next' into kimi-runtime-support

This commit is contained in:
Viktorplus
2026-06-07 11:56:18 +02:00
51 changed files with 4350 additions and 205 deletions

View File

@@ -34,6 +34,7 @@ const {
getRoadmapPhaseInternal,
extractPhaseToken,
resolveGranularityInternal,
assertValidGranularityOverride,
} = core;
import { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -340,11 +341,12 @@ function cmdResolveModel(cwd: string, agentType: string | undefined, raw: boolea
output(result, raw, model);
}
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean): void {
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean, override?: string): void {
if (!phaseType) {
error('phase-type required');
}
const granularity = resolveGranularityInternal(cwd, phaseType);
assertValidGranularityOverride(override, error);
const granularity = resolveGranularityInternal(cwd, phaseType, override);
const result = (VALID_PHASE_TYPES).has(phaseType!)
? { granularity, phase_type: phaseType }
: { granularity, phase_type: phaseType, unknown_phase_type: true };

View File

@@ -429,22 +429,31 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Set nested value using dot notation (e.g., "workflow.research")
// Set nested value using dot notation (e.g., "workflow.research").
// Prototype-pollution guard: reject dangerous segments via inline literal
// comparisons on the exact key used to index `current`, immediately before
// each write. The inline comparison is the barrier CodeQL's
// js/prototype-pollution-utility query recognises — the previous Set-based
// pre-loop check was functionally correct but not traced through, so
// code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
const keys = keyPath.split('.');
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
if (keys.some((k) => FORBIDDEN_KEYS.has(k))) {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
current[keys[keys.length - 1]] = parsedValue;
const lastKey = keys[keys.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const previousValue = current[lastKey]; // Capture previous value before overwriting
current[lastKey] = parsedValue;
// Write back
try {

View File

@@ -536,6 +536,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
effort: (parsed['effort']) || null,
fast_mode: (parsed['fast_mode']) || null,
agent_skills: (parsed['agent_skills']) || {},
agent_skills_security: (parsed['agent_skills_security']) || null,
manager: (parsed['manager']) || {},
response_language: get('response_language') || null,
claude_md_path: get('claude_md_path') || null,
@@ -1085,37 +1086,40 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
const sectionStart = selected.index;
const sectionMatch = selected;
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const restContent = content.slice(sectionStart + sectionMatch[0].length);
const nextMilestonePattern = new RegExp(
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
'i'
);
let sectionEnd = content.length;
let fenceChar: string | null = null;
let fenceLen = 0;
let charOffset = 0;
for (const line of restContent.split('\n')) {
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fenceMatch) {
const char = fenceMatch[1][0];
const len = fenceMatch[1].length;
const trailing = fenceMatch[2] || '';
if (!fenceChar) {
fenceChar = char;
fenceLen = len;
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
fenceChar = null;
fenceLen = 0;
const computeSectionEnd = (headingText: string, headingStart: number): number => {
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const rest = content.slice(headingStart + headingText.length);
const stopPattern = new RegExp(
`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
'i',
);
let end = content.length;
let fc: string | null = null;
let fl = 0;
let off = 0;
for (const line of rest.split('\n')) {
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fm) {
const ch = fm[1][0];
const ln = fm[1].length;
const trailing = fm[2] || '';
if (!fc) {
fc = ch;
fl = ln;
} else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
fc = null;
fl = 0;
}
} else if (!fc && stopPattern.test(line)) {
end = headingStart + headingText.length + off;
break;
}
} else if (!fenceChar && nextMilestonePattern.test(line)) {
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
break;
off += line.length + 1;
}
charOffset += line.length + 1;
}
return end;
};
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
const firstMilestoneMatch = content.match(anyMilestonePattern);
@@ -1125,12 +1129,46 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
const beforeMilestones = content.slice(0, preambleCutoff);
const currentSection = content.slice(sectionStart, sectionEnd);
// Multi-milestone roadmaps split each added milestone across two version-bearing
// headings: a `## Phases` checklist subsection (early) and a dedicated
// `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
// detail headers. The scope window above stops at the next version-bearing
// heading — the current milestone's OWN Phase Details heading — leaving those
// detail headers outside `currentSection`. Append that section so phase
// resolution and counting see the current milestone's phases. Anchor the lookup
// to the SELECTED heading's specific version token (boundary-aware, so a
// `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
// that share a version prefix do not cross-pollinate. (#730)
const selectedVersionToken = selected[1].match(
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
)?.[0];
const detailsVersionBoundary = selectedVersionToken
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
: null;
let detailsSection = '';
const detailsMatch = allMatches.find(
(m) =>
/\(Phase\s+Details\)/i.test(m[1]) &&
!isClosed(m[1]) &&
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
(m.index ?? 0) >= sectionEnd,
);
if (detailsMatch) {
const detailsStart = detailsMatch.index ?? 0;
detailsSection = content.slice(
detailsStart,
computeSectionEnd(detailsMatch[0], detailsStart),
);
}
const preamble = beforeMilestones
.replace(/<details>[\s\S]*?<\/details>/gi, '')
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return preamble + currentSection;
return detailsSection
? preamble + currentSection + '\n' + detailsSection
: preamble + currentSection;
}
/**
@@ -1553,7 +1591,12 @@ const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
/**
* Resolve the planning granularity for a phase type (#68).
*/
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined): string {
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
if (override !== undefined && override !== null && override !== '') {
if (VALID_GRANULARITIES.has(override)) {
return override;
}
}
const config = loadConfig(cwd);
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
@@ -1573,6 +1616,19 @@ function resolveGranularityInternal(cwd: string, phaseType: string | null | unde
return 'standard';
}
/**
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
*/
function assertValidGranularityOverride(
override: string | null | undefined,
fail: (msg: string) => never,
): void {
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
}
}
/**
* #3024 — Resolve a model for a specific dynamic-routing attempt.
*/
@@ -2163,6 +2219,7 @@ export = {
resolveModelForTier,
resolveGranularityInternal,
VALID_GRANULARITIES,
assertValidGranularityOverride,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,

View File

@@ -66,8 +66,8 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] });
},
'new-project': () => init.cmdInitNewProject(cwd, raw),
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),

View File

@@ -21,7 +21,7 @@ import { stateExtractField } from './state-document.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { validatePath } from './security.cjs';
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');
@@ -29,6 +29,8 @@ import frontmatterMod = require('./frontmatter.cjs');
const {
loadConfig,
resolveModelInternal,
resolveGranularityInternal,
assertValidGranularityOverride,
findPhaseInternal,
getRoadmapPhaseInternal,
pathExistsInternal,
@@ -375,12 +377,17 @@ function cmdInitPlanPhase(
}
}
const granularityOverride = options['granularity'] as string | undefined;
assertValidGranularityOverride(granularityOverride, error);
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
tdd_mode: options['tdd'] || config.tdd_mode || false,
granularity,
research_enabled: config.research,
plan_checker_enabled: config.plan_checker,
nyquist_validation_enabled: config.nyquist_validation,
@@ -1863,6 +1870,12 @@ function buildAgentSkillsBlock(
if (typeof skillPaths === 'string') skillPaths = [skillPaths];
if (!Array.isArray(skillPaths) || skillPaths.length === 0) return '';
// Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does
// realpathSync I/O and should run at most once per call, not once per failing skill.
// It returns [] cheaply when no roots are configured, so the realpath cost only
// occurs when the caller has actually set trusted_global_roots.
const trustedGlobalRoots = loadTrustedGlobalRoots(config);
const validPaths: { ref: string; display: string }[] = [];
for (const skillPath of skillPaths) {
if (typeof skillPath !== 'string') continue;
@@ -1898,10 +1911,17 @@ function buildAgentSkillsBlock(
}
const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }) as unknown as Record<string, unknown>;
if (!pathCheck['safe']) {
process.stderr.write(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
);
continue;
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
const rootCheck = validatePath(globalSkillMd, root, { allowAbsolute: true }) as unknown as Record<string, unknown>;
return Boolean(rootCheck['safe']);
});
if (!acceptedViaTrustedRoot) {
process.stderr.write(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
);
continue;
}
process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
}
validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
continue;

View File

@@ -19,6 +19,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// ─── Path Traversal Prevention ──────────────────────────────────────────────
@@ -75,6 +76,74 @@ export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowA
return { safe: true, resolved: resolvedPath };
}
/**
* Load the opt-in trusted global roots allowlist from config.
*
* Reads `config.agent_skills_security.trusted_global_roots` (an array of
* path strings). Each entry is canonicalized via realpathSync: non-strings
* are dropped, leading `~/` is expanded to `os.homedir()`, entries that are
* not absolute after expansion are dropped (project-relative paths are
* rejected as a security boundary), and entries that do not exist on disk are
* dropped (a non-existent root is not trustworthy). The canonical realpath is
* used for all subsequent checks and as the stored value — this closes the
* case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`)
* and ensures trust doesn't drift across re-invocations if a root is
* re-created at a different target. Results are de-duplicated by canonical path.
*/
export function loadTrustedGlobalRoots(config: unknown): string[] {
const roots = (config as Record<string, unknown> | null | undefined)
?.['agent_skills_security'] as Record<string, unknown> | undefined;
const raw = roots?.['trusted_global_roots'];
if (!Array.isArray(raw)) return [];
// Compute canonical homedir once for case-insensitive-safe comparison.
let realHome: string;
try {
realHome = fs.realpathSync(os.homedir());
} catch {
realHome = os.homedir();
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of raw) {
if (typeof entry !== 'string') continue;
let expanded: string;
if (entry === '~') {
expanded = os.homedir();
} else if (entry.startsWith('~/')) {
expanded = path.join(os.homedir(), entry.slice(2));
} else {
expanded = entry;
}
if (!path.isAbsolute(expanded)) continue; // reject project-relative
// Canonicalize: resolve symlinks and normalise case. If the path doesn't
// exist or can't be read, skip it — a non-existent root is not trustworthy.
let real: string;
try {
real = fs.realpathSync(expanded);
} catch {
continue; // non-existent or unreadable — skip
}
// Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share').
// Normalize both sides by stripping trailing path separators before comparing so that
// Windows UNC shares (where path.parse().root includes a trailing separator) are caught.
const stripTrailingSep = (p: string): string => p.replace(/[\\/]+$/, '');
if (stripTrailingSep(path.parse(real).root) === stripTrailingSep(real)) continue;
// Reject homedir itself (canonical compare closes case-insensitive bypass).
// Apply stripTrailingSep for robustness on platforms where realpathSync may
// or may not include a trailing separator on the homedir path.
if (stripTrailingSep(real) === stripTrailingSep(realHome)) continue;
if (seen.has(real)) continue;
seen.add(real);
result.push(real);
}
return result;
}
/**
* Validate a file path and throw on traversal attempt.
* Convenience wrapper around validatePath for use in CLI commands.

View File

@@ -0,0 +1,56 @@
/**
* Verification-status subcommand router.
* Routes `verification.status <phaseDir>` to verification.cmdVerificationStatus.
*
* Note: `verification` (reads verifier-emitted status) is distinct from `verify`
* (runs verification checks like plan-structure/artifacts). Keep them separate.
*
* ADR-457 build-at-publish: source in src/verification-command-router.cts,
* compiled to gsd-core/bin/lib/verification-command-router.cjs (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
// ─── Types ────────────────────────────────────────────────────────────────────
interface VerificationModule {
cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void;
}
interface RouteVerificationCommandOptions {
verification: VerificationModule;
args: string[];
cwd: string;
raw: boolean;
error: (message: string) => void;
}
// ─── Implementation ───────────────────────────────────────────────────────────
const VERIFICATION_SUBCOMMANDS = ['status'];
function routeVerificationCommand({
verification,
args,
cwd,
raw,
error,
}: RouteVerificationCommandOptions): void {
routeCjsCommandFamily({
args,
subcommands: VERIFICATION_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand: string, available: string[]) =>
`Unknown verification subcommand. Available: ${available.join(', ')}`,
handlers: {
status: () => verification.cmdVerificationStatus(cwd, args[2], raw),
},
});
}
export = {
routeVerificationCommand,
};

234
src/verification.cts Normal file
View File

@@ -0,0 +1,234 @@
/**
* Verification Status — single queryable home for verification-status routing.
*
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
* previously scattered across ship.md and execute-phase.md into a single
* tested module. Both workflow files will later consume this module's routing
* table as the single source of truth.
*
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
* gsd-core/bin/lib/verification.cjs (gitignored).
*
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
* inside a fenced code block) is ignored — this is the exact failure mode that
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
* its regex at byte 0 of the document, which provides this guarantee.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
import core = require('./core.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
const { output, extractPhaseToken } = core;
const { extractFrontmatter } = frontmatterMod;
// ─── Constants ────────────────────────────────────────────────────────────────
/** The set of status values that the gsd-verifier agent emits. */
const VERIFIER_STATUSES: ReadonlyArray<string> = ['passed', 'gaps_found', 'human_needed'];
// ─── Routing table ────────────────────────────────────────────────────────────
interface VerificationRoute {
status: string;
next_action: string;
next_command: string;
}
/**
* Canonical routing table for verification statuses.
*
* This is the single source of truth — ship.md and execute-phase.md will
* later import from here instead of embedding their own message strings.
*
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
* lookup so they can only be reached via internal construction paths.
*
* 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.
*/
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
passed: {
status: 'passed',
next_action: 'Verification passed — continue.',
next_command: '',
},
gaps_found: {
status: 'gaps_found',
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
// next_command is computed at call time; this entry is never returned directly.
next_command: '',
},
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: '',
},
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
// the file has no parseable frontmatter status. Never emitted by the verifier.
missing: {
status: 'missing',
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
next_command: '/gsd: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',
},
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface FsLike {
readdirSync(dir: string): string[];
readFileSync(filePath: string, encoding: 'utf-8'): string;
}
/**
* Build a 'missing' result from the routing table.
* Used for two early-return paths: no *-VERIFICATION.md file found, and
* file present but no parseable frontmatter status.
*/
function missingResult(): VerificationStatusResult {
const route = VERIFICATION_ROUTING_TABLE['missing'];
return {
status: route.status,
next_action: route.next_action,
next_command: route.next_command,
};
}
// ─── Public API ───────────────────────────────────────────────────────────────
interface ReadVerificationStatusOptions {
fs?: FsLike;
}
interface VerificationStatusResult {
status: string;
next_action: string;
next_command: string;
}
/**
* Read the verification status from the first `*-VERIFICATION.md` file in
* phaseDir and return the routing result.
*
* Behavior:
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
* If none → status 'missing'.
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
* If no frontmatter block or no `status` key → status 'missing'.
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
*
* @param phaseDir - Absolute path to the phase directory.
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
*/
function readVerificationStatus(
phaseDir: string,
opts: ReadVerificationStatusOptions = {},
): VerificationStatusResult {
const fsImpl: FsLike = opts.fs ?? fs;
// Phase token for the gaps_found command
const baseName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(baseName);
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
// 1. Find *-VERIFICATION.md
let verificationFile: string | null = null;
try {
const entries = fsImpl.readdirSync(phaseDir);
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
verificationFile = candidates.length > 0 ? candidates[0] : null;
} catch {
// Directory unreadable → treat as missing
verificationFile = null;
}
if (!verificationFile) {
return missingResult();
}
// 2. Read and parse frontmatter using the shared parser.
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
const filePath = path.join(phaseDir, verificationFile);
let rawStatus: string | null = null;
try {
const content = fsImpl.readFileSync(filePath, 'utf-8');
const fm = extractFrontmatter(content);
const statusVal = fm['status'];
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
// only accept string values — arrays and objects are not valid status values.
if (typeof statusVal === 'string') {
const trimmed = statusVal.trim();
rawStatus = trimmed.length > 0 ? trimmed : null;
}
} catch {
rawStatus = null;
}
if (!rawStatus) {
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;
return {
status: entry.status,
next_action: entry.next_action,
next_command,
};
}
// Unknown value
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
return {
status: unknownRoute.status,
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
next_command: unknownRoute.next_command,
};
}
/**
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
* emit via core.output().
*
* @param cwd - Current working directory (used to resolve phaseDirArg).
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
* @param raw - Whether to emit raw (non-JSON) output.
*/
function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
if (!phaseDirArg) {
core.error('phase directory required for verification.status');
return;
}
const phaseDir = path.resolve(cwd, phaseDirArg);
const result = readVerificationStatus(phaseDir);
output(result, raw);
}
export = {
VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE,
readVerificationStatus,
cmdVerificationStatus,
};

368
src/worktree-base-ref.cts Normal file
View File

@@ -0,0 +1,368 @@
/**
* Worktree base-ref detection and degradation logic (issue #683).
*
* Determines whether a worktree's HEAD has drifted from the fork base that the
* Claude Code harness would use to create a 'fresh' parallel worktree. When
* drift is detected the caller should fall back to sequential execution on the
* main working tree to avoid a base mismatch.
*
* Pure/testable module: all I/O is injectable via the `deps` argument so unit
* tests can run without touching the real filesystem or spawning real git.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam } from './shell-command-projection.cjs';
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Strip JSONC comments (line and block forms) from a string to produce valid JSON.
* Handles comments inside strings correctly (does not strip them).
* Mirrors the same logic in bin/install.js:stripJsonComments.
*/
function stripJsonComments(text: string): string {
let result = '';
let i = 0;
let inString = false;
let stringChar = '';
while (i < text.length) {
// Handle string literals — don't strip comments inside strings
if (inString) {
if (text[i] === '\\') {
result += text[i] + (text[i + 1] || '');
i += 2;
continue;
}
if (text[i] === stringChar) {
inString = false;
}
result += text[i];
i++;
continue;
}
// Start of string
if (text[i] === '"' || text[i] === "'") {
inString = true;
stringChar = text[i];
result += text[i];
i++;
continue;
}
// Line comment
if (text[i] === '/' && text[i + 1] === '/') {
// Skip to end of line
while (i < text.length && text[i] !== '\n') i++;
continue;
}
// Block comment
if (text[i] === '/' && text[i + 1] === '*') {
i += 2;
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
i += 2; // skip closing */
continue;
}
result += text[i];
i++;
}
// Remove trailing commas before } or ] (common in JSONC)
return result.replace(/,\s*([}\]])/g, '$1');
}
/**
* Parse a string as JSONC (JSON with comments). Returns the parsed value or
* throws a SyntaxError if the content is genuinely malformed.
*/
function parseJsonc(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return JSON.parse(stripJsonComments(text));
}
}
// ─── Internal types ───────────────────────────────────────────────────────────
type ExecGitFn = (
args: string[],
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
}
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
// ─── Exports ──────────────────────────────────────────────────────────────────
/**
* Returns the first 8 characters of a SHA, or '' if null/empty.
*/
export function shortSha(sha: string | null): string {
if (!sha) return '';
return sha.slice(0, 8);
}
/**
* Extracts settings.worktree.baseRef if it is a string; otherwise null.
* Defensive: settings may be null/undefined, worktree may be missing or
* not an object.
*/
export function readBaseRefFromSettings(settings: unknown): string | null {
if (settings == null || typeof settings !== 'object') return null;
const s = settings as Record<string, unknown>;
if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree)) return null;
const worktree = s.worktree as Record<string, unknown>;
if (typeof worktree.baseRef !== 'string') return null;
return worktree.baseRef;
}
/**
* No-clobber application of worktree.baseRef = 'head'.
*
* - If baseRef is absent/null/undefined → set to 'head', return changed:true.
* - If already 'head' → skip, return skipped:'already-head'.
* - If any other string → skip without overwriting, return skipped:'explicit-other'.
*
* Mutates `settings` in place and also returns it.
*/
export function applyWorktreeBaseRef(settings: Record<string, unknown>): {
changed: boolean;
settings: object;
skipped: null | 'already-head' | 'explicit-other';
previous: string | null;
} {
// Defensive: caller must pass a plain object — reject null, arrays, and primitives.
if (settings === null || Array.isArray(settings) || typeof settings !== 'object') {
throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`);
}
// Ensure worktree object exists, preserving any existing keys
if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) {
settings.worktree = {};
}
const worktree = settings.worktree as Record<string, unknown>;
const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null;
if (current === 'head') {
return { changed: false, settings, skipped: 'already-head', previous: 'head' };
}
if (current !== null) {
// Some other explicit string value — don't overwrite
return { changed: false, settings, skipped: 'explicit-other', previous: current };
}
// Absent/null/undefined → set to 'head'
worktree.baseRef = 'head';
return { changed: true, settings, skipped: null, previous: null };
}
/**
* Reads settings.local.json then settings.json under claudeDir, extracts
* worktree.baseRef from the first file that provides a non-null string value.
*
* deps.readFile(path) must return the file contents or null on any error.
*/
export function resolveEffectiveBaseRef(
claudeDir: string,
deps?: { readFile?: (p: string) => string | null }
): string | null {
const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => {
try {
return fs.readFileSync(p, 'utf8');
} catch {
return null;
}
});
const localPath = path.join(claudeDir, 'settings.local.json');
const sharedPath = path.join(claudeDir, 'settings.json');
function parseBaseRef(filePath: string): string | null {
const contents = readFile(filePath);
if (contents == null) return null;
try {
const parsed: unknown = parseJsonc(contents);
return readBaseRefFromSettings(parsed);
} catch {
return null;
}
}
const localRef = parseBaseRef(localPath);
if (localRef !== null) return localRef;
return parseBaseRef(sharedPath);
}
/**
* CLI command: check current worktree base-ref degradation status.
*
* Reads effective baseRef from <cwd>/.claude settings, runs degradation
* evaluation, writes JSON result to stdout (or injected write), and returns
* the result object.
*/
export function cmdWorktreeBaseCheck(
cwd: string,
_args: string[],
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void }
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
const claudeDir = path.join(cwd, '.claude');
const effectiveBaseRef = resolveEffectiveBaseRef(
claudeDir,
deps?.readFile ? { readFile: deps.readFile } : undefined
);
const result = evaluateWorktreeBaseDegrade({
cwd,
effectiveBaseRef,
execGit: deps?.execGit,
});
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
write(JSON.stringify(result, null, 2) + '\n');
return result;
}
/**
* CLI command: write worktree.baseRef = 'head' into <cwd>/.claude/settings.local.json.
*
* No-clobber: if the file already has an explicit baseRef that is not 'head',
* the existing value is preserved and output reflects skipped:'explicit-other'.
* If the file contains malformed JSON, throws a clear error rather than
* silently clobbering the user's file.
*/
export function cmdWorktreeSetBaseRef(
cwd: string,
_args: string[],
deps?: {
readFile?: (p: string) => string | null;
writeFile?: (p: string, content: string) => void;
mkdir?: (p: string, opts: { recursive: boolean }) => void;
existsSync?: (p: string) => boolean;
write?: (s: string) => void;
}
): { changed: boolean; skipped: null | 'already-head' | 'explicit-other'; previous: string | null; file: string; baseRef: string } {
const file = path.join(cwd, '.claude', 'settings.local.json');
const readFile: (p: string) => string | null = deps?.readFile ??
((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } });
const raw = readFile(file);
let settings: Record<string, unknown> = {};
if (raw != null) {
let parsed: unknown;
try {
parsed = parseJsonc(raw);
} catch {
throw new Error(`Refusing to modify ${file}: existing JSON is malformed`);
}
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`);
}
settings = parsed as Record<string, unknown>;
}
const apply = applyWorktreeBaseRef(settings);
if (apply.changed) {
const dir = path.dirname(file);
const existsSync: (p: string) => boolean = deps?.existsSync ?? fs.existsSync;
const mkdirFn: (p: string, opts: { recursive: boolean }) => void = deps?.mkdir ??
((p: string, opts: { recursive: boolean }) => { fs.mkdirSync(p, opts); });
if (!existsSync(dir)) {
mkdirFn(dir, { recursive: true });
}
const writeFile: (p: string, content: string) => void = deps?.writeFile ??
((p: string, content: string) => { fs.writeFileSync(p, content, 'utf8'); });
writeFile(file, JSON.stringify(settings, null, 2) + '\n');
}
const output = {
changed: apply.changed,
skipped: apply.skipped,
previous: apply.previous,
baseRef: 'head' as const,
file,
};
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
write(JSON.stringify(output, null, 2) + '\n');
return output;
}
/**
* Evaluates whether the current worktree HEAD has diverged from the fork base
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
* parallel worktree.
*
* Returns a structured result with shouldDegrade, reason, and a user-visible
* message when degradation is warranted.
*/
export function evaluateWorktreeBaseDegrade(deps?: {
execGit?: ExecGitFn;
effectiveBaseRef?: string | null;
cwd?: string;
}): {
shouldDegrade: boolean;
reason: string;
message: string | null;
headSha: string | null;
forkRef: string | null;
forkSha: string | null;
} {
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
const cwd = deps?.cwd;
const cwdOpts = cwd ? { cwd } : {};
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
if (deps?.effectiveBaseRef === 'head') {
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
// b. Resolve HEAD sha.
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
if (headResult.exitCode !== 0 || !headStdout) {
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
const headSha = headStdout;
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
let forkRef: string | null = null;
let forkSha: string | null = null;
// Try direct origin/HEAD rev-parse first.
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
if (directResult.exitCode === 0 && directStdout) {
forkRef = 'origin/HEAD';
forkSha = directStdout;
} else {
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
if (symResult.exitCode === 0 && symStdout) {
const ref = symStdout;
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
if (symShaResult.exitCode === 0 && symShaStdout) {
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
forkRef = ref.replace(/^refs\/remotes\//, '');
forkSha = symShaStdout;
}
}
}
// d. Evaluate.
if (forkSha === null) {
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null };
}
if (forkSha === headSha) {
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha };
}
const message = buildMsgDiverged(headSha, forkRef, forkSha);
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha };
}