@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -9,6 +9,30 @@
9
9
  // In .cts (CommonJS output) files, `require` is available as a global.
10
10
  const _require = require;
11
11
  const path = _require('node:path');
12
+ /**
13
+ * Asserts that `destSubpath` resolves to a path inside `configDir`.
14
+ *
15
+ * Rejects any path that escapes the configDir root (e.g. "../../etc") and any
16
+ * path containing a NUL byte. This is a security gate for Phase B of
17
+ * ADR-1239: third-party descriptors must never be able to write outside the
18
+ * designated config home directory.
19
+ *
20
+ * @param configDir - The root config directory (e.g. ~/.claude).
21
+ * @param destSubpath - The relative path declared by the runtime descriptor.
22
+ * @returns The resolved absolute path under configDir.
23
+ * @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
24
+ */
25
+ function assertDestWithinConfigHome(configDir, destSubpath) {
26
+ if (destSubpath.includes('\0')) {
27
+ throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`);
28
+ }
29
+ const root = path.resolve(configDir);
30
+ const resolved = path.resolve(configDir, destSubpath);
31
+ if (resolved === root || !resolved.startsWith(root + path.sep)) {
32
+ throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`);
33
+ }
34
+ return resolved;
35
+ }
12
36
  function errorMessage(err) {
13
37
  if (err instanceof Error)
14
38
  return err.message;
@@ -36,10 +60,34 @@ function createRuntimeArtifactInstallPlan(args) {
36
60
  platform,
37
61
  resolveAttribution,
38
62
  };
63
+ // ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
64
+ // the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
65
+ // → normalize). This mirrors the exact per-file order in the inline agent loop
66
+ // in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
67
+ // to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
68
+ // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
69
+ const os = _require('node:os');
70
+ const homedirFn = homedir ?? (() => os.homedir());
71
+ const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
72
+ const homeDir = homedirFn().replace(/\\/g, '/');
73
+ const isGlobal = scope === 'global';
74
+ const isOpencode = layout.runtime === 'opencode';
75
+ const isWindowsHost = (platform ?? process.platform) === 'win32';
76
+ const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
77
+ const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
78
+ const agentCtx = { runtime: layout.runtime, pathPrefix, attribution };
39
79
  for (const kind of layout.kinds) {
40
80
  let stagedDir;
41
81
  try {
42
- stagedDir = kind.stage(resolvedProfile);
82
+ if (kind.kind === 'agents') {
83
+ // ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
84
+ // the full inline-loop order: pathRewrites → attribution → converter → normalize.
85
+ // The cross-cutting is now PRE-converter (inside staging), not POST.
86
+ stagedDir = kind.stage(resolvedProfile, agentCtx);
87
+ }
88
+ else {
89
+ stagedDir = kind.stage(resolvedProfile);
90
+ }
43
91
  }
44
92
  catch (err) {
45
93
  return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
@@ -54,6 +102,8 @@ function createRuntimeArtifactInstallPlan(args) {
54
102
  const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
55
103
  sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
56
104
  }
105
+ // agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
106
+ // No POST-step needed. sourceDir stays as stagedDir.
57
107
  }
58
108
  catch (err) {
59
109
  return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
@@ -61,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) {
61
111
  items.push({
62
112
  kind: kind.kind,
63
113
  sourceDir,
64
- destDir: path.join(layout.configDir, kind.destSubpath),
114
+ destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
65
115
  });
66
116
  }
67
117
  return { ok: true, plan: { items, cleanupDirs } };
@@ -70,8 +120,8 @@ function createRuntimeArtifactUninstallPlan(layout) {
70
120
  return {
71
121
  items: layout.kinds.map((kind) => ({
72
122
  kind: kind.kind,
73
- destDir: path.join(layout.configDir, kind.destSubpath),
123
+ destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
74
124
  })),
75
125
  };
76
126
  }
77
- module.exports = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
127
+ module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
@@ -156,13 +156,16 @@ function convertedAgentsKind(destSubpath, prefix, converterName, configDir, scop
156
156
  kind: 'agents',
157
157
  destSubpath,
158
158
  prefix,
159
- stage: (resolved) => {
159
+ stage: (resolved, agentCtx) => {
160
160
  // isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
161
161
  // choose global-home vs workspace-relative paths; converters that only take
162
162
  // (content) ignore the extra positional arg. Mirrors skillsKind's scope
163
163
  // threading (#1173).
164
164
  const converter = conversionExports[converterName];
165
- return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter, scope === 'global');
165
+ // ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
166
+ // for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
167
+ // can apply the full pre-converter + post-converter sequence in the correct order.
168
+ return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter, scope === 'global', agentCtx);
166
169
  },
167
170
  };
168
171
  }
@@ -84,7 +84,7 @@ function atomicWriteFileSync(target, data, options) {
84
84
  __atomicWrittenTmps.add(tmp);
85
85
  try {
86
86
  node_fs_1.default.writeFileSync(tmp, data, options);
87
- node_fs_1.default.renameSync(tmp, target);
87
+ shellCmdProjection.retryRenameSync(tmp, target);
88
88
  // Successful rename: the tmp path no longer exists, but leave it in the
89
89
  // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
90
90
  // lingers (e.g. a rename succeeded but left a stale entry on some FS).
@@ -19,6 +19,10 @@ exports.canonicalizeRuntimeName = canonicalizeRuntimeName;
19
19
  exports.resolveRuntimeNameFromCandidates = resolveRuntimeNameFromCandidates;
20
20
  exports.getProjectInstructionFile = getProjectInstructionFile;
21
21
  exports.getDirName = getDirName;
22
+ exports.getRuntimeLabel = getRuntimeLabel;
23
+ exports.getGlobalConfigHomeFragment = getGlobalConfigHomeFragment;
24
+ exports.runtimeFlags = runtimeFlags;
25
+ exports.getRuntimeNewProjectCommand = getRuntimeNewProjectCommand;
22
26
  const node_fs_1 = __importDefault(require("node:fs"));
23
27
  const node_path_1 = __importDefault(require("node:path"));
24
28
  const FALLBACK_ALIASES = {
@@ -152,35 +156,161 @@ function getProjectInstructionFile(runtime) {
152
156
  * `bin/install.js` re-exports this same function for back-compat.
153
157
  */
154
158
  function getDirName(runtime) {
155
- if (runtime === 'copilot')
156
- return '.github';
157
- if (runtime === 'opencode')
158
- return '.opencode';
159
- if (runtime === 'gemini')
160
- return '.gemini';
161
- if (runtime === 'kilo')
162
- return '.kilo';
163
- if (runtime === 'codex')
164
- return '.codex';
165
- if (runtime === 'antigravity')
166
- return '.agents';
167
- if (runtime === 'cursor')
168
- return '.cursor';
169
- if (runtime === 'windsurf')
170
- return '.windsurf';
171
- if (runtime === 'augment')
172
- return '.augment';
173
- if (runtime === 'trae')
174
- return '.trae';
175
- if (runtime === 'qwen')
176
- return '.qwen';
177
- if (runtime === 'hermes')
178
- return '.hermes';
179
- if (runtime === 'kimi')
180
- return '.kimi-code';
181
- if (runtime === 'codebuddy')
182
- return '.codebuddy';
183
- if (runtime === 'cline')
184
- return '.cline';
159
+ if (!runtime)
160
+ return '.claude';
161
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
162
+ const { runtimes } = require('./capability-registry.cjs');
163
+ const dir = runtimes[runtime]?.runtime?.localConfigDir;
164
+ if (typeof dir === 'string' && dir.length > 0)
165
+ return dir;
185
166
  return '.claude';
186
167
  }
168
+ /**
169
+ * Curated short display labels for the install/uninstall console output, keyed
170
+ * by canonical runtime id. The SINGLE source of truth consumed by both
171
+ * `install()` and `uninstall()` in bin/install.js via `getRuntimeLabel`.
172
+ *
173
+ * Collapses the two duplicated `runtimeLabel` assignment chains that previously
174
+ * lived inline in bin/install.js (ADR-1239 Phase B, #1679) — the add-a-host tax:
175
+ * a new runtime meant remembering to add a label line in BOTH chains, and they
176
+ * had drifted out of sync (uninstall omitted `cline` and used a different
177
+ * `kimi` value than install). This table is the curated canonical resolution:
178
+ * - kimi: install 'Kimi' / uninstall 'Kimi CLI' → 'Kimi CLI' (majority + descriptor title)
179
+ * - cline: install 'Cline' / uninstall (omitted) → 'Cline' (majority + descriptor title)
180
+ *
181
+ * Voice: these are the SHORT UI labels, intentionally distinct from the
182
+ * descriptor `title` (the long product name — e.g. "OpenAI Codex CLI",
183
+ * "GitHub Copilot", "Gemini CLI") which serves documentation/registry display,
184
+ * not the install console. A future slice may relocate this to a
185
+ * `runtime.label` descriptor field; until then this table is the source.
186
+ *
187
+ * Lookup is RAW-ID only (no alias expansion) — callers pass an already-
188
+ * canonicalized runtime id, keeping the label surface explicit. Unknown/empty
189
+ * ids fall back to 'Claude Code' (the always-safe default, fail-closed).
190
+ *
191
+ * The drift-guard test (tests/runtime-label-policy.test.cjs) pins this table's
192
+ * id set to the capability-registry runtime id set, so adding/removing a runtime
193
+ * forces a deliberate update here.
194
+ */
195
+ const RUNTIME_LABELS = {
196
+ claude: 'Claude Code',
197
+ opencode: 'OpenCode',
198
+ gemini: 'Gemini',
199
+ kilo: 'Kilo',
200
+ codex: 'Codex',
201
+ copilot: 'Copilot',
202
+ antigravity: 'Antigravity',
203
+ cursor: 'Cursor',
204
+ windsurf: 'Windsurf',
205
+ augment: 'Augment',
206
+ trae: 'Trae',
207
+ qwen: 'Qwen Code',
208
+ hermes: 'Hermes Agent',
209
+ kimi: 'Kimi CLI',
210
+ codebuddy: 'CodeBuddy',
211
+ cline: 'Cline',
212
+ };
213
+ /**
214
+ * Map a canonical runtime id to its short display label for the
215
+ * install/uninstall console output. Unknown/empty inputs fall back to
216
+ * 'Claude Code'. Sibling to `getDirName`; pure (no I/O).
217
+ */
218
+ function getRuntimeLabel(runtime) {
219
+ if (!runtime)
220
+ return 'Claude Code';
221
+ const label = RUNTIME_LABELS[runtime];
222
+ return typeof label === 'string' && label.length > 0 ? label : 'Claude Code';
223
+ }
224
+ /**
225
+ * Source-string fragments for the runtime → global config-home path, used by
226
+ * `getConfigDirFromHome` in bin/install.js to template `path.join()` calls in
227
+ * generated hook scripts. Each value is a JS-source snippet (embedded quotes /
228
+ * commas are intentional — it is spliced into generated code as path.join args).
229
+ *
230
+ * Collapses the prior 14-branch `if (runtime === 'x') return "'...'"` chain in
231
+ * bin/install.js (ADR-1239 Phase B / #1679, AC2 slice 2) — the add-a-host tax:
232
+ * a new runtime meant remembering to add a branch here. Values are preserved
233
+ * BYTE-FOR-BYTE from the prior chain; golden install parity asserts generated
234
+ * hook output is unchanged across all 16 runtimes.
235
+ *
236
+ * Two runtimes are intentionally absent (handled by the caller, NOT this table):
237
+ * - `claude` → the default; falls through to `DEFAULT_FRAGMENT`.
238
+ * - `antigravity`→ resolved dynamically via resolveAntigravityGlobalDir +
239
+ * path.relative (multi-segment, env-overridable).
240
+ *
241
+ * Unknown/empty ids fall back to the default (`.claude`).
242
+ */
243
+ const DEFAULT_CONFIG_HOME_FRAGMENT = "'.claude'";
244
+ const GLOBAL_CONFIG_HOME_FRAGMENTS = {
245
+ copilot: "'.copilot'",
246
+ opencode: "'.config', 'opencode'",
247
+ gemini: "'.gemini'",
248
+ kilo: "'.config', 'kilo'",
249
+ codex: "'.codex'",
250
+ cursor: "'.cursor'",
251
+ windsurf: "'.windsurf'",
252
+ augment: "'.augment'",
253
+ trae: "'.trae'",
254
+ qwen: "'.qwen'",
255
+ hermes: "'.hermes'",
256
+ codebuddy: "'.codebuddy'",
257
+ cline: "'.cline'",
258
+ kimi: "'.config', 'agents'",
259
+ };
260
+ /**
261
+ * Return the global config-home path-fragment source snippet for a runtime
262
+ * (for hook path.join() codegen). `claude`/unknown/empty → the default
263
+ * `'.claude'` fragment. `antigravity` is NOT handled here (caller resolves it
264
+ * dynamically). Pure: no I/O. Sibling to `getDirName` / `getRuntimeLabel`.
265
+ */
266
+ function getGlobalConfigHomeFragment(runtime) {
267
+ if (!runtime)
268
+ return DEFAULT_CONFIG_HOME_FRAGMENT;
269
+ const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime];
270
+ return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT;
271
+ }
272
+ /**
273
+ * The runtime ids for which `bin/install.js` needs an `is<Runtime>` boolean
274
+ * predicate (every installed host that takes a non-claude install branch).
275
+ * Single source of truth — adding a runtime is one entry here, not a per-
276
+ * function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
277
+ * removes).
278
+ */
279
+ const RUNTIME_FLAG_IDS = Object.freeze([
280
+ 'opencode', 'kilo', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor',
281
+ 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi',
282
+ ]);
283
+ /**
284
+ * Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
285
+ * id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
286
+ * runtime === 'x'` declaration blocks that lived in `bin/install.js`'s
287
+ * `uninstall`/`writeManifest`/`install`/etc. into one helper (sibling to
288
+ * `getDirName`/`getRuntimeLabel`). Pure: no I/O.
289
+ */
290
+ function runtimeFlags(runtime) {
291
+ const flags = {};
292
+ for (const id of RUNTIME_FLAG_IDS) {
293
+ flags['is' + id.charAt(0).toUpperCase() + id.slice(1)] = runtime === id;
294
+ }
295
+ return Object.freeze(flags);
296
+ }
297
+ /**
298
+ * The `/gsd-new-project` invocation syntax per runtime — the post-install
299
+ * "next step" command string. Most runtimes use the default `/gsd-new-project`;
300
+ * a few hosts need a different surface syntax. Collapses the 14-line
301
+ * `if (runtime === 'x') command = ...` chain in bin/install.js's next-step
302
+ * message (ADR-1239 Phase B / #1679 AC2). Pure: no I/O.
303
+ */
304
+ const DEFAULT_NEW_PROJECT_COMMAND = '/gsd-new-project';
305
+ const RUNTIME_NEW_PROJECT_COMMANDS = {
306
+ gemini: '/gsd:new-project',
307
+ codex: '$gsd-new-project',
308
+ cursor: 'gsd-new-project (mention the skill name)',
309
+ kimi: '/skill:gsd-new-project',
310
+ };
311
+ function getRuntimeNewProjectCommand(runtime) {
312
+ if (!runtime)
313
+ return DEFAULT_NEW_PROJECT_COMMAND;
314
+ const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime];
315
+ return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND;
316
+ }
@@ -40,6 +40,7 @@ exports.execNpm = execNpm;
40
40
  exports.execTool = execTool;
41
41
  exports.probeTty = probeTty;
42
42
  exports.normalizeContent = normalizeContent;
43
+ exports.retryRenameSync = retryRenameSync;
43
44
  exports.platformWriteSync = platformWriteSync;
44
45
  exports.platformReadSync = platformReadSync;
45
46
  exports.platformEnsureDir = platformEnsureDir;
@@ -575,6 +576,21 @@ function atomicRenameWithRetry(tmpPath, filePath) {
575
576
  }
576
577
  return renameErr;
577
578
  }
579
+ /**
580
+ * Drop-in replacement for `fs.renameSync(from, to)` that retries the transient
581
+ * Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded
582
+ * number of times with a short backoff before rethrowing the final error.
583
+ *
584
+ * Idempotent on POSIX (the transient errnos do not occur), so callers retain
585
+ * identical semantics on macOS/Linux while gaining resilience on Windows where
586
+ * an antivirus scanner, indexer, or concurrent reader may briefly hold the
587
+ * target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6).
588
+ */
589
+ function retryRenameSync(fromPath, toPath) {
590
+ const err = atomicRenameWithRetry(fromPath, toPath);
591
+ if (err !== null)
592
+ throw err;
593
+ }
578
594
  function platformWriteSync(filePath, content, opts = {}) {
579
595
  const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
580
596
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650).
5
+ *
6
+ * Runtimes `codex` and `opencode` bake the resolved model ID into each agent's
7
+ * static config at install time (bin/install.js ~5667-5767 for codex,
8
+ * ~10008-10026 for opencode). Their task/spawn_agent interfaces do not accept
9
+ * an inline `model` parameter, so editing `model_overrides` in
10
+ * `.planning/config.json` or `~/.gsd/defaults.json` has NO effect until the
11
+ * user re-runs `gsd install <runtime>` (or `gsd update`). The failure is
12
+ * silent — the sub-agent just uses the prior base model. This module detects
13
+ * that staleness at workflow entry and emits a single stderr warning.
14
+ *
15
+ * Design: pure decision + formatter (testable, no I/O) backed by fs probes
16
+ * that swallow every error (the guard must never break the CLI). Dedup'd per
17
+ * (runtime, cwd) within a process so a single `gsd-tools init *` invocation
18
+ * warns at most once even though multiple agents resolve models underneath.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ /**
25
+ * Runtimes whose agent config is static frontmatter/TOML baked at install time.
26
+ * MUST stay in sync with the bake paths in bin/install.js. The parity test in
27
+ * tests/stale-bake-guard.test.cjs asserts this matches the runtimes that
28
+ * actually emit a baked model: line id #2256 (opencode) and #49/#2256 (codex).
29
+ */
30
+ const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']);
31
+
32
+ const _warnedKeys = new Set();
33
+
34
+ /**
35
+ * Pure: decide whether a stale-bake condition exists.
36
+ *
37
+ * Returns `{ stale: true, deltaMs }` when `configMtimeMs` is strictly newer
38
+ * than `agentMtimeMs` on a static-frontmatter runtime. Returns `null` when the
39
+ * guard does not apply (claude / other spawn-time runtime, missing or
40
+ * non-finite mtimes, or agents already at least as new as config).
41
+ */
42
+ function detectStaleBake({ runtime, configMtimeMs, agentMtimeMs }) {
43
+ if (!runtime || !STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return null;
44
+ if (typeof configMtimeMs !== 'number' || typeof agentMtimeMs !== 'number') return null;
45
+ if (!Number.isFinite(configMtimeMs) || !Number.isFinite(agentMtimeMs)) return null;
46
+ if (configMtimeMs <= agentMtimeMs) return null;
47
+ return { stale: true, deltaMs: configMtimeMs - agentMtimeMs };
48
+ }
49
+
50
+ /**
51
+ * Pure: format the warning string. Returns `''` when no warning is warranted
52
+ * (delegates to detectStaleBake so the decision and the message cannot drift).
53
+ */
54
+ function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtimeMs }) {
55
+ const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs });
56
+ if (!signal) return '';
57
+ const configDate = new Date(configMtimeMs).toISOString();
58
+ const installFlag = runtime === 'opencode' ? '--opencode' : '--codex';
59
+ return [
60
+ `gsd: model config in ${configPath} changed since agents were last baked (${configDate}).`,
61
+ ` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`,
62
+ ` until you re-run: gsd install ${installFlag}`,
63
+ ` (or 'gsd update')`,
64
+ ].join('\n');
65
+ }
66
+
67
+ /**
68
+ * Pure: resolve the active runtime id from a parsed config object.
69
+ * Returns the runtime string, or `'claude'` when unset (the spawn-time default
70
+ * for which the guard is a no-op).
71
+ */
72
+ function resolveRuntimeFromConfig(config) {
73
+ if (config && typeof config === 'object'
74
+ && typeof config.runtime === 'string' && config.runtime) {
75
+ return config.runtime;
76
+ }
77
+ return 'claude';
78
+ }
79
+
80
+ /**
81
+ * Resolve the install root for a runtime's agent files, honoring the same env
82
+ * vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR). Returns the
83
+ * absolute directory or `null` for unsupported runtimes.
84
+ */
85
+ function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) {
86
+ if (runtime === 'opencode') {
87
+ const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode');
88
+ return path.join(base, 'agent');
89
+ }
90
+ if (runtime === 'codex') {
91
+ const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex');
92
+ return path.join(base, 'agents');
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Find the newest mtime across config sources that exist. Mirrors the
99
+ * up-to-8-levels-up walk in readGsdEffectiveModelOverrides (bin/install.js)
100
+ * and includes the global ~/.gsd/defaults.json. Returns
101
+ * `{ mtimeMs, path }` of the newest existing config, or `null` if none exist.
102
+ *
103
+ * `homedir` is injectable so tests can point the global lookup at a fixture
104
+ * dir (otherwise the real ~/.gsd/defaults.json on the CI runner leaks in and
105
+ * skews the newest-config calculation — see warnIfStaleBake orchestrator).
106
+ */
107
+ function findNewestConfigMtime(cwd, { fsStatSync = fs.statSync, homedir = os.homedir } = {}) {
108
+ const candidates = [];
109
+ let probe = path.resolve(cwd || '.');
110
+ for (let i = 0; i < 8; i += 1) {
111
+ candidates.push(path.join(probe, '.planning', 'config.json'));
112
+ const parent = path.dirname(probe);
113
+ if (parent === probe) break;
114
+ probe = parent;
115
+ }
116
+ candidates.push(path.join(homedir(), '.gsd', 'defaults.json'));
117
+
118
+ let newest = null;
119
+ for (const p of candidates) {
120
+ try {
121
+ const st = fsStatSync(p);
122
+ if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
123
+ && (!newest || st.mtimeMs > newest.mtimeMs)) {
124
+ newest = { mtimeMs: st.mtimeMs, path: p };
125
+ }
126
+ } catch {
127
+ // not present / unreadable — skip
128
+ }
129
+ }
130
+ return newest;
131
+ }
132
+
133
+ /**
134
+ * Find the oldest mtime across installed gsd-* agent files for the runtime.
135
+ * Returns `{ mtimeMs, dir }` or `null` if the agent dir is absent or holds no
136
+ * gsd-* files (e.g. not yet installed, or uninstalled).
137
+ */
138
+ function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir, fsStatSync = fs.statSync, fsReaddirSync = fs.readdirSync } = {}) {
139
+ const dir = resolveAgentDir(runtime, { env, homedir });
140
+ if (!dir) return null;
141
+ let entries;
142
+ try {
143
+ entries = fsReaddirSync(dir, { withFileTypes: true });
144
+ } catch {
145
+ return null; // dir missing — runtime not installed for this user
146
+ }
147
+ let oldest = null;
148
+ for (const entry of entries) {
149
+ if (!entry.isFile()) continue;
150
+ if (!entry.name.startsWith('gsd-')) continue;
151
+ const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md'))
152
+ || (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md')));
153
+ if (!isAgentFile) continue;
154
+ try {
155
+ const st = fsStatSync(path.join(dir, entry.name));
156
+ if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
157
+ && (!oldest || st.mtimeMs < oldest.mtimeMs)) {
158
+ oldest = { mtimeMs: st.mtimeMs, dir };
159
+ }
160
+ } catch {
161
+ // unreadable — skip
162
+ }
163
+ }
164
+ return oldest;
165
+ }
166
+
167
+ /**
168
+ * Orchestrator (side-effecting): probe fs, decide, write warning to stderr.
169
+ *
170
+ * - Silent on claude / other spawn-time runtimes (returns false).
171
+ * - Silent when agents are already at least as new as config.
172
+ * - Silent when config or agent dir is absent (nothing to compare).
173
+ * - Dedup'd per (runtime, cwd): a single process warns at most once per pair,
174
+ * so repeated `resolveModelInternal` calls under one `gsd-tools init *` do
175
+ * not repeat the warning.
176
+ * - Swallows every error: a warning helper must never break the CLI.
177
+ *
178
+ * Pass `config` to skip the internal JSON read (caller already loaded it).
179
+ * Returns `true` if a warning was written, `false` otherwise.
180
+ */
181
+ function warnIfStaleBake(cwd, options = {}) {
182
+ const {
183
+ stderr = process.stderr,
184
+ config = null,
185
+ env = process.env,
186
+ homedir = os.homedir,
187
+ fsStatSync = fs.statSync,
188
+ fsReaddirSync = fs.readdirSync,
189
+ } = options;
190
+ try {
191
+ const resolvedConfig = config || _readRuntimeConfig(cwd, { fsStatSync });
192
+ const runtime = resolveRuntimeFromConfig(resolvedConfig);
193
+ if (!STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return false;
194
+
195
+ const dedupKey = `${runtime}::${path.resolve(cwd || '.')}`;
196
+ if (_warnedKeys.has(dedupKey)) return false;
197
+
198
+ const newest = findNewestConfigMtime(cwd, { fsStatSync, homedir });
199
+ const oldest = findOldestAgentMtime(runtime, { env, homedir, fsStatSync, fsReaddirSync });
200
+ if (!newest || !oldest) return false;
201
+
202
+ const warning = formatStaleBakeWarning({
203
+ runtime,
204
+ configPath: newest.path,
205
+ configMtimeMs: newest.mtimeMs,
206
+ agentMtimeMs: oldest.mtimeMs,
207
+ });
208
+ if (!warning) return false;
209
+
210
+ stderr.write(warning + '\n');
211
+ _warnedKeys.add(dedupKey);
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /** Best-effort minimal read of `.planning/config.json` for the `runtime` key. */
219
+ function _readRuntimeConfig(cwd, { fsStatSync = fs.statSync } = {}) {
220
+ let probe = path.resolve(cwd || '.');
221
+ for (let i = 0; i < 8; i += 1) {
222
+ const candidate = path.join(probe, '.planning', 'config.json');
223
+ try {
224
+ fsStatSync(candidate);
225
+ const raw = fs.readFileSync(candidate, 'utf8');
226
+ const parsed = JSON.parse(raw);
227
+ if (parsed && typeof parsed === 'object') return parsed;
228
+ return {};
229
+ } catch {
230
+ // not present / unreadable / malformed — walk up
231
+ }
232
+ const parent = path.dirname(probe);
233
+ if (parent === probe) break;
234
+ probe = parent;
235
+ }
236
+ return {};
237
+ }
238
+
239
+ /** Test-only: reset the in-process dedup set between cases. */
240
+ function _resetWarnedForTests() {
241
+ _warnedKeys.clear();
242
+ }
243
+
244
+ module.exports = {
245
+ STATIC_FRONTMATTER_RUNTIMES,
246
+ detectStaleBake,
247
+ formatStaleBakeWarning,
248
+ resolveRuntimeFromConfig,
249
+ resolveAgentDir,
250
+ findNewestConfigMtime,
251
+ findOldestAgentMtime,
252
+ warnIfStaleBake,
253
+ _resetWarnedForTests,
254
+ };
@@ -155,6 +155,10 @@ function routeStateCommand({ state, args, cwd, raw, error }) {
155
155
  const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['keep-recent'], ['dry-run']);
156
156
  state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
157
157
  },
158
+ rebuild: () => {
159
+ const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, [], ['dry-run', 'verbose']);
160
+ state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === true }, raw);
161
+ },
158
162
  // complete-phase: CJS-only — no SDK counterpart.
159
163
  'complete-phase': () => {
160
164
  const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['phase']);