@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0-rc.6

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-ui-checker.md +2 -0
  4. package/agents/gsd-ui-researcher.md +1 -0
  5. package/bin/install.js +970 -192
  6. package/gsd-core/bin/lib/capability-registry.cjs +501 -85
  7. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  8. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  9. package/gsd-core/bin/lib/init.cjs +33 -40
  10. package/gsd-core/bin/lib/install-engine.cjs +90 -20
  11. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  12. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  13. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  14. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +235 -38
  15. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -13
  16. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  17. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  18. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +501 -12
  19. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  20. package/gsd-core/bin/lib/security.cjs +6 -36
  21. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  22. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  23. package/gsd-core/bin/lib/surface.cjs +10 -6
  24. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  25. package/gsd-core/bin/shared/model-catalog.json +8 -3
  26. package/gsd-core/references/ui-consideration-probe.md +73 -0
  27. package/gsd-core/templates/UI-SPEC.md +25 -0
  28. package/gsd-core/templates/VALIDATION.md +2 -0
  29. package/gsd-core/workflows/audit-milestone.md +7 -4
  30. package/gsd-core/workflows/plan-phase.md +6 -0
  31. package/gsd-core/workflows/settings-advanced.md +7 -4
  32. package/gsd-core/workflows/ui-phase.md +146 -1
  33. package/gsd-core/workflows/validate-phase.md +2 -2
  34. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  35. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  36. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  37. package/hooks/gsd-windsurf-pre-command.js +275 -0
  38. package/hooks/gsd-windsurf-pre-write.js +132 -0
  39. package/hooks/managed-hooks-registry.cjs +2 -0
  40. package/package.json +8 -4
  41. package/pi/gsd.cjs +354 -0
  42. package/scripts/build-hooks.js +3 -0
  43. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  44. package/scripts/gen-registry.cjs +128 -0
  45. package/scripts/lint-test-file-count.allowlist.json +2 -1
  46. package/scripts/registry-schema.cjs +565 -0
  47. package/scripts/validate-registry.cjs +117 -0
  48. package/vscode/browser.js +197 -0
  49. package/vscode/extension.js +383 -0
  50. package/vscode/host-binding.js +113 -0
  51. package/vscode/package.json +96 -0
@@ -15,6 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.NO_LOCAL_CONFIG_DIR_SENTINEL = void 0;
18
19
  exports.canonicalizeRuntimeName = canonicalizeRuntimeName;
19
20
  exports.resolveRuntimeNameFromCandidates = resolveRuntimeNameFromCandidates;
20
21
  exports.getProjectInstructionFile = getProjectInstructionFile;
@@ -132,7 +133,15 @@ function resolveRuntimeNameFromCandidates(...candidates) {
132
133
  * Aliases are normalized via `canonicalizeRuntimeName` first, so inputs like
133
134
  * `codex-cli` resolve to `codex` → `AGENTS.md`. Replaces the prior codex-only
134
135
  * override in profile-output.cjs (#3163) which left AGENTS-native runtimes
135
- * (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O.
136
+ * (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O
137
+ * (the lazy `require` below reads a static generated module, not the disk).
138
+ *
139
+ * Descriptor-driven (ADR-1239 / #2096): antigravity's `GEMINI.md` is folded
140
+ * from a hardcoded `canonical === 'antigravity'` literal into a read of
141
+ * `runtime.hostBehaviors.projectInstructionFile`. claude/copilot stay
142
+ * hardcoded (out of scope here) mirroring `getDirName` below, which already
143
+ * lazy-`require`s `capability-registry.cjs` inside the function body to
144
+ * avoid a circular dependency at module load.
136
145
  */
137
146
  function getProjectInstructionFile(runtime) {
138
147
  const canonical = canonicalizeRuntimeName(runtime);
@@ -140,17 +149,46 @@ function getProjectInstructionFile(runtime) {
140
149
  return '.claude/CLAUDE.md';
141
150
  if (canonical === 'copilot')
142
151
  return '.github/copilot-instructions.md';
143
- if (canonical === 'antigravity')
144
- return 'GEMINI.md';
152
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
153
+ const { runtimes } = require('./capability-registry.cjs');
154
+ const declared = canonical ? runtimes[canonical]?.runtime?.hostBehaviors?.projectInstructionFile : undefined;
155
+ if (typeof declared === 'string' && declared.length > 0)
156
+ return declared;
145
157
  // codex, opencode, kilo, kimi, AND unknown/future runtimes all default to
146
158
  // root AGENTS.md (the safe cross-agent instruction file).
147
159
  return 'AGENTS.md';
148
160
  }
161
+ /**
162
+ * Sentinel returned by {@link getDirName} for a runtime whose
163
+ * `runtime.configHome.kind === 'none'` (#2103 — a Marketplace/VSIX-distributed
164
+ * host with NO file-projected config directory at all, e.g. VS Code).
165
+ *
166
+ * A plain fallback to `.claude` would be actively wrong here — it would read
167
+ * as "this runtime installs into .claude", which is false. This sentinel is
168
+ * a string (not `null`) so `getDirName`'s return type and every existing
169
+ * template-literal call site (`` `${getDirName(runtime)}` `` in bin/install.js
170
+ * / runtime-artifact-conversion.cjs / install-engine.cjs) are unaffected —
171
+ * widening the return type to `string | null` would require auditing every
172
+ * call site for a null-check, which is out of scope for a runtime that is
173
+ * never actually dispatched through those installer paths (vscode has no
174
+ * install surface — see capabilities/vscode/capability.json). The value is
175
+ * deliberately NOT a plausible dot-dir name (parens are not valid in a
176
+ * directory-name token GSD would ever generate) so a future caller that
177
+ * mistakenly interpolates it into a path fails obviously rather than
178
+ * silently colliding with a real directory.
179
+ */
180
+ exports.NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
149
181
  /**
150
182
  * Map a canonical runtime id to its on-disk local config directory name
151
183
  * (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs
152
184
  * fall back to `.claude`.
153
185
  *
186
+ * #2103: a runtime whose descriptor declares `configHome.kind === 'none'`
187
+ * (no file-projected config directory at all) returns
188
+ * {@link NO_LOCAL_CONFIG_DIR_SENTINEL} instead of falling through to
189
+ * `.claude` — it has no local config dir, and `.claude` would be a wrong
190
+ * answer, not just an imprecise one.
191
+ *
154
192
  * Pure runtime-identity projection. Relocated from `bin/install.js` per
155
193
  * ADR-1508 (epic #1507, #1510 Phase 1) so the Runtime Artifact Conversion
156
194
  * Module's rewrite engine can consume it without importing the installer.
@@ -161,9 +199,12 @@ function getDirName(runtime) {
161
199
  return '.claude';
162
200
  // eslint-disable-next-line @typescript-eslint/no-require-imports
163
201
  const { runtimes } = require('./capability-registry.cjs');
164
- const dir = runtimes[runtime]?.runtime?.localConfigDir;
202
+ const entry = runtimes[runtime]?.runtime;
203
+ const dir = entry?.localConfigDir;
165
204
  if (typeof dir === 'string' && dir.length > 0)
166
205
  return dir;
206
+ if (entry?.configHome?.kind === 'none')
207
+ return exports.NO_LOCAL_CONFIG_DIR_SENTINEL;
167
208
  return '.claude';
168
209
  }
169
210
  /**
@@ -210,6 +251,12 @@ const RUNTIME_LABELS = {
210
251
  codebuddy: 'CodeBuddy',
211
252
  cline: 'Cline',
212
253
  zcode: 'ZCode',
254
+ pi: 'pi',
255
+ // #2103: vscode is a registered (role:runtime) capability for validator +
256
+ // host-integration coverage, even though it is never CLI-installed (no
257
+ // --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs).
258
+ // A distinct label is still required by the drift guard below.
259
+ vscode: 'VS Code',
213
260
  };
214
261
  /**
215
262
  * Map a canonical runtime id to its short display label for the
@@ -257,6 +304,12 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS = {
257
304
  cline: "'.cline'",
258
305
  kimi: "'.config', 'agents'",
259
306
  zcode: "'.zcode'",
307
+ // pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
308
+ // parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
309
+ // resolveConfigHomeFromDescriptor's `path.join(home, parent, name)` for the
310
+ // no-probe dot-home-nested case (src/runtime-homes.cts). Two-segment
311
+ // path.join args, same shape as opencode/kilo/kimi above.
312
+ pi: "'.pi', 'agent'",
260
313
  };
261
314
  /**
262
315
  * Return the global config-home path-fragment source snippet for a runtime
@@ -277,9 +330,14 @@ function getGlobalConfigHomeFragment(runtime) {
277
330
  * function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
278
331
  * removes).
279
332
  */
333
+ // #2094: 'trae' stays here — bin/install.js's agents-converter dispatch
334
+ // (convertClaudeAgentToTraeAgent selection) still reads isTrae directly.
335
+ // Removing it is gated on migrating that runtime-keyed `else if` chain to a
336
+ // cross-runtime agents-dispatch table (out of scope for #2094, which only
337
+ // folds the shared-hooks-install skip).
280
338
  const RUNTIME_FLAG_IDS = Object.freeze([
281
339
  'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
282
- 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode',
340
+ 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode', 'pi',
283
341
  ]);
284
342
  /**
285
343
  * Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
@@ -34,7 +34,6 @@ exports.safeJsonParse = safeJsonParse;
34
34
  exports.validatePhaseNumber = validatePhaseNumber;
35
35
  exports.validateFieldName = validateFieldName;
36
36
  exports.validatePromptStructure = validatePromptStructure;
37
- exports.scanEntropyAnomalies = scanEntropyAnomalies;
38
37
  const node_fs_1 = __importDefault(require("node:fs"));
39
38
  const node_os_1 = __importDefault(require("node:os"));
40
39
  const node_path_1 = __importDefault(require("node:path"));
@@ -443,38 +442,9 @@ function validatePromptStructure(text, fileType) {
443
442
  }
444
443
  return { valid: violations.length === 0, violations };
445
444
  }
446
- // ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────────────────────────────────────────────────────
447
- function shannonEntropy(text) {
448
- if (!text || text.length === 0)
449
- return 0;
450
- const freq = {};
451
- for (const ch of text) {
452
- freq[ch] = (freq[ch] || 0) + 1;
453
- }
454
- const len = text.length;
455
- let entropy = 0;
456
- for (const count of Object.values(freq)) {
457
- const p = count / len;
458
- entropy -= p * Math.log2(p);
459
- }
460
- return entropy;
461
- }
462
- /**
463
- * Scan text for paragraphs with anomalously high Shannon entropy.
464
- */
465
- function scanEntropyAnomalies(text) {
466
- if (!text || typeof text !== 'string') {
467
- return { clean: true, findings: [] };
468
- }
469
- const findings = [];
470
- const paragraphs = text.split(/\n\n+/);
471
- for (const para of paragraphs) {
472
- if (para.length <= 50)
473
- continue;
474
- const entropy = shannonEntropy(para);
475
- if (entropy > 5.5) {
476
- findings.push(`High-entropy paragraph detected (${entropy.toFixed(2)} bits/char) — possible encoded payload`);
477
- }
478
- }
479
- return { clean: findings.length === 0, findings };
480
- }
445
+ // NOTE (#2198): scanEntropyAnomalies + shannonEntropy were removed as dead exports.
446
+ // They had zero production callers — the live hooks (gsd-prompt-guard.js,
447
+ // gsd-read-injection-scanner.js) inline their own pattern subsets for hook
448
+ // independence and never called these functions. scanForInjection is retained
449
+ // below: it serves as the CI codebase-scanner engine
450
+ // (tests/prompt-injection-scan.security.test.cjs), not as a live hook.
@@ -38,6 +38,8 @@ exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
38
38
  exports.execGit = execGit;
39
39
  exports.execNpm = execNpm;
40
40
  exports.execTool = execTool;
41
+ exports.resolveGsdToolsPath = resolveGsdToolsPath;
42
+ exports.dispatchGsdCommand = dispatchGsdCommand;
41
43
  exports.probeTty = probeTty;
42
44
  exports.normalizeContent = normalizeContent;
43
45
  exports.retryRenameSync = retryRenameSync;
@@ -122,10 +124,18 @@ function formatManagedHookScriptToken(scriptPath, opts = {}) {
122
124
  return null;
123
125
  return JSON.stringify(scriptPath.replace(/\\/g, '/'));
124
126
  }
125
- function projectLocalHookPrefix({ runtime = 'claude', dirName }) {
127
+ function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }) {
126
128
  if (!dirName)
127
129
  return dirName;
128
- return (runtime === 'antigravity')
130
+ // Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
131
+ // `runtime === 'antigravity'` literal into the runtime's declared
132
+ // `hostBehaviors.hookPathStyle`. Runtimes that always run project hooks
133
+ // with the project dir as cwd (Antigravity today) declare 'raw' and get
134
+ // the bare dirName; every other runtime keeps the $CLAUDE_PROJECT_DIR-
135
+ // anchored prefix. `runtime` itself is now unused here but stays in the
136
+ // signature for call-site/back-compat parity (kept `_`-prefixed to
137
+ // satisfy no-unused-vars).
138
+ return (hookPathStyle === 'raw')
129
139
  ? dirName
130
140
  : `"$CLAUDE_PROJECT_DIR"/${dirName}`;
131
141
  }
@@ -461,6 +471,109 @@ function execTool(program, args, opts = {}) {
461
471
  });
462
472
  return _spawnResult(result, program);
463
473
  }
474
+ /**
475
+ * Resolve the absolute path to gsd-tools.cjs relative to THIS module.
476
+ *
477
+ * This file compiles to gsd-core/bin/lib/shell-command-projection.cjs — a
478
+ * sibling of gsd-core/bin/gsd-tools.cjs — so the relative walk-up is stable
479
+ * regardless of install location (global/local/dev-repo layouts all ship
480
+ * gsd-core/bin/ as a unit).
481
+ */
482
+ function resolveGsdToolsPath() {
483
+ return node_path_1.default.resolve(__dirname, '..', 'gsd-tools.cjs');
484
+ }
485
+ /**
486
+ * Subprocess-shim dispatch to gsd-tools.cjs (ADR-1239 #2102 Stage 2).
487
+ *
488
+ * No fully-populated in-process command-routing hub exists anywhere in the
489
+ * tree — every `createHub()` caller (cjs-command-router-adapter.cts,
490
+ * phase-command-router.cts, command-routing-hub.cts's own tests) builds a
491
+ * single-family hub for its own narrow purpose. The ONLY dispatch path that
492
+ * covers the FULL family/subcommand surface is the gsd-tools.cjs CLI itself.
493
+ * This mirrors the SUBPROCESS-REUSE precedent already established for the
494
+ * OpenCode/Kilo hook bridge (see .opencode/plugins/gsd-core.js header:
495
+ * "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
496
+ * processes") — the same pattern, applied to command dispatch instead of
497
+ * hook dispatch.
498
+ *
499
+ * Output-flag choice (verified by direct invocation — see #2102 dispatch
500
+ * notes for the sample invocations): always pass `--raw` (undecorated,
501
+ * programmatically-consumable stdout on success) and `--json-errors` (a
502
+ * structured `{ok:false,reason,message}` JSON object on stderr, with a
503
+ * non-zero exit, instead of a free-text "Error: ..." line). Both are global
504
+ * flags accepted by every gsd-tools.cjs family/subcommand, so passing them
505
+ * unconditionally is safe for the full command surface.
506
+ *
507
+ * `family` maps 1:1 onto gsd-tools.cjs's first positional argv token;
508
+ * `subcommand` (when present) onto the second — e.g.
509
+ * `{family:'phase', subcommand:'add'}` → `gsd-tools.cjs phase add`. An empty
510
+ * `subcommand` is omitted entirely (some families, e.g. `config-path`, take
511
+ * no subcommand).
512
+ *
513
+ * NEVER throws. Degrades to `{ ok:false, ... }` on:
514
+ * - a missing/invalid "family" (validated locally, no subprocess spawned)
515
+ * - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
516
+ * - a wall-clock timeout (`timedOut:true`, mirroring the
517
+ * `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
518
+ * by worktree-safety.cts)
519
+ * - any other unanticipated throw from the underlying spawn (defensive
520
+ * try/catch — execTool itself is spawnSync-based and does not throw).
521
+ */
522
+ function dispatchGsdCommand({ family, subcommand, args = [], cwd, timeout = 30_000, gsdToolsPath, } = {}) {
523
+ if (typeof family !== 'string' || family.length === 0) {
524
+ return {
525
+ ok: false,
526
+ stdout: '',
527
+ stderr: 'dispatchGsdCommand requires a non-empty string "family".',
528
+ code: null,
529
+ timedOut: false,
530
+ };
531
+ }
532
+ const resolvedCwd = cwd || process.cwd();
533
+ const toolsPath = gsdToolsPath || resolveGsdToolsPath();
534
+ const argv = [
535
+ toolsPath,
536
+ family,
537
+ ...(subcommand ? [subcommand] : []),
538
+ ...(Array.isArray(args) ? args : []),
539
+ '--cwd', resolvedCwd,
540
+ '--raw',
541
+ '--json-errors',
542
+ ];
543
+ let result;
544
+ try {
545
+ result = execTool(process.execPath, argv, { cwd: resolvedCwd, timeout });
546
+ }
547
+ catch (e) {
548
+ // Defensive belt-and-suspenders: execTool is spawnSync-based and does not
549
+ // throw today, but a degraded result here keeps this seam's no-throw
550
+ // contract true even under an unanticipated future failure mode.
551
+ return {
552
+ ok: false,
553
+ stdout: '',
554
+ stderr: e instanceof Error ? e.message : String(e),
555
+ code: null,
556
+ timedOut: false,
557
+ };
558
+ }
559
+ // Mirrors the established `result.error && (result.error as
560
+ // NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
561
+ // narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
562
+ // to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
563
+ // trips a typescript-eslint no-unnecessary-type-assertion false positive for
564
+ // this exact narrowing shape (all of ErrnoException's extra fields over Error
565
+ // are optional).
566
+ const timedOut = result.signal === 'SIGTERM'
567
+ && result.error !== null
568
+ && result.error.code === 'ETIMEDOUT';
569
+ return {
570
+ ok: result.exitCode === 0 && !timedOut,
571
+ stdout: result.stdout,
572
+ stderr: result.stderr,
573
+ code: result.exitCode,
574
+ timedOut,
575
+ };
576
+ }
464
577
  function probeTty(opts = {}) {
465
578
  const platform = opts.platform ?? process.platform;
466
579
  if (platform === 'win32')
@@ -3,9 +3,10 @@
3
3
  /**
4
4
  * Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650).
5
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
6
+ * Runtimes `codex`, `opencode`, and (since #2093) `kilo` bake the resolved
7
+ * model ID into each agent's static config at install time (bin/install.js
8
+ * ~5667-5767 for codex, ~10008-10026 for opencode, and the adjacent kilo
9
+ * branch added by #2093). Their task/spawn_agent interfaces do not accept
9
10
  * an inline `model` parameter, so editing `model_overrides` in
10
11
  * `.planning/config.json` or `~/.gsd/defaults.json` has NO effect until the
11
12
  * user re-runs `gsd install <runtime>` (or `gsd update`). The failure is
@@ -25,9 +26,17 @@ const os = require('os');
25
26
  * Runtimes whose agent config is static frontmatter/TOML baked at install time.
26
27
  * MUST stay in sync with the bake paths in bin/install.js. The parity test in
27
28
  * 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
+ * actually emit a baked model: line id #2256 (opencode), #49/#2256 (codex),
30
+ * and #2093 (kilo).
29
31
  */
30
- const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']);
32
+ const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'kilo', 'opencode']);
33
+
34
+ /** Per-runtime `gsd install` flag, for the remediation hint in the warning. */
35
+ const INSTALL_FLAG_BY_RUNTIME = Object.freeze({
36
+ codex: '--codex',
37
+ opencode: '--opencode',
38
+ kilo: '--kilo',
39
+ });
31
40
 
32
41
  const _warnedKeys = new Set();
33
42
 
@@ -55,7 +64,7 @@ function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtime
55
64
  const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs });
56
65
  if (!signal) return '';
57
66
  const configDate = new Date(configMtimeMs).toISOString();
58
- const installFlag = runtime === 'opencode' ? '--opencode' : '--codex';
67
+ const installFlag = INSTALL_FLAG_BY_RUNTIME[runtime] || `--${runtime}`;
59
68
  return [
60
69
  `gsd: model config in ${configPath} changed since agents were last baked (${configDate}).`,
61
70
  ` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`,
@@ -79,13 +88,24 @@ function resolveRuntimeFromConfig(config) {
79
88
 
80
89
  /**
81
90
  * 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.
91
+ * vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR, KILO_CONFIG_DIR).
92
+ * Returns the absolute directory or `null` for unsupported runtimes.
84
93
  */
85
94
  function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) {
86
95
  if (runtime === 'opencode') {
87
96
  const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode');
88
- return path.join(base, 'agent');
97
+ // #2093 fix: the installer writes agents to `<base>/agents` (plural — see
98
+ // bin/install.js's universal `agentsDest = path.join(targetDir, 'agents')`,
99
+ // verified against a live `--opencode --global` install). The prior
100
+ // singular `agent` never matched the real install output, so this guard's
101
+ // `findOldestAgentMtime` always hit ENOENT and warnIfStaleBake was a
102
+ // silent no-op for opencode in production — discovered while wiring the
103
+ // parallel kilo entry below (same bake mechanism, #2093).
104
+ return path.join(base, 'agents');
105
+ }
106
+ if (runtime === 'kilo') {
107
+ const base = (env.KILO_CONFIG_DIR && String(env.KILO_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'kilo');
108
+ return path.join(base, 'agents');
89
109
  }
90
110
  if (runtime === 'codex') {
91
111
  const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex');
@@ -148,7 +168,7 @@ function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir
148
168
  for (const entry of entries) {
149
169
  if (!entry.isFile()) continue;
150
170
  if (!entry.name.startsWith('gsd-')) continue;
151
- const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md'))
171
+ const isAgentFile = ((runtime === 'opencode' || runtime === 'kilo') && entry.name.endsWith('.md'))
152
172
  || (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md')));
153
173
  if (!isAgentFile) continue;
154
174
  try {
@@ -472,10 +472,14 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest, runtime) {
472
472
  // Normalize: allow legacy string context for backward-compat with internal callers
473
473
  const kindName = (typeof kind === 'string') ? kind : kind.kind;
474
474
  const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
475
- // #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
476
- // the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
477
- // the staged filename verbatim.
478
- const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
475
+ // #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
476
+ // time when the runtime's descriptor declares hostBehaviors.agentFileExtension
477
+ // (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
478
+ // loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
479
+ // of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
480
+ // (no agentFileExtension declared) keep the staged filename verbatim.
481
+ const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
482
+ const isRenamedAgents = !!_agentExt && kindName === 'agents';
479
483
  if (kindName === 'skills') {
480
484
  // Skills kind: work with directories, not files.
481
485
  // Each staged entry is a directory named ${prefix}${stem}.
@@ -510,8 +514,8 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest, runtime) {
510
514
  const stagedFiles = node_fs_1.default.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
511
515
  const stagedDestNames = new Set();
512
516
  for (const file of stagedFiles) {
513
- const destName = isCopilotAgents
514
- ? file.replace(/\.md$/, '.agent.md')
517
+ const destName = isRenamedAgents
518
+ ? file.replace(/\.md$/, _agentExt)
515
519
  : (kindName === 'agents' || namespacedByDir)
516
520
  ? file
517
521
  : `${kindPrefix}${file.slice(0, -3)}.md`;