Merge branch 'next' into feat/1346-enhance-verify-phase-project-a-check-vio
This commit is contained in:
@@ -2119,6 +2119,40 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
|
||||
return `${resolvedTarget}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
|
||||
* Exported so test files can import this single source of truth rather than
|
||||
* maintaining divergent hand-rolled arrays (#1521).
|
||||
*
|
||||
* Keep in sync with the runtime flags in bin/install.js and getDirName().
|
||||
*/
|
||||
const NON_CLAUDE_RUNTIMES: string[] = [
|
||||
'codex', 'opencode', 'kilo', 'gemini', 'copilot', 'antigravity',
|
||||
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'kimi',
|
||||
'codebuddy', 'cline',
|
||||
];
|
||||
|
||||
/**
|
||||
* #1521: Every non-Claude runtime resolves its own runtime identity from a
|
||||
* runtime-neutral config, and defaults workflow.use_worktrees to false —
|
||||
* GSD's worktree isolation uses Claude Code's isolation="worktree" spawn
|
||||
* parameter, which no other runtime honors. Stamped into the emitted
|
||||
* workflow runtime-resolution blocks. (Generalizes the Codex-only #1515 fix.)
|
||||
*
|
||||
* @private — exported as `_stampNonClaudeRuntimeDefaults` for tests.
|
||||
*/
|
||||
function _stampNonClaudeRuntimeDefaults(content: string, runtime: string): string {
|
||||
content = content.replace(
|
||||
/config-get workflow\.use_worktrees --raw 2>\/dev\/null \|\| echo "true"/g,
|
||||
'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"',
|
||||
);
|
||||
content = content.replace(
|
||||
/config-get runtime --default claude --raw 2>\/dev\/null \|\| echo "claude"/g,
|
||||
`config-get runtime --default ${runtime} --raw 2>/dev/null || echo "${runtime}"`,
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the per-runtime rewrite table to a single content string.
|
||||
* Relocated from bin/install.js `_applyRuntimeRewrites`.
|
||||
@@ -2133,12 +2167,20 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a
|
||||
const dirName = getDirName(runtime);
|
||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||
|
||||
// #1521: stamp runtime identity + use_worktrees=false for every non-Claude runtime
|
||||
// before brand-specific path rewrites, so the replace operates on the pristine
|
||||
// source line and is idempotent regardless of subsequent path substitutions.
|
||||
if (runtime !== 'claude') {
|
||||
content = _stampNonClaudeRuntimeDefaults(content, runtime);
|
||||
}
|
||||
|
||||
switch (runtime) {
|
||||
case 'codex':
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
|
||||
content = content.replace(/~\/\.codex\//g, pathPrefix);
|
||||
// #1515 stamp moved to _stampNonClaudeRuntimeDefaults (#1521 generalisation).
|
||||
content = processAttribution(content, attribution);
|
||||
break;
|
||||
|
||||
@@ -2402,6 +2444,11 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
|
||||
* attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace
|
||||
* (single copy+rewrite owner).
|
||||
*
|
||||
* @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam
|
||||
* API for command bodies. No production caller today (install rewrites commands via
|
||||
* copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for
|
||||
* API symmetry + test coverage.
|
||||
*
|
||||
* @returns {string} path to the temp dir (caller is responsible for cleanup)
|
||||
*/
|
||||
function rewriteStagedCommandBodies(stagedDir, opts) {
|
||||
@@ -2517,4 +2564,7 @@ export = {
|
||||
rewriteStagedCommandBodies,
|
||||
_computePathPrefix: computePathPrefix,
|
||||
_applyRuntimeRewrites,
|
||||
_stampNonClaudeRuntimeDefaults,
|
||||
// #1521: canonical non-Claude runtime list for test files and tooling
|
||||
NON_CLAUDE_RUNTIMES,
|
||||
};
|
||||
|
||||
@@ -868,6 +868,241 @@ function cmdWorktreeCleanupWave(cwd: string, args: string[] = []): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface RecordAgentFields {
|
||||
agentId: string;
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
interface RecordAgentPlan {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
hint?: string;
|
||||
entry: CleanupManifestEntry | null;
|
||||
/** Serialized manifest to write back (with trailing newline); null when ok === false. */
|
||||
manifest: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure planner for the per-agent wave-manifest append.
|
||||
*
|
||||
* Validates the candidate entry at write time using the SAME rules the
|
||||
* cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
|
||||
* entry that `record-agent` accepts is guaranteed to survive
|
||||
* `normalizeCleanupManifest` on read — a field that would be silently dropped
|
||||
* at cleanup time fails loudly here instead.
|
||||
*
|
||||
* `agent_id` is treated write-strict (required) even though the reader is
|
||||
* lenient (nullable): the whole point of this verb is to catch an
|
||||
* under-populated entry at write time, and an entry whose author cannot be
|
||||
* identified defeats that. A duplicate `(worktree_path, branch)` is also
|
||||
* rejected loudly — the reader dedups on that key, so a re-record would be
|
||||
* silently dropped (the failure mode this verb exists to eliminate). The
|
||||
* on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
|
||||
* `branch`, `expected_base`) — no schema change; the reader re-derives
|
||||
* `allowed_bases`.
|
||||
*/
|
||||
function planWorktreeRecordAgent(manifestRaw: string, fields: RecordAgentFields): RecordAgentPlan {
|
||||
// 1. Write-strict required-field check (loud, with which flag is missing).
|
||||
// Trim first so a whitespace-only value (" ") is rejected here rather
|
||||
// than deferred to a guaranteed `git worktree remove` failure at cleanup.
|
||||
const agentId = (fields.agentId || '').trim();
|
||||
const worktreePath = (fields.worktreePath || '').trim();
|
||||
const branch = (fields.branch || '').trim();
|
||||
const base = (fields.base || '').trim();
|
||||
const missing: string[] = [];
|
||||
if (!agentId) missing.push('--agent-id');
|
||||
if (!worktreePath) missing.push('--path');
|
||||
if (!branch) missing.push('--branch');
|
||||
if (!base) missing.push('--base');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing_field',
|
||||
hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Shared validation: run the candidate through the reader's normalizer.
|
||||
// If it returns null the reader would drop this entry on read — reject now.
|
||||
const candidate = {
|
||||
agent_id: agentId,
|
||||
worktree_path: worktreePath,
|
||||
branch,
|
||||
expected_base: base,
|
||||
};
|
||||
const entry = normalizeCleanupManifestEntry(candidate);
|
||||
if (!entry) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_entry',
|
||||
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
|
||||
// is written inline by the orchestrator before any agent spawns; a missing or
|
||||
// malformed manifest is a loud failure here, not a silent under-populated write.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_manifest_json',
|
||||
hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Accept the canonical {worktrees: []} shell or a bare top-level array (both
|
||||
// are read by normalizeCleanupManifest); preserve any other top-level keys.
|
||||
let worktrees: unknown[];
|
||||
let writeBack: unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
worktrees = parsed;
|
||||
writeBack = worktrees;
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
const container = parsed as Record<string, unknown>;
|
||||
if (container.worktrees === undefined) container.worktrees = [];
|
||||
if (!Array.isArray(container.worktrees)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'manifest_shape_invalid',
|
||||
hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
worktrees = container.worktrees;
|
||||
writeBack = container;
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'manifest_shape_invalid',
|
||||
hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
|
||||
// exact key, but only over entries that NORMALIZE successfully — so an
|
||||
// existing malformed same-key entry (which the reader would drop) must NOT
|
||||
// block recording a valid one. Run each existing entry through the reader's
|
||||
// own normalizer and compare only the entries the reader would keep; this
|
||||
// matches its dedup behavior exactly. A real duplicate signals an upstream
|
||||
// double-spawn — surface it loudly instead of silently dropping it.
|
||||
const dupKey = `${entry.worktree_path}\0${entry.branch}`;
|
||||
const isDuplicate = worktrees.some((existing) => {
|
||||
const normalized = normalizeCleanupManifestEntry(existing);
|
||||
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
|
||||
});
|
||||
if (isDuplicate) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'duplicate_entry',
|
||||
hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
|
||||
entry: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Append the minimal 4-field entry, matching the existing on-disk format.
|
||||
const recorded: CleanupManifestEntry = {
|
||||
agent_id: entry.agent_id,
|
||||
worktree_path: entry.worktree_path,
|
||||
branch: entry.branch,
|
||||
expected_base: entry.expected_base,
|
||||
};
|
||||
worktrees.push(recorded);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'ok',
|
||||
entry: recorded,
|
||||
manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
interface RecordAgentCmdDeps {
|
||||
readFile?: (p: string) => string;
|
||||
writeFile?: (p: string, content: string) => void;
|
||||
write?: (s: string) => void;
|
||||
writeErr?: (s: string) => void;
|
||||
}
|
||||
|
||||
interface RecordAgentCmdResult {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
hint?: string;
|
||||
entry: CleanupManifestEntry | null;
|
||||
manifest_path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: append a validated per-agent entry to a wave cleanup manifest.
|
||||
*
|
||||
* Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
||||
*
|
||||
* Fails loudly (non-zero exit + recovery hint on stderr) when a field is
|
||||
* missing/garbled or the manifest is absent/malformed, rather than appending an
|
||||
* under-populated entry that the cleanup reader would silently drop.
|
||||
*/
|
||||
function cmdWorktreeRecordAgent(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps = {}): RecordAgentCmdResult {
|
||||
const flag = (name: string): string => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
|
||||
};
|
||||
const write = deps.write || ((s: string) => process.stdout.write(s));
|
||||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||||
|
||||
const manifestPath = flag('--manifest');
|
||||
if (!manifestPath) {
|
||||
writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
|
||||
process.exitCode = 2;
|
||||
return { ok: false, reason: 'usage', entry: null };
|
||||
}
|
||||
|
||||
const resolved = path.resolve(cwd, manifestPath);
|
||||
const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8'));
|
||||
let manifestRaw: string;
|
||||
try {
|
||||
manifestRaw = readFile(resolved);
|
||||
} catch (err) {
|
||||
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
|
||||
writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
|
||||
}
|
||||
|
||||
const plan = planWorktreeRecordAgent(manifestRaw, {
|
||||
agentId: flag('--agent-id'),
|
||||
worktreePath: flag('--path'),
|
||||
branch: flag('--branch'),
|
||||
base: flag('--base'),
|
||||
});
|
||||
|
||||
if (!plan.ok || plan.manifest === null) {
|
||||
writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
|
||||
}
|
||||
|
||||
const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8'));
|
||||
writeFile(resolved, plan.manifest);
|
||||
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
|
||||
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
||||
* branch tip is fully merged into the default branch, and whose lock file
|
||||
@@ -1167,6 +1402,8 @@ export = {
|
||||
planWorktreeWaveCleanup,
|
||||
executeWorktreeWaveCleanupPlan,
|
||||
cmdWorktreeCleanupWave,
|
||||
planWorktreeRecordAgent,
|
||||
cmdWorktreeRecordAgent,
|
||||
reapOrphanWorktrees,
|
||||
cmdWorktreeReapOrphans,
|
||||
resolveWorktreeRoot,
|
||||
|
||||
Reference in New Issue
Block a user