@opengsd/gsd-core 1.8.0 → 1.9.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 (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -16,41 +16,108 @@
16
16
  * detected. The instance→cli mapping lives HERE (single source; see the parity
17
17
  * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX).
18
18
  *
19
- * KNOWN_REVIEWER_SLUGS (post-review #2092): registry-derived, not a flat
20
- * hand-maintained array. Each capability-runtime descriptor that is a valid
21
- * reviewer CLI declares `runtime.hostBehaviors.reviewerCli: true`
22
- * (capabilities/<id>/capability.json); this module reads that flag off the
23
- * generated capability-registry.cjs at require-time. A handful of reviewer
24
- * CLIs are NOT install-time runtimes at all (no capabilities/<id>/ descriptor
25
- * exists) — those stay a small hardcoded tail:
26
- * - `gemini` — hook-event dialect name only (see runtime-hooks-surface.cts);
27
- * the Gemini CLI reviewer is not an installable runtime (#1928 folded
28
- * gemini into antigravity's descriptor).
29
- * - `coderabbit` / `ollama` / `lm_studio` / `llama_cpp` — third-party
30
- * review/model CLIs with no GSD install surface at all.
19
+ * KNOWN_REVIEWER_SLUGS (ADR-2782 D9, Phase 5a #2798): derived from declared
20
+ * `reviewer` bodies in the capability registry, not a hand-maintained tail.
21
+ * A capability of EITHER `role: "runtime"` (the six dual-purpose hosts —
22
+ * antigravity/claude/codex/cursor/opencode/qwen) or the lane-only
23
+ * `role: "reviewer"` (gemini/coderabbit/ollama/lm_studio/llama_cpp) may carry
24
+ * a `reviewer` body, and it is the body's `reviewer.slug` — NOT the capability
25
+ * id — that becomes the roster entry: the two differ for `lm-studio` (id) /
26
+ * `lm_studio` (slug) and `llama-cpp` (id) / `llama_cpp` (slug), ADR-2782's
27
+ * three-namespace trap (`id` must be kebab; `slug` keeps the shipped roster's
28
+ * snake form).
29
+ *
30
+ * `runtime.hostBehaviors.reviewerCli: true` survives as a DERIVED LEGACY ALIAS
31
+ * for one release (D9): a capability that only sets the flag (no `reviewer`
32
+ * body yet) still contributes its capability id as a slug, exactly as before
33
+ * this phase. Where a capability carries BOTH a declared body and the alias,
34
+ * the BODY WINS — that capability contributes only the body's slug, never
35
+ * both. Alias removal is a named later phase (#2801), not done here.
36
+ *
37
+ * Before this phase the five non-runtime reviewers (`gemini`, `coderabbit`,
38
+ * `ollama`, `lm_studio`, `llama_cpp`) had no `capabilities/<id>/` descriptor at
39
+ * all and were a hardcoded `NON_RUNTIME_REVIEWER_SLUGS` tail. Phase 5a gives
40
+ * each one a lane-only `role: "reviewer"` capability (ADR-2782 D3), so the
41
+ * tail is deleted outright. See
42
+ * `docs/adr/2782-reviewer-lane-capability-surface.md`.
31
43
  */
32
44
  Object.defineProperty(exports, "__esModule", { value: true });
33
45
  exports.INSTANCE_NAME_PATTERN = exports.KNOWN_REVIEWER_SLUGS = void 0;
46
+ exports.deriveReviewerSlugs = deriveReviewerSlugs;
34
47
  exports.normalizeConfiguredDefaultReviewers = normalizeConfiguredDefaultReviewers;
35
48
  exports.normalizeReviewerInstances = normalizeReviewerInstances;
36
49
  exports.resolveReviewerSelection = resolveReviewerSelection;
37
- const NON_RUNTIME_REVIEWER_SLUGS = [
38
- 'gemini',
39
- 'coderabbit',
40
- 'ollama',
41
- 'lm_studio',
42
- 'llama_cpp',
43
- ];
44
- function deriveRuntimeReviewerSlugs() {
45
- // eslint-disable-next-line @typescript-eslint/no-require-imports
46
- const registry = require('./capability-registry.cjs');
47
- const runtimes = registry.runtimes || {};
48
- return Object.keys(runtimes).filter((id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true);
50
+ /**
51
+ * Derive the reviewer-slug roster from a capability registry.
52
+ *
53
+ * Exported (rather than a require()-time-only side effect) so tests can
54
+ * exercise the derivation directly against a synthetic registry — the real
55
+ * roster below is a single call against the generated
56
+ * `capability-registry.cjs`, and `tests/review-lane-descriptor.test.cjs`'s
57
+ * `checkReviewerLaneParity` separately guards that the real roster still
58
+ * agrees with `src/review-lane-descriptor.cts` and the workflow.
59
+ *
60
+ * Reads `registry.capabilities` — every declared capability regardless of
61
+ * role — because a `role: "reviewer"` lane-only capability is never stored in
62
+ * `registry.runtimes` (that map is role:"runtime" only). Per capability: a
63
+ * declared `reviewer.slug` wins outright (D9) and the capability contributes
64
+ * ONLY that slug; only when there is no body does the legacy
65
+ * `hostBehaviors.reviewerCli` alias contribute the capability id instead. The
66
+ * result is collected into a Set (so a slug can never appear twice, even if
67
+ * two distinct capabilities somehow named the same one) and returned as a
68
+ * SORTED array, so the roster's order never depends on `Object.entries()`
69
+ * iteration / registry build order.
70
+ */
71
+ function deriveReviewerSlugs(registry) {
72
+ const capabilities = registry.capabilities || {};
73
+ const slugs = new Set();
74
+ for (const [capId, cap] of Object.entries(capabilities)) {
75
+ // Trim before the emptiness test: `length > 0` alone admits a whitespace-only
76
+ // slug (" ") verbatim into the roster, where it can never match a real lane
77
+ // but still occupies a roster entry. Unreachable through the checked-in
78
+ // registry today, but this function is EXPORTED for reuse and carries no
79
+ // other validation, so it should not depend on its caller's hygiene.
80
+ const rawSlug = cap?.reviewer?.slug;
81
+ const declaredSlug = typeof rawSlug === 'string' ? rawSlug.trim() : '';
82
+ if (declaredSlug.length > 0) {
83
+ slugs.add(declaredSlug);
84
+ continue; // the body wins — never ALSO add this capability's alias below.
85
+ }
86
+ if (cap?.runtime?.hostBehaviors?.reviewerCli === true) {
87
+ slugs.add(capId);
88
+ }
89
+ }
90
+ return [...slugs].sort();
49
91
  }
50
- exports.KNOWN_REVIEWER_SLUGS = [
51
- ...deriveRuntimeReviewerSlugs(),
52
- ...NON_RUNTIME_REVIEWER_SLUGS,
53
- ];
92
+ /**
93
+ * The roster, derived once at module load.
94
+ *
95
+ * GUARDED, because this runs at `require()` time: an uncaught throw here does not
96
+ * degrade reviewer selection, it breaks `require()` of this module for EVERY
97
+ * consumer. The sibling `capability-trust.cjs` already holds this line — its
98
+ * `discloseExecutableSurfaces` is documented "TOTAL: never throws, for any
99
+ * manifest shape" and wrapped accordingly — and this module handles the same
100
+ * class of registry-derived input, so it should not be the asymmetric one.
101
+ *
102
+ * A malformed registry yields an EMPTY roster rather than a crash. That is a
103
+ * visible degradation, not a silent one: under ADR-2782 D4 an explicitly
104
+ * requested reviewer that is unavailable is an ERROR, so `/gsd:review --claude`
105
+ * against an empty roster fails loudly instead of quietly reviewing with nobody.
106
+ *
107
+ * Unreachable through the checked-in registry today (it is generated, JSON-sourced
108
+ * and code-reviewed, so it cannot carry a getter or a Proxy). Guarded anyway —
109
+ * reachability analysis is not a contract, and the next caller should not have to
110
+ * redo it.
111
+ */
112
+ exports.KNOWN_REVIEWER_SLUGS = (() => {
113
+ try {
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ return deriveReviewerSlugs(require('./capability-registry.cjs'));
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ })();
54
121
  /** Instance names are lowercase slugs that must not shadow a built-in slug. */
55
122
  exports.INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
56
123
  function normalizeConfiguredDefaultReviewers(rawValue) {
@@ -164,12 +231,27 @@ function resolveReviewerSelection(input) {
164
231
  let selected = [];
165
232
  if (explicitFlags.size > 0) {
166
233
  source = 'explicit_flags';
234
+ // ADR-2782 D4: absent-safe governs DISCOVERY, never explicit selection.
235
+ // Not finding a lane nobody asked for is normal; failing to run a lane
236
+ // somebody asked for is an error. Every miss used to be an `info`, so a
237
+ // PARTIAL miss (`--gemini --qwen` with qwen absent) ran the review with a
238
+ // thinner reviewer set while present_results reported success — "a cross-AI
239
+ // review that silently drops a lane is blind in one eye" (review.md).
240
+ // A total miss already errored, but only as a side effect of the selected
241
+ // set being empty; the partial case had no signal at all.
242
+ const priorErrorCount = errors.length;
167
243
  selected = [...explicitFlags].filter((slug) => detected.has(slug));
168
- const missing = [...explicitFlags].filter((slug) => !detected.has(slug));
169
- if (missing.length > 0) {
170
- infos.push(`explicit reviewers missing on host: ${missing.join(', ')}`);
244
+ // Sorted so the error order does not depend on flag order on the command line.
245
+ const missing = [...explicitFlags].filter((slug) => !detected.has(slug)).sort();
246
+ for (const slug of missing) {
247
+ errors.push(`explicit reviewer '${slug}' is not available on this host`);
171
248
  }
172
- if (selected.length === 0 && errors.length === 0) {
249
+ // Guarded on the error count as it stood BEFORE this branch, so the
250
+ // aggregate message fires under exactly the conditions it did previously.
251
+ // Guarding on `errors.length` would let the per-slug errors just pushed
252
+ // suppress it, silently dropping a message this change did not intend to
253
+ // remove.
254
+ if (selected.length === 0 && priorErrorCount === 0) {
173
255
  errors.push('no selected reviewers are available for explicit flags');
174
256
  }
175
257
  }
@@ -31,6 +31,9 @@ roadmapPhaseLookupSources, } = phaseIdModule;
31
31
  const planningWorkspace = require("./planning-workspace.cjs");
32
32
  const { planningDir } = planningWorkspace;
33
33
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const unusableInputMod = require("./unusable-input.cjs");
36
+ const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
34
37
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
35
38
  // ─── Roadmap milestone scoping ───────────────────────────────────────────────
36
39
  /**
@@ -271,10 +274,18 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
271
274
  const normalizedPhase = stripProjectCodePrefix(phaseNum);
272
275
  if (/^999(?:\.|$)/.test(normalizedPhase))
273
276
  return null;
274
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
275
- if (!node_fs_1.default.existsSync(roadmapPath))
276
- return null;
277
+ // Resolved INSIDE the try for the same reason as getMilestoneInfo below: planningDir
278
+ // throws a plain Error for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and resolving
279
+ // it outside let that escape uncaught, crashing every caller for a malformed workstream
280
+ // name. ADR-227 is explicit that throwing breaks pipeline continuity, and this read path
281
+ // has no reason to be the exception -- it already degrades to null for every other
282
+ // failure. Absence still returns null before any diagnostic, and when the path never
283
+ // resolved there is nothing to name.
284
+ let roadmapPath;
277
285
  try {
286
+ roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
287
+ if (!node_fs_1.default.existsSync(roadmapPath))
288
+ return null;
278
289
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
279
290
  if (roadmapRaw === null)
280
291
  throw new Error('missing');
@@ -301,10 +312,33 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
301
312
  }
302
313
  return null;
303
314
  }
304
- catch {
315
+ catch (err) {
316
+ // Absence already returned above via existsSync; anything caught here is a read fault
317
+ // or the synthetic missing-marker. The null is preserved exactly either way.
318
+ if (roadmapPath !== undefined)
319
+ reportUnreadableRoadmap(err, roadmapPath);
305
320
  return null;
306
321
  }
307
322
  }
323
+ /**
324
+ * Report a ROADMAP.md that exists but could not be read (#1881, ADR-1411).
325
+ *
326
+ * The discriminator is the errno, and it matters in the SILENT direction.
327
+ * platformReadSync returns null for ENOENT and both callers convert that null into a
328
+ * synthetic Error carrying no code, which lands in the same catch as a real EACCES.
329
+ * Reporting unconditionally here would flag every project that has no ROADMAP.md yet --
330
+ * every brand-new project -- as corrupt. A genuine read fault always carries an errno;
331
+ * absence never does.
332
+ *
333
+ * The parse itself is regex over text and cannot throw, so anything reaching a catch is
334
+ * either a read fault or that synthetic absence marker. Nothing else gets here.
335
+ */
336
+ function reportUnreadableRoadmap(err, roadmapPath) {
337
+ const code = err?.code;
338
+ if (typeof code !== 'string')
339
+ return;
340
+ warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
341
+ }
308
342
  /**
309
343
  * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
310
344
  * milestone-name capture. Markdown headings commonly take the shape
@@ -318,8 +352,16 @@ function stripLeadingDelimiter(s) {
318
352
  return s.replace(/^[\s—–:-]+/, '').trim();
319
353
  }
320
354
  function getMilestoneInfo(cwd) {
355
+ // Declared here but RESOLVED INSIDE the try, so the catch can name the file without
356
+ // moving planningDir() out of the protected region. planningDir throws a plain Error
357
+ // for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and hoisting the call let that
358
+ // escape uncaught — breaking the invariant #2245 relies on, that this function never
359
+ // throws. When the path never resolved there is nothing to name, so the diagnostic is
360
+ // skipped and the default is returned exactly as before.
361
+ let roadmapPath;
321
362
  try {
322
- const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
363
+ roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
364
+ const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
323
365
  if (roadmap === null)
324
366
  throw new Error('missing');
325
367
  let stateVersion = null;
@@ -388,7 +430,13 @@ function getMilestoneInfo(cwd) {
388
430
  name: 'milestone',
389
431
  };
390
432
  }
391
- catch {
433
+ catch (err) {
434
+ // This function has no existsSync guard, so an absent ROADMAP arrives here too, as a
435
+ // synthetic Error with no errno. Only a real read fault is reported; the populated
436
+ // default is returned unchanged either way, and a plausible-looking default needs the
437
+ // diagnostic more than an empty sentinel does, not less (ADR-1411).
438
+ if (roadmapPath !== undefined)
439
+ reportUnreadableRoadmap(err, roadmapPath);
392
440
  return { version: 'v1.0', name: 'milestone' };
393
441
  }
394
442
  }
@@ -137,10 +137,16 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
137
137
  // comparison; unrecognized values are preserved verbatim for forward-compat.
138
138
  const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
139
139
  const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
140
- // Extract success criteria as structured array
141
- const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
140
+ // Extract success criteria as structured array. A criterion may wrap onto extra
141
+ // indented lines (no `N.` prefix); those continuations must fold INTO their
142
+ // criterion, not end the run (#2522 — the old `(?:\s*\d+\.\s*[^\n]+)+` broke on a
143
+ // wrapped line, truncating it and silently dropping every criterion below it).
144
+ // `\n*` before each numbered line keeps blank-line-separated criteria working.
145
+ const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\n*[ \t]*\d+\.[^\n]*\n?(?:[ \t]+(?!\d+\.)[^\n]*\n?)*)+)/i);
142
146
  const success_criteria = criteriaMatch
143
- ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
147
+ ? criteriaMatch[1].trim().split(/\n+(?=[ \t]*\d+\.)/)
148
+ .map(entry => entry.replace(/^\s*\d+\.\s*/, '').replace(/\s*\n\s*/g, ' ').trim())
149
+ .filter(Boolean)
144
150
  : [];
145
151
  return {
146
152
  found: true,
@@ -658,7 +664,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
658
664
  const planPath = node_path_1.default.join(node_path_1.default.resolve(cwd, phaseInfo.directory), planFile);
659
665
  try {
660
666
  const content = node_fs_1.default.readFileSync(planPath, 'utf-8');
661
- const fm = extractFrontmatter(content);
667
+ const fm = extractFrontmatter(content, planPath);
662
668
  const wave = parseInt(fm.wave, 10) || 1;
663
669
  const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
664
670
  const truths = parseMustHavesBlock(content, 'truths') || [];
@@ -518,6 +518,28 @@ function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cm
518
518
  const normalizedBody = convertGsdCommandReferencesToKimiSkillInvocations(frontmatter ? body : content, names);
519
519
  return `---\nname: ${kimiSkillName}\ndescription: ${yamlQuote(toSingleLine(description))}\n---\nInvoke this Kimi skill with \`/skill:${kimiSkillName}\`.\n\n${normalizedBody}`;
520
520
  }
521
+ /**
522
+ * Convert a Claude command-markdown source into a Kimi Code Agent Skill.
523
+ *
524
+ * Kimi Code (Moonshot's Node CLI) uses the standard Agent Skills format —
525
+ * a directory containing SKILL.md with frontmatter (name/description) and
526
+ * body — auto-discovered from `~/.kimi-code/skills/` (per Kimi Code docs:
527
+ * "Agent Skills is an open format for adding specialized knowledge and
528
+ * workflows to AI agents"). The invocation prefix is `/skill:<name>`,
529
+ * identical to Python kimi-cli.
530
+ *
531
+ * Today the output is byte-identical to `convertClaudeCommandToKimiSkill`
532
+ * (the Python kimi-cli converter) because both products consume the same
533
+ * Agent Skills format + `/skill:` invocation. The distinct function name
534
+ * lets a future divergence land cleanly if Kimi Code's skill format evolves
535
+ * independently of Python kimi-cli.
536
+ *
537
+ * Registered as `convertClaudeCommandToKimiCodeSkill` in the capabilities/
538
+ * kimi-code/capability.json `artifactLayout` `converter` field (#2454 PR 2).
539
+ */
540
+ function convertClaudeCommandToKimiCodeSkill(content, skillName, _runtime = null, cmdNames = null) {
541
+ return convertClaudeCommandToKimiSkill(content, skillName, _runtime, cmdNames);
542
+ }
521
543
  const KIMI_CANONICAL_GSD_AGENT_RE = /^gsd-[a-z0-9-]+$/;
522
544
  function parseKimiAgentSource(source) {
523
545
  if (typeof source === 'string') {
@@ -1391,10 +1413,14 @@ Typed mapping (agent_type-capable schema only):
1391
1413
  inherited, or unsupported values; do not invent one-off effort literals in
1392
1414
  workflow prose.
1393
1415
  - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
1394
- - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct Codex mapping.
1395
- Codex \`spawn_agent\` does not create or bind a git worktree automatically.
1396
- Workflows that require this isolation must fail closed or use an explicit
1397
- manual worktree protocol before spawning (#3360).
1416
+ - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
1417
+ but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
1418
+ \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
1419
+ creates the worktree and process-spawns the executor into it with
1420
+ \`codex exec --cd <dir>\`, performing every git operation on the executor's behalf
1421
+ (its \`workspace-write\` sandbox makes \`.git\` read-only). Workflows must therefore
1422
+ never fabricate a manual worktree protocol — route through the negotiated
1423
+ isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
1398
1424
 
1399
1425
  Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
1400
1426
  When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
@@ -2603,6 +2629,7 @@ module.exports = {
2603
2629
  convertClaudeCommandToAntigravitySkill,
2604
2630
  convertClaudeCommandToClaudeSkill,
2605
2631
  convertClaudeCommandToKimiSkill,
2632
+ convertClaudeCommandToKimiCodeSkill,
2606
2633
  buildKimiAgentArtifacts,
2607
2634
  convertClaudeToCursorMarkdown,
2608
2635
  convertClaudeCommandToCursorSkill,
@@ -316,7 +316,7 @@ function dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry)
316
316
  default:
317
317
  throw new TypeError(`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`);
318
318
  }
319
- if (typeof entry.home === 'string' && entry.home !== '') {
319
+ if (scope === 'global' && typeof entry.home === 'string' && entry.home !== '') {
320
320
  result.home = node_path_1.default.join(node_os_1.default.homedir(), entry.home);
321
321
  }
322
322
  return result;
@@ -149,6 +149,81 @@ function atomicWriteFileSync(target, data, options) {
149
149
  }
150
150
  }
151
151
  // ---------------------------------------------------------------------------
152
+ // CommonJS package.json marker for staged .js hook scripts (#2717)
153
+ //
154
+ // Node resolves the nearest package.json walking up from a .js file. When a
155
+ // runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any
156
+ // parent — declares {"type":"module"}, Node loads GSD's staged CommonJS hook
157
+ // scripts as ESM and every require() fails with "require is not defined",
158
+ // silently disabling that runtime's lifecycle hooks.
159
+ //
160
+ // installSharedHooksBundle writes this marker for the 12 runtimes that go
161
+ // through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall)
162
+ // and codex (the !isCodex gate) stage their .js hooks via the dedicated paths
163
+ // below and never reached it. These helpers decouple the marker write from the
164
+ // shared bundle so any code path that stages .js hooks can ensure the marker
165
+ // lands in the SAME directory as the scripts (#2717).
166
+ //
167
+ // The marker content is byte-identical to installSharedHooksBundle's
168
+ // (bin/install.js installSharedHooksBundle): {"type":"commonjs"}\n.
169
+ // ---------------------------------------------------------------------------
170
+ /** The exact marker content GSD writes, matching installSharedHooksBundle. */
171
+ const COMMONJS_MARKER_CONTENT = '{"type":"commonjs"}\n';
172
+ /**
173
+ * Ensure a `package.json` forcing CommonJS mode exists in `dir` (the directory
174
+ * holding GSD-staged `.js` hook scripts). Idempotent: a no-op if the marker is
175
+ * already present with GSD's content. Overwrites only when the file is absent
176
+ * or already carries GSD's exact marker — it never clobbers a distinct
177
+ * user-authored package.json (it leaves such a file in place; the user owns it).
178
+ *
179
+ * @param dir - absolute path to the directory holding the staged .js hooks
180
+ * @returns `true` if the marker is present after the call (written or already there)
181
+ */
182
+ function ensureCommonJsMarker(dir) {
183
+ const markerPath = node_path_1.default.join(dir, 'package.json');
184
+ try {
185
+ if (node_fs_1.default.existsSync(markerPath)) {
186
+ const existing = node_fs_1.default.readFileSync(markerPath, 'utf8');
187
+ // Already GSD's marker (tolerant of trailing-whitespace variants) — done.
188
+ if (existing.trim() === '{"type":"commonjs"}')
189
+ return true;
190
+ // A distinct package.json the user owns — do NOT clobber. The hook will
191
+ // load as whatever type the user declared; that is the user's choice.
192
+ return false;
193
+ }
194
+ node_fs_1.default.writeFileSync(markerPath, COMMONJS_MARKER_CONTENT);
195
+ return true;
196
+ }
197
+ catch {
198
+ // Best-effort: a marker write failure must not fail the whole install.
199
+ return false;
200
+ }
201
+ }
202
+ /**
203
+ * Remove the CommonJS marker from `dir` on uninstall — but ONLY if it carries
204
+ * GSD's exact marker content. A user-authored package.json is never deleted.
205
+ * Mirrors the kimi uninstall guard in bin/install.js.
206
+ *
207
+ * @param dir - absolute path to the directory that held the staged .js hooks
208
+ * @returns `true` if a GSD-owned marker was removed
209
+ */
210
+ function removeCommonJsMarkerIfGsdOwned(dir) {
211
+ const markerPath = node_path_1.default.join(dir, 'package.json');
212
+ try {
213
+ if (!node_fs_1.default.existsSync(markerPath))
214
+ return false;
215
+ const content = node_fs_1.default.readFileSync(markerPath, 'utf8').trim();
216
+ if (content === '{"type":"commonjs"}') {
217
+ node_fs_1.default.unlinkSync(markerPath);
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ // ---------------------------------------------------------------------------
152
227
  // parseTomlValue + findMultilineBasicStringClose
153
228
  // (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state)
154
229
  // ---------------------------------------------------------------------------
@@ -956,6 +1031,48 @@ function writeCursorHooksJson(targetDir, src, opts) {
956
1031
  installedScripts.add(script);
957
1032
  }
958
1033
  }
1034
+ // Stage the hooks/lib/ helpers the staged scripts require (#2587). Cursor sets
1035
+ // hostBehaviors.skipSharedHooksInstall, so it never reaches the installer's
1036
+ // bulk hooks/lib copy — without this, a script requiring './lib/…' would throw
1037
+ // MODULE_NOT_FOUND at load, BEFORE its own try/catch, and wedge every Cursor
1038
+ // session on the one runtime these hooks exist for. Driven off what the staged
1039
+ // scripts actually require so a future helper cannot be silently omitted.
1040
+ const requiredLibFiles = new Set();
1041
+ for (const script of installedScripts) {
1042
+ const staged = node_fs_1.default.readFileSync(node_path_1.default.join(hooksDir, script), 'utf8');
1043
+ // Tolerant of interior whitespace and either quote style: a hook author
1044
+ // writing `require( "./lib/x.js" )` must still get its helper staged, since
1045
+ // a miss here surfaces as MODULE_NOT_FOUND at hook load, not at install.
1046
+ const re = /require\(\s*['"]\.\/lib\/([A-Za-z0-9._-]+)['"]\s*\)/g;
1047
+ let m;
1048
+ while ((m = re.exec(staged)) !== null)
1049
+ requiredLibFiles.add(m[1]);
1050
+ }
1051
+ if (requiredLibFiles.size > 0) {
1052
+ const srcLibDir = node_path_1.default.join(srcHooksDir, 'lib');
1053
+ const destLibDir = node_path_1.default.join(hooksDir, 'lib');
1054
+ node_fs_1.default.mkdirSync(destLibDir, { recursive: true });
1055
+ for (const libFile of requiredLibFiles) {
1056
+ const libSrc = node_path_1.default.join(srcLibDir, libFile);
1057
+ if (!node_fs_1.default.existsSync(libSrc)) {
1058
+ // FAIL LOUD. Skipping here would ship hook scripts whose top-level
1059
+ // require() throws before their own try/catch, wedging every session —
1060
+ // and the install would still exit 0, so nobody would know until a user
1061
+ // hit it. A missing helper source is a packaging bug; surface it.
1062
+ throw new Error(`hooks/lib/${libFile} is required by a staged Cursor hook but is missing from ${srcLibDir}. `
1063
+ + 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.');
1064
+ }
1065
+ let libContent = node_fs_1.default.readFileSync(libSrc, 'utf8');
1066
+ libContent = libContent.replace(/gsd:/gi, 'gsd-');
1067
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destLibDir, libFile), libContent);
1068
+ }
1069
+ }
1070
+ // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1071
+ // scripts. Cursor sets skipSharedHooksInstall, so it never reaches
1072
+ // installSharedHooksBundle (the only other writer of this marker); without
1073
+ // it, a ~/.cursor/package.json declaring {"type":"module"} makes Node load
1074
+ // these require()-using scripts as ESM and every Cursor hook fails silently.
1075
+ ensureCommonJsMarker(hooksDir);
959
1076
  const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
960
1077
  const commands = {};
961
1078
  for (const ev of events) {
@@ -987,6 +1104,14 @@ function removeCursorHooksJson(targetDir) {
987
1104
  const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
988
1105
  if (!hasAnyEvents) {
989
1106
  node_fs_1.default.unlinkSync(hooksJsonPath);
1107
+ // #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
1108
+ // only if it still carries GSD's exact content (a user-authored
1109
+ // package.json is never deleted). Best-effort: a failure here must not
1110
+ // mask the hooks.json removal above.
1111
+ try {
1112
+ removeCommonJsMarkerIfGsdOwned(node_path_1.default.join(targetDir, 'hooks'));
1113
+ }
1114
+ catch { /* leave it */ }
990
1115
  return { changed: true };
991
1116
  }
992
1117
  }
@@ -1136,6 +1261,12 @@ function writeWindsurfHooksJson(targetDir, src, opts) {
1136
1261
  installedScripts.add(script);
1137
1262
  }
1138
1263
  }
1264
+ // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1265
+ // scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
1266
+ // installSharedHooksBundle (the only other writer of this marker); without
1267
+ // it, a config-root package.json declaring {"type":"module"} makes Node load
1268
+ // these require()-using scripts as ESM and the Windsurf hooks fail silently.
1269
+ ensureCommonJsMarker(hooksDir);
1139
1270
  const hookOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
1140
1271
  const commands = {};
1141
1272
  for (const ev of WINDSURF_HOOK_EVENTS) {
@@ -1174,6 +1305,13 @@ function removeWindsurfHooksJson(targetDir) {
1174
1305
  const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
1175
1306
  if (!hasAnyEvents) {
1176
1307
  node_fs_1.default.unlinkSync(hooksJsonPath);
1308
+ // #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
1309
+ // only if it still carries GSD's exact content (a user-authored
1310
+ // package.json is never deleted). Best-effort.
1311
+ try {
1312
+ removeCommonJsMarkerIfGsdOwned(node_path_1.default.join(targetDir, 'hooks'));
1313
+ }
1314
+ catch { /* leave it */ }
1177
1315
  return { changed: true };
1178
1316
  }
1179
1317
  }
@@ -1963,6 +2101,8 @@ module.exports = {
1963
2101
  GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1964
2102
  GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1965
2103
  GSD_WINDSURF_HOOK_SCRIPTS,
2104
+ ensureCommonJsMarker,
2105
+ removeCommonJsMarkerIfGsdOwned,
1966
2106
  GSD_WINDSURF_HOOK_MARKER,
1967
2107
  // Copilot
1968
2108
  buildCopilotHookConfig,
@@ -40,6 +40,7 @@ const FALLBACK_ALIASES = {
40
40
  qwen: ['qwen', 'qwen-code', 'qwen-cli'],
41
41
  hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
42
42
  kimi: ['kimi'],
43
+ 'kimi-code': ['kimi-code', 'kimicode', 'kimi_code'],
43
44
  codebuddy: ['codebuddy', 'codebuddy-cli'],
44
45
  cline: ['cline', 'cline-cli'],
45
46
  };
@@ -248,6 +249,7 @@ const RUNTIME_LABELS = {
248
249
  qwen: 'Qwen Code',
249
250
  hermes: 'Hermes Agent',
250
251
  kimi: 'Kimi CLI',
252
+ 'kimi-code': 'Kimi Code',
251
253
  codebuddy: 'CodeBuddy',
252
254
  cline: 'Cline',
253
255
  zcode: 'ZCode',
@@ -303,6 +305,7 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS = {
303
305
  codebuddy: "'.codebuddy'",
304
306
  cline: "'.cline'",
305
307
  kimi: "'.config', 'agents'",
308
+ 'kimi-code': "'.kimi-code'",
306
309
  zcode: "'.zcode'",
307
310
  // pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
308
311
  // parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
@@ -337,8 +340,18 @@ function getGlobalConfigHomeFragment(runtime) {
337
340
  // folds the shared-hooks-install skip).
338
341
  const RUNTIME_FLAG_IDS = Object.freeze([
339
342
  'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
340
- 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode', 'pi',
343
+ 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'kimi-code', 'zcode', 'pi',
341
344
  ]);
345
+ /**
346
+ * Convert a runtime id (kebab-case, e.g. 'kimi-code') to its `is<Foo>` flag
347
+ * name in PascalCase (e.g. 'isKimiCode'). The first letter is capitalised and
348
+ * every `-[a-z]` boundary is folded to its uppercase twin. Single-word ids
349
+ * (the prior 16: opencode, kilo, codex, …) are unaffected — only hyphenated
350
+ * ids like 'kimi-code' (#2454) hit the folding branch.
351
+ */
352
+ function runtimeIdToFlagName(id) {
353
+ return 'is' + id.charAt(0).toUpperCase() + id.slice(1).replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
354
+ }
342
355
  /**
343
356
  * Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
344
357
  * id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
@@ -349,7 +362,7 @@ const RUNTIME_FLAG_IDS = Object.freeze([
349
362
  function runtimeFlags(runtime) {
350
363
  const flags = {};
351
364
  for (const id of RUNTIME_FLAG_IDS) {
352
- flags['is' + id.charAt(0).toUpperCase() + id.slice(1)] = runtime === id;
365
+ flags[runtimeIdToFlagName(id)] = runtime === id;
353
366
  }
354
367
  return Object.freeze(flags);
355
368
  }
@@ -224,7 +224,7 @@ function readStateFile(statePath) {
224
224
  catch {
225
225
  return null;
226
226
  }
227
- const fm = extractFrontmatter(content);
227
+ const fm = extractFrontmatter(content, statePath);
228
228
  const body = content.replace(/^---[\s\S]*?---\s*/, '');
229
229
  return { fm, body };
230
230
  }