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

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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. 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
@@ -13,6 +13,7 @@ exports.ORM_INFO = exports.SCHEMA_PATTERNS = void 0;
13
13
  exports.detectSchemaFiles = detectSchemaFiles;
14
14
  exports.detectSchemaOrm = detectSchemaOrm;
15
15
  exports.checkSchemaDrift = checkSchemaDrift;
16
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
17
  exports.SCHEMA_PATTERNS = [
17
18
  { pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
18
19
  { pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
@@ -61,7 +62,7 @@ function detectSchemaFiles(files) {
61
62
  const matches = [];
62
63
  const orms = new Set();
63
64
  for (const rawFile of files) {
64
- const file = rawFile.replace(/\\/g, '/');
65
+ const file = (0, shell_command_projection_cjs_1.posixNormalize)(rawFile);
65
66
  for (const { pattern, orm } of exports.SCHEMA_PATTERNS) {
66
67
  if (pattern.test(file)) {
67
68
  matches.push(rawFile);
@@ -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"));
@@ -342,7 +341,7 @@ function sanitizeForDisplay(text) {
342
341
  let sanitized = sanitizeForPrompt(text);
343
342
  const protocolLeakPatterns = [
344
343
  /^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
345
- /^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
344
+ /^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i, // allow-adhoc-markdown: not a GFM table-cell scan — matches `<|role|>` protocol-leak marker tokens (prompt-injection sanitization), a false-positive on the table-regex pipe+cell-class fingerprint
346
345
  ];
347
346
  sanitized = sanitized
348
347
  .split('\n')
@@ -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.
@@ -15,6 +15,9 @@ 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.toPosixPath = toPosixPath;
19
+ exports.toNativePath = toNativePath;
20
+ exports.posixNormalize = posixNormalize;
18
21
  exports.hookCommandNeedsPowerShellCallOperator = hookCommandNeedsPowerShellCallOperator;
19
22
  exports.formatHookCommandForRuntime = formatHookCommandForRuntime;
20
23
  exports.shellHookOmitsBashRunner = shellHookOmitsBashRunner;
@@ -38,6 +41,8 @@ exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
38
41
  exports.execGit = execGit;
39
42
  exports.execNpm = execNpm;
40
43
  exports.execTool = execTool;
44
+ exports.resolveGsdToolsPath = resolveGsdToolsPath;
45
+ exports.dispatchGsdCommand = dispatchGsdCommand;
41
46
  exports.probeTty = probeTty;
42
47
  exports.normalizeContent = normalizeContent;
43
48
  exports.retryRenameSync = retryRenameSync;
@@ -50,26 +55,58 @@ const node_fs_1 = __importDefault(require("node:fs"));
50
55
  // can intercept calls from this seam — destructured imports capture references
51
56
  // at load time and become un-mockable.
52
57
  const node_child_process_1 = __importDefault(require("node:child_process"));
58
+ /**
59
+ * Convert a filesystem path to POSIX form (forward slashes) by translating the
60
+ * platform-native separator. Single seam for native→POSIX conversion.
61
+ *
62
+ * Prefer this over `p.replace(/\\/g, '/')`: the regex form hardcodes both
63
+ * separators and corrupts POSIX paths containing a literal backslash (a legal
64
+ * filename character). Splitting on `path.sep` only ever touches real
65
+ * separators — a no-op on POSIX, `\`→`/` on Windows.
66
+ */
67
+ function toPosixPath(p) {
68
+ return p.split(node_path_1.default.sep).join(node_path_1.default.posix.sep);
69
+ }
70
+ /**
71
+ * Convert a filesystem path to the platform-native separator form. No-op on
72
+ * POSIX; `/`→`\` on Windows. Prefer this over a
73
+ * `process.platform === 'win32' ? p.replace(/\//g, '\\') : p` ternary.
74
+ */
75
+ function toNativePath(p) {
76
+ return p.split(node_path_1.default.posix.sep).join(node_path_1.default.sep);
77
+ }
78
+ /**
79
+ * Normalize ALL backslashes to forward slashes, unconditionally and independent
80
+ * of the running OS. Use this when emitting a path into a POSIX/bash target
81
+ * (which may differ from the running platform — e.g. generating a Windows config
82
+ * on a Linux runner) or when parsing input whose separators are unpredictable.
83
+ *
84
+ * Contrast `toPosixPath`, which is running-OS-relative (splits on `path.sep`) and
85
+ * is for *this machine's* filesystem paths. Do NOT use `toPosixPath` for
86
+ * target-platform projection — on a Linux runner it would not convert a
87
+ * Windows-target path's backslashes.
88
+ */
89
+ function posixNormalize(p) {
90
+ return p.replace(/\\/g, '/');
91
+ }
53
92
  /**
54
93
  * Return true when a managed hook command must be prefixed with PowerShell's
55
94
  * call operator so a quoted executable token is invokable by the target
56
95
  * runtime/shell combination.
57
96
  *
58
- * Current evidence-backed policy:
59
- * - Claude Code on Windows does NOT need it: its hook commands execute under
60
- * bash/Git Bash and `& ` breaks there (#3413).
61
- * - #1928: Gemini CLI — the ONLY runtime with a verified need for the `& `
62
- * prefix on Windows — was removed (Google sunset it 2026-06-18). No currently
63
- * supported runtime has a verified need, so this seam is now inert. It is
64
- * retained (not deleted) so a future runtime with a verified need is a
65
- * one-line re-enable, per the conservative policy below. Note: Antigravity —
66
- * the Gemini-backend successor — never matched the old `runtime === 'gemini'`
67
- * check, so its behavior (no prefix) is unchanged.
97
+ * The `&`/no-`&` decision is keyed on the **effective hook-execution shell**
98
+ * (`opts.hookShell`), not on runtime alone — a single runtime (Claude Code)
99
+ * can host either Git Bash or PowerShell on Windows, and no single static
100
+ * command string is valid in both (#2236):
101
+ * - Git Bash: `"node.exe" "hook.js"` works; `& "node.exe" …` → syntax error.
102
+ * - PowerShell: `& "node.exe" "hook.js"` works; bare `"node.exe" …` →
103
+ * `Unexpected token`.
68
104
  *
69
- * Keep the policy conservative until another runtime has a verified need.
105
+ * Default is `false` (Git Bash form) for backward compatibility. Set
106
+ * `opts.hookShell = 'powershell'` to emit the PowerShell call-operator form.
70
107
  */
71
- function hookCommandNeedsPowerShellCallOperator(_opts = {}) {
72
- return false;
108
+ function hookCommandNeedsPowerShellCallOperator(opts = {}) {
109
+ return opts.hookShell === 'powershell';
73
110
  }
74
111
  /**
75
112
  * Project a fully-assembled hook command string for the target runtime.
@@ -120,39 +157,48 @@ function formatManagedHookScriptToken(scriptPath, opts = {}) {
120
157
  const platform = opts.platform || process.platform;
121
158
  if (platform !== 'win32')
122
159
  return null;
123
- return JSON.stringify(scriptPath.replace(/\\/g, '/'));
160
+ return JSON.stringify(posixNormalize(scriptPath));
124
161
  }
125
- function projectLocalHookPrefix({ runtime = 'claude', dirName }) {
162
+ function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }) {
126
163
  if (!dirName)
127
164
  return dirName;
128
- return (runtime === 'antigravity')
165
+ // Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
166
+ // `runtime === 'antigravity'` literal into the runtime's declared
167
+ // `hostBehaviors.hookPathStyle`. Runtimes that always run project hooks
168
+ // with the project dir as cwd (Antigravity today) declare 'raw' and get
169
+ // the bare dirName; every other runtime keeps the $CLAUDE_PROJECT_DIR-
170
+ // anchored prefix. `runtime` itself is now unused here but stays in the
171
+ // signature for call-site/back-compat parity (kept `_`-prefixed to
172
+ // satisfy no-unused-vars).
173
+ return (hookPathStyle === 'raw')
129
174
  ? dirName
130
175
  : `"$CLAUDE_PROJECT_DIR"/${dirName}`;
131
176
  }
132
177
  function projectPortableHookBaseDir({ configDir, homeDir }) {
133
- const normalizedConfigDir = String(configDir || '').replace(/\\/g, '/');
134
- const normalizedHome = String(homeDir || '').replace(/\\/g, '/');
178
+ const normalizedConfigDir = posixNormalize(String(configDir || ''));
179
+ const normalizedHome = posixNormalize(String(homeDir || ''));
135
180
  if (!normalizedConfigDir || !normalizedHome)
136
181
  return normalizedConfigDir;
137
182
  return normalizedConfigDir.startsWith(normalizedHome)
138
183
  ? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
139
184
  : normalizedConfigDir;
140
185
  }
141
- function projectShellCommandText({ runnerToken, argTokens = [], runtime = 'generic', platform = process.platform, }) {
186
+ function projectShellCommandText({ runnerToken, argTokens = [], runtime = 'generic', platform = process.platform, hookShell, }) {
142
187
  if (!runnerToken)
143
188
  return null;
144
189
  const parts = [runnerToken, ...argTokens.filter(Boolean)];
145
- return formatHookCommandForRuntime(parts.join(' '), { platform, runtime });
190
+ return formatHookCommandForRuntime(parts.join(' '), { platform, runtime, hookShell });
146
191
  }
147
- function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform }) {
192
+ function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform, hookShell }) {
148
193
  if (!absoluteRunner || !scriptPath)
149
194
  return null;
150
- const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
195
+ const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
151
196
  return projectShellCommandText({
152
197
  runnerToken: absoluteRunner,
153
198
  argTokens: [JSON.stringify(normalizedScriptPath)],
154
199
  runtime,
155
200
  platform,
201
+ hookShell,
156
202
  });
157
203
  }
158
204
  const MANAGED_HOOK_BASENAMES_BY_SURFACE = {
@@ -245,14 +291,14 @@ function isManagedHookCommand(commandText, opts = {}) {
245
291
  for (const arg of opts.args) {
246
292
  if (typeof arg !== 'string')
247
293
  continue;
248
- const argBasename = arg.replace(/\\/g, '/').split('/').pop() || '';
294
+ const argBasename = posixNormalize(arg).split('/').pop() || '';
249
295
  if (isManagedHookBasename(argBasename, { surface }))
250
296
  return true;
251
297
  }
252
298
  }
253
- const normalizedCommand = commandText.replace(/\\/g, '/');
299
+ const normalizedCommand = posixNormalize(commandText);
254
300
  if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
255
- const normalizedHooksDir = `${node_path_1.default.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
301
+ const normalizedHooksDir = `${posixNormalize(node_path_1.default.join(opts.configDir, 'hooks'))}/`;
256
302
  if (!normalizedCommand.includes(normalizedHooksDir))
257
303
  return false;
258
304
  }
@@ -282,7 +328,7 @@ const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
282
328
  function projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptToken, runtime = 'generic', platform = process.platform, }) {
283
329
  if (!absoluteRunner || !scriptPath)
284
330
  return null;
285
- const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
331
+ const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
286
332
  // #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
287
333
  // quoted prefix (local installs) is already a valid shell token — only the
288
334
  // variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
@@ -340,7 +386,7 @@ function projectPathActionProjection({ mode = 'repair', targetDir, platform = pr
340
386
  let shellActions;
341
387
  if (isWin32) {
342
388
  const psTargetDir = escapePowerShellSingleQuoted(targetDir);
343
- const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir).replace(/\\/g, '/'));
389
+ const bashTargetDir = escapeSingleQuotedShellLiteral(posixNormalize(String(targetDir)));
344
390
  shellActions = [
345
391
  {
346
392
  label: 'PowerShell',
@@ -461,6 +507,109 @@ function execTool(program, args, opts = {}) {
461
507
  });
462
508
  return _spawnResult(result, program);
463
509
  }
510
+ /**
511
+ * Resolve the absolute path to gsd-tools.cjs relative to THIS module.
512
+ *
513
+ * This file compiles to gsd-core/bin/lib/shell-command-projection.cjs — a
514
+ * sibling of gsd-core/bin/gsd-tools.cjs — so the relative walk-up is stable
515
+ * regardless of install location (global/local/dev-repo layouts all ship
516
+ * gsd-core/bin/ as a unit).
517
+ */
518
+ function resolveGsdToolsPath() {
519
+ return node_path_1.default.resolve(__dirname, '..', 'gsd-tools.cjs');
520
+ }
521
+ /**
522
+ * Subprocess-shim dispatch to gsd-tools.cjs (ADR-1239 #2102 Stage 2).
523
+ *
524
+ * No fully-populated in-process command-routing hub exists anywhere in the
525
+ * tree — every `createHub()` caller (cjs-command-router-adapter.cts,
526
+ * phase-command-router.cts, command-routing-hub.cts's own tests) builds a
527
+ * single-family hub for its own narrow purpose. The ONLY dispatch path that
528
+ * covers the FULL family/subcommand surface is the gsd-tools.cjs CLI itself.
529
+ * This mirrors the SUBPROCESS-REUSE precedent already established for the
530
+ * OpenCode/Kilo hook bridge (see .opencode/plugins/gsd-core.js header:
531
+ * "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
532
+ * processes") — the same pattern, applied to command dispatch instead of
533
+ * hook dispatch.
534
+ *
535
+ * Output-flag choice (verified by direct invocation — see #2102 dispatch
536
+ * notes for the sample invocations): always pass `--raw` (undecorated,
537
+ * programmatically-consumable stdout on success) and `--json-errors` (a
538
+ * structured `{ok:false,reason,message}` JSON object on stderr, with a
539
+ * non-zero exit, instead of a free-text "Error: ..." line). Both are global
540
+ * flags accepted by every gsd-tools.cjs family/subcommand, so passing them
541
+ * unconditionally is safe for the full command surface.
542
+ *
543
+ * `family` maps 1:1 onto gsd-tools.cjs's first positional argv token;
544
+ * `subcommand` (when present) onto the second — e.g.
545
+ * `{family:'phase', subcommand:'add'}` → `gsd-tools.cjs phase add`. An empty
546
+ * `subcommand` is omitted entirely (some families, e.g. `config-path`, take
547
+ * no subcommand).
548
+ *
549
+ * NEVER throws. Degrades to `{ ok:false, ... }` on:
550
+ * - a missing/invalid "family" (validated locally, no subprocess spawned)
551
+ * - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
552
+ * - a wall-clock timeout (`timedOut:true`, mirroring the
553
+ * `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
554
+ * by worktree-safety.cts)
555
+ * - any other unanticipated throw from the underlying spawn (defensive
556
+ * try/catch — execTool itself is spawnSync-based and does not throw).
557
+ */
558
+ function dispatchGsdCommand({ family, subcommand, args = [], cwd, timeout = 30_000, gsdToolsPath, } = {}) {
559
+ if (typeof family !== 'string' || family.length === 0) {
560
+ return {
561
+ ok: false,
562
+ stdout: '',
563
+ stderr: 'dispatchGsdCommand requires a non-empty string "family".',
564
+ code: null,
565
+ timedOut: false,
566
+ };
567
+ }
568
+ const resolvedCwd = cwd || process.cwd();
569
+ const toolsPath = gsdToolsPath || resolveGsdToolsPath();
570
+ const argv = [
571
+ toolsPath,
572
+ family,
573
+ ...(subcommand ? [subcommand] : []),
574
+ ...(Array.isArray(args) ? args : []),
575
+ '--cwd', resolvedCwd,
576
+ '--raw',
577
+ '--json-errors',
578
+ ];
579
+ let result;
580
+ try {
581
+ result = execTool(process.execPath, argv, { cwd: resolvedCwd, timeout });
582
+ }
583
+ catch (e) {
584
+ // Defensive belt-and-suspenders: execTool is spawnSync-based and does not
585
+ // throw today, but a degraded result here keeps this seam's no-throw
586
+ // contract true even under an unanticipated future failure mode.
587
+ return {
588
+ ok: false,
589
+ stdout: '',
590
+ stderr: e instanceof Error ? e.message : String(e),
591
+ code: null,
592
+ timedOut: false,
593
+ };
594
+ }
595
+ // Mirrors the established `result.error && (result.error as
596
+ // NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
597
+ // narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
598
+ // to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
599
+ // trips a typescript-eslint no-unnecessary-type-assertion false positive for
600
+ // this exact narrowing shape (all of ErrnoException's extra fields over Error
601
+ // are optional).
602
+ const timedOut = result.signal === 'SIGTERM'
603
+ && result.error !== null
604
+ && result.error.code === 'ETIMEDOUT';
605
+ return {
606
+ ok: result.exitCode === 0 && !timedOut,
607
+ stdout: result.stdout,
608
+ stderr: result.stderr,
609
+ code: result.exitCode,
610
+ timedOut,
611
+ };
612
+ }
464
613
  function probeTty(opts = {}) {
465
614
  const platform = opts.platform ?? process.platform;
466
615
  if (platform === 'win32')
@@ -42,6 +42,7 @@ exports.runSmartEntry = runSmartEntry;
42
42
  const node_fs_1 = __importDefault(require("node:fs"));
43
43
  const node_path_1 = __importDefault(require("node:path"));
44
44
  const node_child_process_1 = require("node:child_process");
45
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
45
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
47
  const ioMod = require("./io.cjs");
47
48
  const { output } = ioMod;
@@ -274,9 +275,9 @@ function detectSignals(cwd, now = Date.now) {
274
275
  fmScalar(fm, body, 'progress', 'Progress');
275
276
  // Blockers list: `- <text>` items under a `## Blockers` heading.
276
277
  const blockers = [];
277
- const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only blockers section-collect in smart-entry.cts; mirrors state.cts (#1372), pending collectSection migration
278
- if (blockersMatch) {
279
- const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
278
+ const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
279
+ if (blockersSection) {
280
+ const items = blockersSection.body.match(/^-\s+(.+)$/gm) || [];
280
281
  for (const item of items)
281
282
  blockers.push(item.replace(/^-\s+/, '').trim());
282
283
  }
@@ -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 {