Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
119
src/core.cts
119
src/core.cts
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
30
src/init.cts
30
src/init.cts
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
56
src/verification-command-router.cts
Normal file
56
src/verification-command-router.cts
Normal 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
234
src/verification.cts
Normal 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
368
src/worktree-base-ref.cts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user