Merge branch 'next' into codex/gsd-onboard

This commit is contained in:
Tom Boucher
2026-07-06 22:43:32 -04:00
committed by GitHub
149 changed files with 6229 additions and 983 deletions

View File

@@ -46,7 +46,7 @@ const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfilesMod = require('./install-profiles.cjs');
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import surfaceMod = require('./surface.cjs');
@@ -384,22 +384,84 @@ function _loadInstalledSkillsManifest(configDir: string): Map<string, string[]>
return manifest;
}
/**
* #1858 — Build a skill dependency manifest from a FLAT commands/gsd-<stem>.md
* source layout (the Claude local project install shape, where the `gsd-`
* prefix is baked into each filename at the commands/ level and there is no
* commands/gsd/ subdir). Strips the `gsd-` prefix so stems match the nested
* loader's output (gsd-validate-phase.md → validate-phase, same as nested
* validate-phase.md).
*
* Map shape is identical to loadSkillsManifest: each stem maps to its
* `requires` deps (parsed via the same shared parseRequires) and carries a
* companion `_calls_agents_<stem>` key (parsed via parseCallsAgents) so the
* flat and nested paths cannot drift.
*
* Returns an empty Map when the parent directory does not exist or contains
* no gsd-*.md files (so _resolveManifest can use size>0 as the "flat layout
* present" signal and fall through to the installed-skills branch otherwise).
*/
function _loadFlatCommandsGsdManifest(commandsParentDir: string): Map<string, string[]> {
const manifest = new Map<string, string[]>();
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(commandsParentDir, { withFileTypes: true });
} catch {
return manifest;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('gsd-')) continue;
if (!entry.name.endsWith('.md')) continue;
// Strip 'gsd-' prefix (4 chars) and '.md' suffix (3 chars) → stem.
const stem = entry.name.slice(4, -3);
if (!stem) continue;
// Mirror loadSkillsManifest's try/catch structure exactly: wrap read +
// parse + set together so an unreadable file OR a thrown parser degrades
// both keys to [] (parity; closes the latent catch-scope drift a reviewer
// flagged — both parsers are non-throwing today, but the structural
// match future-proofs the "identical Map shape" contract).
try {
const content = fs.readFileSync(path.join(commandsParentDir, entry.name), 'utf8');
manifest.set(stem, parseRequires(content));
manifest.set(`_calls_agents_${stem}`, parseCallsAgents(content));
} catch {
manifest.set(stem, []);
manifest.set(`_calls_agents_${stem}`, []);
}
}
return manifest;
}
/**
* Resolve the skill dependency manifest for capability-state resolution.
*
* Resolution order (fixes #1160 — installed-runtime capability surface):
* 1. If commandsGsdDir exists, load from source (repo-checkout behavior).
* 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
* Resolution order:
* 1. If commandsGsdDir exists, load from the nested source layout
* (repo-checkout behavior: <repo>/commands/gsd/*.md).
* 2. #1858 — otherwise, if the flat source layout is present (gsd-<stem>.md
* files in dirname(commandsGsdDir)), load from there. This is the Claude
* local project install shape where commands/gsd/ does not exist but
* commands/gsd-<stem>.md files do.
* 3. #1160 — otherwise, fall back to installed skills at
* configDir/skills/gsd-[stem]/SKILL.md.
*
* In an installed runtime the commands/gsd source tree is absent; only the
* skills/ layout exists. Returning an empty manifest caused resolveSurface to
* materialise the full-sentinel to an empty Set, making every capability appear
* unsurfaced even when the skill was physically installed.
* In an installed runtime both source trees are absent; only the skills/
* layout exists. Returning an empty manifest caused resolveSurface to
* materialise the full-sentinel to an empty Set, making every skill-bearing
* capability appear unsurfaced even when the skill was physically installed
* (#1160) or authored as a flat command file (#1858).
*/
function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string, string[]> {
if (fs.existsSync(commandsGsdDir)) {
return loadSkillsManifest(commandsGsdDir);
}
// #1858: flat source layout — gsd-<stem>.md files at dirname(commandsGsdDir).
// Only claim the flat branch when it actually has gsd-*.md files; otherwise
// fall through to the installed-skills branch (a commands/ dir with no gsd
// files must not shadow an installed skills/ tree).
const flat = _loadFlatCommandsGsdManifest(path.dirname(commandsGsdDir));
if (flat.size > 0) return flat;
return _loadInstalledSkillsManifest(configDir);
}
@@ -619,6 +681,7 @@ export = {
// Exported for tests
_resolveCommandsGsdDir,
_loadInstalledSkillsManifest,
_loadFlatCommandsGsdManifest,
_resolveManifest,
_isSafePropKey,
};

View File

@@ -92,7 +92,7 @@ interface DesiredCapability {
}
interface SetCapabilityStateOptions {
materialize?: { runtime: string; scope: string };
materialize?: { runtime: string; scope: string; resolveAttribution?: (runtime: string) => string | null | undefined };
}
/**
@@ -352,8 +352,18 @@ function setCapabilityState(
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
// #1575: applySurface now accepts opts.resolveAttribution so surface-path
// agents get the same Co-Authored-By trailer as the install path. The
// resolver is not threaded here yet — the CLI command handler does not have
// access to getCommitAttribution (which lives in bin/install.js). Until that
// is refactored into a shared module, surface-path agents for descriptor-
// driven runtimes will lack the Co-Authored-By trailer that install adds.
// Parity is proven when resolveAttribution IS provided (see
// tests/issue-1575-agent-descriptor-parity.test.cjs).
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
applySurface(resolvedConfigDir, layout, manifest, undefined, registry);
applySurface(resolvedConfigDir, layout, manifest, undefined, registry, opts?.materialize?.resolveAttribution
? { resolveAttribution: opts.materialize.resolveAttribution }
: undefined);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Fix C: materialise was explicitly requested — a failure is an error (non-zero exit),

View File

@@ -0,0 +1,159 @@
/**
* Claude orchestration command router — CLI dispatcher for
* `gsd-tools claude-orchestration <subcommand>`.
*
* #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module.
* Lets execute-phase (or any orchestrator) invoke the Workflow-backend
* detection and the Workflow-script emitter through the standard capability
* command surface (ADR-959) instead of a bare `require()`.
*
* Router signature: { args, cwd, raw, error } — identical to the other host
* routers; discovered by dispatchCapabilityCommand via the registry's
* commandFamilies index.
*
* Subcommands:
* detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
* Resolves whether the Workflow backend should activate. `--runtime`
* defaults to the GSD_RUNTIME env var (or 'unknown'). Reads the
* `claude_orchestration.*` keys from .planning/config.json. Emits
* { available, backend, reason }.
*
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
* Reads a wave/plan manifest JSON file and emits the generated Workflow
* script + summary. The manifest shape matches emitWorkflowScript's input:
* { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./claude-orchestration.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoader = require('./config-loader.cjs');
const { output } = io;
const { detectWorkflowBackend, emitWorkflowScript } = core;
const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
interface RouterOpts {
args: string[];
cwd: string;
raw: boolean;
error: (msg: string, reason?: string) => void;
}
function usage(error: (msg: string, reason?: string) => void): void {
error(
'Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]',
);
}
function argValue(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
}
/**
* Detect whether the Workflow backend should activate for the current/given
* runtime. Reads `claude_orchestration.*` from the project config; runtime and
* SDK version come from flags (the orchestrator already knows these) or env.
*/
function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void {
const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
const agentSdkVersion = argValue(args, '--agent-sdk-version');
const noNested = args.includes('--no-nested-dispatch');
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
// Resolve the claude_orchestration.* slice from the project config (federated
// keys are merged by loadConfig as a nested object). A config read failure
// degrades to inline — it must not break the core loop.
let claudeSlice: Record<string, unknown> = {};
try {
const loaded = configLoader.loadConfig(cwd);
const slice = loaded['claude_orchestration'];
if (slice && typeof slice === 'object' && !Array.isArray(slice)) {
claudeSlice = slice as Record<string, unknown>;
}
} catch {
claudeSlice = {};
}
// Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
const flatConfig: Record<string, unknown> = {};
for (const k of Object.keys(claudeSlice)) {
flatConfig['claude_orchestration.' + k] = claudeSlice[k];
}
const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
output(result, raw);
}
/**
* Emit a Workflow script from a wave/plan manifest file.
*/
function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg: string, reason?: string) => void): void {
const wavesPath = argValue(args, '--waves');
const runId = argValue(args, '--run-id');
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
const budgetRaw = argValue(args, '--budget');
if (!wavesPath) {
error('emit-workflow requires --waves <path>');
return;
}
if (!runId) {
error('emit-workflow requires --run-id <id>');
return;
}
let waves: unknown;
try {
const content = fs.readFileSync(path.resolve(wavesPath), 'utf8');
const parsed = JSON.parse(content) as Record<string, unknown>;
waves = parsed['waves'];
} catch (e) {
error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
return;
}
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
const result = emitWorkflowScript({
phaseDir,
runId,
waves: waves as EmitInput['waves'],
budgetTokens: budget,
});
if (!result.ok) {
error('emit-workflow: ' + result.reason);
return;
}
output({ script: result.script, summary: result.summary }, raw);
}
// Re-declared minimal input type for the cast above (avoids importing private types).
interface EmitInput {
waves: Array<{ id: string; plans: Array<{ id: string; brief: string; files_modified: string[] }> }>;
}
function routeClaudeOrchestrationCommand(opts: RouterOpts): void {
const { args, cwd, raw, error } = opts;
// args[0] is the family ('claude-orchestration'); the subcommand is args[1].
const subcommand = args[1];
if (subcommand === 'detect-backend') {
cmdDetectBackend(args, cwd, raw);
} else if (subcommand === 'emit-workflow') {
cmdEmitWorkflow(args, cwd, raw, error);
} else {
usage(error);
}
}
export = { routeClaudeOrchestrationCommand };

View File

@@ -0,0 +1,485 @@
/**
* Claude Orchestration Capability — Workflow-tool backend detection + emitter
*
* #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`)
* as an optional, runtime-gated parallel-execution backend for the GSD loop.
*
* This module is the pure, testable core of the capability. It owns two seams:
*
* detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion })
* → { available: boolean, backend: 'workflow'|'inline', reason: string }
* Fail-closed: every miss degrades to `inline` (today's behaviour), so the
* core loop is byte-identical unless every gate opens. This is criteria 3 + 6.
*
* emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? })
* → { ok:true, script, summary } | { ok:false, reason }
* Maps GSD's wave/plan model 1:1 onto Workflow primitives:
* wave → sequential `parallel()` stage barriers,
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`,
* files_modified overlap → forces plans into separate sequential stages
* (the same overlap rule execute-phase already applies inline),
* resumeFromRunId → wired to the phase run id,
* budgetTokens → a shared token pool.
* The emitted script composes the SAME gsd-executor agent and worktree
* isolation the inline path uses, so it produces the same artifacts/commits
* (criterion 2). It is a generated string consumed by the orchestrator; this
* module never invokes the Workflow tool itself.
*
* Design laws:
* - Gall's Law: ship a small working slice that composes existing primitives
* (gsd-executor + worktree isolation) rather than reinventing them.
* - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's
* barrier/pipeline/budget/resume semantics instead of hand-rolling them.
* - Postel's Law: liberal in input (missing fields → inline), conservative in
* output (workflow only when every gate opens).
* - Fail-closed: an unknown version, a missing descriptor, or a disabled
* toggle all resolve to `inline`, never to `workflow`.
*
* Zero external dependencies. Pure functions. Never throws on bad input.
*/
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* The Agent SDK version that introduced the Workflow tool (#1143 prior art).
* Used as the default floor when config does not override it. A runtime reporting
* an agentSdkVersion below this cannot host the Workflow backend.
*/
const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149';
/** Closed enum for the `claude_orchestration.execution_backend` config key. */
const BACKEND_VALUES = new Set<string>(['auto', 'workflow', 'inline']);
/** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */
const WORKFLOW_RUNTIME = 'claude';
// ─── Semver helpers ───────────────────────────────────────────────────────────
/** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
/** True for a syntactically valid semver string. */
function isValidSemver(s: unknown): s is string {
return typeof s === 'string' && SEMVER_RE.test(s);
}
/**
* Compare two semver strings.
* Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed:
* an unparseable version is treated as "less than" any real floor, so detection
* never accidentally enables the preview backend on an unknown SDK).
*
* Pre-release/build metadata are ignored for the comparison — only the numeric
* major.minor.patch triple participates, matching how the Workflow-tool floor is
* specified (a plain "0.3.149").
*/
function compareSemver(a: string, b: string): number {
if (!isValidSemver(a) || !isValidSemver(b)) return -1;
// Split numeric triple from pre-release/build metadata.
const parseTriple = (s: string): number[] => {
const core = s.split('-')[0].split('+')[0].split('.');
return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)];
};
const hasPre = (s: string): boolean => s.indexOf('-') !== -1;
const preIdentifiers = (s: string): string[] => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0);
const am = parseTriple(a);
const bm = parseTriple(b);
for (let i = 0; i < 3; i++) {
if (am[i] < bm[i]) return -1;
if (am[i] > bm[i]) return 1;
}
// Numeric triple is equal. SemVer 2.0.0 §11 precedence:
// - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one
// (keeps the floor fail-closed for pre-release builds of the GA floor);
// - two pre-releases of the same triple are ordered by their dot-separated
// identifiers (numeric < alphanumeric; numeric compared numerically,
// alphanumeric lexically; fewer identifiers < more).
const aPre = hasPre(a);
const bPre = hasPre(b);
if (aPre && !bPre) return -1;
if (!aPre && bPre) return 1;
if (aPre && bPre) {
const ai = preIdentifiers(a);
const bi = preIdentifiers(b);
const len = Math.min(ai.length, bi.length);
for (let i = 0; i < len; i++) {
const ax = ai[i];
const bx = bi[i];
const aNum = /^\d+$/.test(ax);
const bNum = /^\d+$/.test(bx);
if (aNum && bNum) {
const an = parseInt(ax, 10);
const bn = parseInt(bx, 10);
if (an < bn) return -1;
if (an > bn) return 1;
} else if (aNum && !bNum) {
return -1; // numeric identifiers always lower than alphanumeric
} else if (!aNum && bNum) {
return 1;
} else {
if (ax < bx) return -1;
if (ax > bx) return 1;
}
}
if (ai.length < bi.length) return -1;
if (ai.length > bi.length) return 1;
}
return 0;
}
// ─── detectWorkflowBackend ────────────────────────────────────────────────────
interface HostIntegration {
dispatch?: {
nested?: boolean;
background?: boolean;
backgroundDispatch?: boolean;
[k: string]: unknown;
};
[k: string]: unknown;
}
interface BackendConfig {
'claude_orchestration.enabled'?: unknown;
'claude_orchestration.execution_backend'?: unknown;
'claude_orchestration.min_agent_sdk_version'?: unknown;
[k: string]: unknown;
}
interface DetectInput {
runtimeId?: string;
hostIntegration?: HostIntegration | null;
config?: BackendConfig | null;
agentSdkVersion?: string;
}
interface DetectResult {
available: boolean;
backend: 'workflow' | 'inline';
reason: string;
}
/** Inline result shorthand. */
function inline(reason: string, available = false): DetectResult {
return { available, backend: 'inline', reason };
}
/**
* Resolve whether the Workflow-tool backend should activate.
*
* Gate ladder (all must pass for `workflow`; first miss wins, fail-closed):
* 1. capability enabled (claude_orchestration.enabled truthy)
* 2. runtime is Claude (the only runtime that exposes the Workflow tool)
* 3. execution_backend !== 'inline'
* 4. host descriptor signals nested+background dispatch (Workflow-tool capable)
* 5. agentSdkVersion is a known, valid semver
* 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION)
* 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3)
*
* Never throws. Destructures defensively.
*/
function detectWorkflowBackend(input: DetectInput | null | undefined): DetectResult {
if (input === null || input === undefined || typeof input !== 'object') {
return inline('capability_disabled');
}
const cfg: BackendConfig =
(input.config !== null && input.config !== undefined && typeof input.config === 'object')
? input.config
: {};
// 1. capability must be opted in (default-off — ships disabled).
if (!cfg['claude_orchestration.enabled']) {
return inline('capability_disabled');
}
// 2. only Claude can host the Workflow tool.
if (input.runtimeId !== WORKFLOW_RUNTIME) {
return inline('runtime_not_claude');
}
// 3. explicit inline opt-out short-circuits.
let backendRaw = cfg['claude_orchestration.execution_backend'];
if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) {
backendRaw = 'auto';
}
if (backendRaw === 'inline') {
return inline('backend_inline');
}
// 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape
// (a proxy for Workflow-tool presence). This is Claude-specific and already
// gated at step 2; `background:true` alone is true on several non-Claude hosts,
// so the proxy is only meaningful after the runtime check above. Note: this is
// NOT the canonical `shouldFlattenDispatch` rule (which keys on
// `backgroundDispatch`); the Workflow backend works precisely because a single
// tool-call orchestrates internally, sidestepping the backgroundDispatch:false
// limitation. Missing/false/foreign descriptor → fail-closed.
const hi = input.hostIntegration;
if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) {
return inline('workflow_tool_unavailable');
}
const dispatch = (hi as { dispatch?: Record<string, unknown> }).dispatch;
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
return inline('workflow_tool_unavailable');
}
const nested = dispatch['nested'];
const background = dispatch['background'];
if (nested !== true || background !== true) {
return inline('workflow_tool_unavailable');
}
// 5. an unknown agentSdkVersion cannot be trusted to meet the floor.
if (!isValidSemver(input.agentSdkVersion)) {
return inline('agent_sdk_version_unknown');
}
// 6. version floor (config override > default constant).
const floorRaw = cfg['claude_orchestration.min_agent_sdk_version'];
const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION;
if (compareSemver(input.agentSdkVersion, floor) < 0) {
return inline('agent_sdk_version_below_floor');
}
// 7. auto/workflow both reach the workflow backend once every gate passes.
return { available: true, backend: 'workflow', reason: 'workflow_backend_active' };
}
// ─── emitWorkflowScript ───────────────────────────────────────────────────────
interface Plan {
id: string;
brief: string;
files_modified: string[];
}
interface Wave {
id: string;
plans: Plan[];
}
interface EmitInput {
phaseDir: string;
waves: Wave[];
runId: string;
budgetTokens?: number;
}
interface EmitOk {
ok: true;
script: string;
summary: {
waves: number;
plans: number;
stagesByWave: string[][][]; // wave → stage → planId[]
resumeRunId: string;
budgetTokens: number | null;
};
}
interface EmitErr {
ok: false;
reason: string;
}
/**
* Partition a wave's plans into a near-minimal number of sequential stages (via
* greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
* correct: no two plans sharing a file ever cohabit a stage) such that no two
* plans in the same stage share a modified file. Each plan goes into the earliest
* stage where it does not overlap any plan already there.
*
* A plan with an EMPTY files_modified set declares no files; it overlaps nothing
* and coalesces into stage 0 (same behavior as the inline path, which also cannot
* guard against undeclared concurrent writes — declare filesModified accurately).
*
* This is the same overlap rule execute-phase applies inline — the only difference
* is the execution vehicle (Workflow `parallel()` vs one-agent-per-message).
*/
function partitionStages(plans: Plan[]): string[][] {
const stages: { plans: Plan[]; files: Set<string> }[] = [];
for (const plan of plans) {
const fileSet = new Set(plan.files_modified);
let placed = false;
for (const stage of stages) {
let overlap = false;
for (const f of fileSet) {
if (stage.files.has(f)) { overlap = true; break; }
}
if (!overlap) {
stage.plans.push(plan);
for (const f of fileSet) stage.files.add(f);
placed = true;
break;
}
}
if (!placed) {
stages.push({ plans: [plan], files: new Set(fileSet) });
}
}
return stages.map((s) => s.plans.map((p) => p.id));
}
/**
* Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted
* string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote,
* newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language
* itself — there is no hand-rolled escape table to drift. Returns the value already
* wrapped in its surrounding quotes.
*/
function quoteString(s: string): string {
return JSON.stringify(s);
}
/**
* True if `s` is a safe identifier/path token to interpolate into the generated
* script WITHOUT requiring a string-literal context — i.e. it contains no
* character that could terminate a comment line (`\n`/`\r`), break out of a
* string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for
* `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and
* must never legitimately contain such characters. Rejecting them at validation
* (rather than silently flattening) keeps the emitted script faithful to input.
*/
const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/;
function isScriptableIdentifier(s: unknown): boolean {
if (typeof s !== 'string' || s.length === 0) return false;
return !UNSCRIPTABLE_CHAR_RE.test(s);
}
/**
* Emit a Workflow script mapping the phase's wave/plan model onto Workflow
* primitives. Pure and deterministic: identical input yields an identical string.
*
* Returns ok:false (never throws) on invalid input — empty waves, missing runId,
* a wave with no plans, etc.
*/
function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitErr {
if (input === null || input === undefined || typeof input !== 'object') {
return { ok: false, reason: 'invalid_input' };
}
const { phaseDir, waves, runId } = input;
// Identifiers/paths interpolated into the generated script must be free of any
// character that could terminate a comment, break out of a string literal, or
// smuggle control bytes — reject up front (security: #1143 review Finding 1).
if (!isScriptableIdentifier(phaseDir)) {
return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' };
}
if (!isScriptableIdentifier(runId)) {
return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
}
if (!Array.isArray(waves) || waves.length === 0) {
return { ok: false, reason: 'waves must be a non-empty array' };
}
for (let i = 0; i < waves.length; i++) {
const w = waves[i];
if (w === null || typeof w !== 'object' || typeof w.id !== 'string') {
return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' };
}
if (!isScriptableIdentifier(w.id)) {
return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' };
}
if (!Array.isArray(w.plans) || w.plans.length === 0) {
return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' };
}
const seenIds = new Set<string>();
for (let j = 0; j < w.plans.length; j++) {
const p = w.plans[j];
if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' };
}
if (!isScriptableIdentifier(p.id)) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
}
if (seenIds.has(p.id)) {
return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
}
seenIds.add(p.id);
for (const f of p.files_modified) {
if (typeof f !== 'string' || f.length === 0) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' };
}
}
}
}
const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0)
? Math.floor(input.budgetTokens)
: null;
const lines: string[] = [];
lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
lines.push('// phase: ' + phaseDir);
lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,');
lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.');
lines.push('resumeFromRunId(' + quoteString(runId) + ')');
if (budgetTokens !== null) {
lines.push('budget(' + budgetTokens + ')');
}
lines.push('');
const stagesByWave: string[][][] = [];
let totalPlans = 0;
for (let wi = 0; wi < waves.length; wi++) {
const wave = waves[wi];
const stages = partitionStages(wave.plans);
stagesByWave.push(stages);
totalPlans += wave.plans.length;
lines.push('// Wave ' + wave.id);
for (let si = 0; si < stages.length; si++) {
const stagePlanIds = stages[si];
// Resolve back to plan objects for briefs (ids are unique within a wave — validated above).
const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id) as Plan);
if (stages.length > 1) {
lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : ''));
}
if (stagePlans.length === 1) {
const p = stagePlans[0];
lines.push('parallel(');
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })');
lines.push(')');
} else {
lines.push('parallel(');
for (const p of stagePlans) {
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),');
}
// Replace trailing comma on the last agent line with nothing.
const lastIdx = lines.length - 1;
lines[lastIdx] = lines[lastIdx].replace(/,$/, '');
lines.push(')');
}
}
if (wi < waves.length - 1) lines.push('');
}
lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there');
lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.');
const script = lines.join('\n');
return {
ok: true,
script,
summary: {
waves: waves.length,
plans: totalPlans,
stagesByWave,
resumeRunId: runId,
budgetTokens,
},
};
}
// ─── Exports ──────────────────────────────────────────────────────────────────
export = {
detectWorkflowBackend,
emitWorkflowScript,
compareSemver,
isValidSemver,
WORKFLOW_TOOL_FLOOR_VERSION,
BACKEND_VALUES,
WORKFLOW_RUNTIME,
};

View File

@@ -244,7 +244,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string
* - agents: write as-is (files already carry their own `gsd-` prefix).
* For kimi-agents kind: recursively copy generated YAML/prompt files.
*/
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string): void {
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string, runtime?: string): void {
// Defense-in-depth: verify destDir is within the install root even if the
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
// the actual write site against any future call-site drift.
@@ -302,8 +302,11 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
let destName: string;
if (kind.kind === 'agents') {
// Agent files already carry the gsd- prefix in the source dir
destName = entry.name;
// Agent files already carry the gsd- prefix in the source dir.
// #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
destName = runtime === 'copilot'
? entry.name.replace(/\.md$/, '.agent.md')
: entry.name;
} else if (namespacedByDir) {
// Directory is the namespace; don't double-prefix the filename
destName = entry.name;
@@ -619,7 +622,7 @@ function installRuntimeArtifacts(
}
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
// Restore user-owned dirs after the prune+copy
for (const [dirName, snap] of toPreserve) {
@@ -629,7 +632,7 @@ function installRuntimeArtifacts(
// For non-skills kinds (commands, agents): no user content to preserve;
// just prune stale gsd-* entries and copy new ones.
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
}
}
} finally {

View File

@@ -876,6 +876,7 @@ export = {
writeActiveProfile,
// Shared internals
parseRequires,
parseCallsAgents,
cleanupStagedSkills,
// Back-compat / deprecated
MINIMAL_SKILL_ALLOWLIST,

View File

@@ -105,6 +105,66 @@ function _resetModelPolicyWarningCacheForTests(): void {
_modelPolicyUnmappableWarned.clear();
}
// Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041).
const _modelOverrideUnmappableWarned = new Set<string>();
function warnModelOverrideUnmappable(agentType: string, overrideValue: string): void {
const key = `${agentType}::${overrideValue}`;
if (_modelOverrideUnmappableWarned.has(key)) return;
_modelOverrideUnmappableWarned.add(key);
// Cap emission length so an oversized or secret-shaped value cannot leak in
// full to stderr/logs (#2041 security review). MUST go to stderr — resolve-
// model's JSON result is parsed from stdout.
const safe = overrideValue.length > 64 ? overrideValue.slice(0, 64) + '…' : overrideValue;
process.stderr.write(
`gsd: warning — model_overrides value "${safe}" for ${agentType} ` +
`has no Claude agent alias; falling through to tier resolution.\n`,
);
}
// Test-only: reset the model_overrides warn-dedupe cache between cases (#2041).
function _resetModelOverrideWarningCacheForTests(): void {
_modelOverrideUnmappableWarned.clear();
}
/**
* #2041 — Map a `model_overrides` value to its Claude Agent-tool alias on the
* claude runtime, mirroring the `model_policy` path (#1144). Claude Code's
* Agent tool `model` parameter documents only tier aliases (opus/sonnet/haiku/
* fable); a full Claude model ID returned verbatim is silently dropped by the
* spawner. Returns the value to return verbatim, or null to signal "fall
* through to normal tier/dynamic-routing resolution" (used when a Claude full
* ID has no alias — matches model_policy's warn-and-fall-through). Non-Claude
* runtimes and non-Claude values always pass through verbatim.
*
* Hardening (code+security review): a `typeof` guard preserves the pre-fix
* no-crash behavior if a malformed config surfaces a non-string value, and an
* `Object.hasOwn` lookup defeats `__proto__`/`constructor` lookups on the plain
* object literal so those reserved keys cannot return a truthy non-string.
*/
function mapClaudeOverrideForRuntime(
override: string,
configRuntime: string | null | undefined,
agentType: string,
): string | null {
// Defensive: model_overrides is typed Record<string,string> but a malformed
// config could surface a non-string; pass through verbatim (preserving the
// pre-fix no-crash behaviour) and let the downstream Agent tool reject it.
if (typeof override !== 'string') return override;
const onClaude = !configRuntime || configRuntime === 'claude';
if (!onClaude) return override;
// Object.hasOwn guards against __proto__/constructor returning a truthy
// non-string from the plain object literal (#2041 security review).
if (Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, override)) {
return CLAUDE_POLICY_ID_TO_ALIAS[override];
}
if (CLAUDE_AGENT_ALIASES.has(override)) return override;
if (override.startsWith('claude-')) {
warnModelOverrideUnmappable(agentType, override);
return null;
}
return override;
}
/**
* #49 — Provider-neutral model policy preset resolution.
*/
@@ -159,11 +219,15 @@ function resolveModelPolicy(policy: Record<string, unknown> | null | undefined,
function resolveModelInternal(cwd: string, agentType: string): string {
const config = loadConfig(cwd);
// 1. Per-agent override
// 1. Per-agent override (#2041: map Claude full IDs → Agent-tool aliases on
// the claude runtime, mirroring the model_policy path #1144; non-Claude
// runtimes and non-Claude values pass through verbatim).
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
if (override) {
return override;
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
// Unmappable Claude ID — fall through to tier resolution (matches model_policy).
}
// 2. Compute the tier
@@ -287,7 +351,11 @@ function resolveModelForTier(cwd: string, agentType: string, attempt?: number):
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
if (override) return override;
if (override) {
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
// Unmappable Claude ID — fall through to dynamic_routing / model_policy resolution.
}
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
return resolveModelInternal(cwd, agentType);
@@ -508,6 +576,7 @@ export = {
resolveModelPolicy,
resolveModelInternal,
_resetModelPolicyWarningCacheForTests,
_resetModelOverrideWarningCacheForTests,
VALID_GRANULARITIES,
resolveGranularityInternal,
assertValidGranularityOverride,

View File

@@ -1460,7 +1460,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im',
);
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
// Scope the Progress-row search to the ## Progress section so the regex
// doesn't bind to an earlier table (e.g. | Phase | Requirements | Count |)
// whose rows also start with the phase number. (#2012)
const updateProgressRow = (fullRow: string): string => {
const cells = fullRow.split('|').slice(1, -1);
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
if (cells.length === 5) {
@@ -1477,7 +1480,15 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
}
return '|' + cells.join('|') + '|';
});
};
const progressIdx = roadmapContent.indexOf('## Progress');
if (progressIdx >= 0) {
const beforeProgress = roadmapContent.slice(0, progressIdx);
const progressSection = roadmapContent.slice(progressIdx);
roadmapContent = beforeProgress + progressSection.replace(tableRowPattern, updateProgressRow);
} else {
roadmapContent = roadmapContent.replace(tableRowPattern, updateProgressRow);
}
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,

View File

@@ -34,6 +34,9 @@ const { countMatchedSummaries } = coreUtils;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verificationMod = require('./verification.cjs');
const { readVerificationStatus } = verificationMod;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -504,7 +507,13 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
return;
}
const isComplete = summaryCount >= planCount;
// Verification gate (#2022): do NOT check the phase checkbox or stamp a
// completion date until the phase's verification status is 'passed', matching
// cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
// moment the last plan summary landed — before gsd-verifier had verified.
const phaseDir = path.join(cwd, phaseInfo!.directory);
const verificationPassed = readVerificationStatus(phaseDir).status === 'passed';
const isComplete = summaryCount >= planCount && verificationPassed;
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
const today = realClock.today();

View File

@@ -196,6 +196,7 @@ const RUNTIME_LABELS: Readonly<Record<string, string>> = {
kimi: 'Kimi CLI',
codebuddy: 'CodeBuddy',
cline: 'Cline',
zcode: 'ZCode',
};
/**
@@ -243,6 +244,7 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly<Record<string, string>> = {
codebuddy: "'.codebuddy'",
cline: "'.cline'",
kimi: "'.config', 'agents'",
zcode: "'.zcode'",
};
/**
@@ -266,7 +268,7 @@ export function getGlobalConfigHomeFragment(runtime: string): string {
*/
const RUNTIME_FLAG_IDS = Object.freeze([
'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode',
] as const);
/**

View File

@@ -29,6 +29,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -55,11 +56,17 @@ const SURFACE_FILE_NAME = '.gsd-surface.json';
// Types
// ---------------------------------------------------------------------------
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
interface ArtifactKind {
kind: string;
destSubpath: string;
prefix: string;
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }) => string;
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }, agentCtx?: AgentCtx) => string;
}
interface Layout {
@@ -69,6 +76,12 @@ interface Layout {
kinds: ArtifactKind[];
}
interface ApplySurfaceOptions {
resolveAttribution?: (runtime: string) => string | null | undefined;
homedir?: () => string;
platform?: string;
}
// ---------------------------------------------------------------------------
// State IO
// ---------------------------------------------------------------------------
@@ -301,32 +314,54 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
* Re-stage the active surface using the resolved layout.
* Iterates layout.kinds and syncs each artifact kind to its destination.
*/
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }, opts?: ApplySurfaceOptions): { name: string; skills: Set<string>; agents: Set<string> } {
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
}
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
// so SKILL.md bodies reference the install target (pathPrefix), not the
// converter's default ~/.claude paths (#813). Delegated to the conversion
// module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
// needed here (proven: Co-Authored-By never appears in staged content; see
// brief PROVEN KEY FACT). No getInstallExports() call required.
// #1615 adversarial review (PR #1622): commands kind was previously skipped,
// leaving raw @~/.claude/... references in Windsurf workflow bodies after a
// /gsd-surface profile change. Same gap affected any runtime with commands
// kinds (windsurf, opencode, kilo, cursor, augment, codebuddy, gemini).
//
// Asymmetry note: rewriteStagedSkillBodies mutates in place (returns void),
// but rewriteStagedCommandBodies copies to a fresh mkdtemp dir and returns
// its path (commands .md files are flat; mutating the staged source would
// corrupt the package source on full-profile runs). Caller MUST sync from
// the returned dir and clean it up.
// #1575: agents kind now mirrors createRuntimeArtifactInstallPlan — build
// agentCtx (pathPrefix + attribution) and pass it to kind.stage() so
// stageAgentsForRuntimeWithConverter applies the full inline-loop pipeline
// (pathRewrites -> attribution -> converter -> normalize). Without this,
// surface-path agents lack path-prefix rewrites and Co-Authored-By trailers,
// diverging from a fresh install.
const _homedirFn: () => string = opts?.homedir ?? (() => os.homedir());
const _resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const _homeDir = _homedirFn().replace(/\\/g, '/');
const _isGlobal = (layout.scope ?? 'global') === 'global';
const _isOpencode = layout.runtime === 'opencode';
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir });
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution };
const tempDirsToClean: string[] = [];
// #1575: When the surface has no state modifications AND the base profile is
// 'full', pass the '*' sentinel for agents staging so ALL agents are staged —
// matching the install path which uses { skills: '*' }. Without this, agents
// not referenced by any skill's _calls_agents_ manifest entry would be silently
// dropped from the surface path. For tiered profiles (core/standard) or when
// surface mods exist, pass the resolved set so only the filtered subset stages.
const _surfaceState = readSurface(layout.configDir);
const _baseProfileName = (_surfaceState && _surfaceState.baseProfile)
? _surfaceState.baseProfile
: (readActiveProfile(layout.configDir) || 'full');
const _hasSurfaceMods = !!_surfaceState && (
_surfaceState.disabledClusters.length > 0 ||
_surfaceState.explicitAdds.length > 0 ||
_surfaceState.explicitRemoves.length > 0
);
const _isUnmodifiedFull = _baseProfileName === 'full' && !_hasSurfaceMods;
try {
for (const kind of layout.kinds) {
let staged: string = kind.stage(resolved);
let staged: string;
if (kind.kind === 'agents') {
const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' as const } : resolved;
staged = kind.stage(agentProfile, agentCtx);
} else {
staged = kind.stage(resolved);
}
if (kind.kind === 'skills') {
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
@@ -345,7 +380,7 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
}
}
const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, skillManifest);
_syncGsdDir(staged, dest, kind, skillManifest, layout.runtime);
}
} finally {
for (const dir of tempDirsToClean) {
@@ -451,7 +486,7 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
* user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND
* not in staged set. User-owned (not in manifest) are always preserved.
*/
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>): void {
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>, runtime?: string): void {
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
@@ -459,6 +494,11 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
const kindName = (typeof kind === 'string') ? kind : kind.kind;
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
// #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
// the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
// the staged filename verbatim.
const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
if (kindName === 'skills') {
// Skills kind: work with directories, not files.
// Each staged entry is a directory named ${prefix}${stem}.
@@ -497,23 +537,28 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
const stagedDestNames = new Set<string>();
for (const file of stagedFiles) {
const destName = (kindName === 'agents' || namespacedByDir)
? file
: `${kindPrefix}${file.slice(0, -3)}.md`;
const destName = isCopilotAgents
? file.replace(/\.md$/, '.agent.md')
: (kindName === 'agents' || namespacedByDir)
? file
: `${kindPrefix}${file.slice(0, -3)}.md`;
fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, destName));
stagedDestNames.add(destName);
}
// Prune stale GSD-owned files not in the staged set, preserving user-owned files
// (mirrors install's prefix-scoped _removeGsdEntries):
// - agents: only gsd-* are GSD-owned
// - agents: only gsd-* are GSD-owned (copilot: gsd-*.agent.md)
// - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
// - namespaced command dirs: the whole dir is GSD-owned
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
const shouldPruneAgents = !(kindName === 'agents' && (!manifest || manifest.size === 0));
if (shouldPruneAgents) {
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
}
}
}
}