@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
@@ -105,6 +105,109 @@ function isExcludedPath(filePath) {
105
105
  );
106
106
  }
107
107
 
108
+ // Kimi CLI delivers the tool vocabulary the matcher was registered with —
109
+ // the scanner's Kimi matcher is 'ReadFile' (runtime-hooks-surface.cts), so
110
+ // tool_name arrives as 'ReadFile' (possibly module-qualified) and tool_input
111
+ // carries `path` (kimi-cli src/kimi_cli/tools/file/read.py Params), not
112
+ // `file_path`. Without normalization the SCANNED_TOOLS check below never
113
+ // matches on Kimi and the scanner is silently dormant (#2304).
114
+ //
115
+ // SCOPE ON KIMI (#2547): normalization makes this scanner's CHECKS run on
116
+ // Kimi. It does NOT make its block effective there. This is a PostToolUse
117
+ // hook, and kimi-cli's dispatch never inspects PostToolUse hook results —
118
+ // src/kimi_cli/soul/toolset.py fires them via asyncio.create_task() and
119
+ // returns the ToolResult without awaiting, whereas PreToolUse results are
120
+ // awaited and honoured. So `security.injection_blocking` cannot take effect
121
+ // on Kimi regardless of the shape emitted below; reshaping the output would
122
+ // not change that. Blocking prompt injection on Kimi needs a PreToolUse
123
+ // mechanism, or an upstream kimi-cli change. Do not describe this hook as
124
+ // "engaged" or "blocking" on Kimi. This block is
125
+ // kept byte-identical with the copies in gsd-prompt-guard.js,
126
+ // gsd-read-guard.js, and gsd-worktree-path-guard.js — a parity test binds
127
+ // them (tests/kimi-guard-normalization-parity.test.cjs). Inlined per guard
128
+ // (not hooks/lib/): hook scripts are staged as standalone files, and a
129
+ // sibling require is a staging dependency that can fail silently.
130
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
131
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
132
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
133
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
134
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
135
+ function normalizeKimiPayload(data) {
136
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
137
+ // payloads reached the `data.tool_name` read below and threw — falsifying
138
+ // this function's own "total over the inputs JSON can express" claim, which
139
+ // property (e) now tests directly. Harmless in practice (a null payload has
140
+ // nothing to guard, and the throw landed in the same fail-open catch as the
141
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
142
+ if (data === null || typeof data !== 'object') return data;
143
+ const raw = data.tool_name;
144
+ if (typeof raw !== 'string') return data;
145
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
146
+ if (!mapped) return data;
147
+ data.tool_name = mapped;
148
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
149
+ data.tool_response = data.tool_output;
150
+ }
151
+ const input = data.tool_input;
152
+ if (input && typeof input === 'object') {
153
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
154
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
155
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
156
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
157
+ // model's raw json-parsed
158
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
159
+ // tool.call() — after the hook has already decided. So a `file_path` in a
160
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
161
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
162
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
163
+ // guard reading an empty string and exiting 0, while the identical write
164
+ // without the extra key blocked — a bypass needing no crash at all. The same
165
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
166
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
167
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
168
+ // elsewhere, reached through the guard's own read rather than through
169
+ // normalization. Overwriting can only ever narrow what a guard inspects to
170
+ // the path that will actually be written, so it cannot under-block.
171
+ if (typeof input.path === 'string') {
172
+ input.file_path = input.path;
173
+ }
174
+ const edits = Array.isArray(input.edit) ? input.edit
175
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
176
+ if (edits.length) {
177
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
178
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
179
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
180
+ // reached each guard's outer `catch { process.exit(0) }` and silently
181
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
182
+ // never threw — `('x').old` is a legal read yielding undefined.)
183
+ //
184
+ // The String() coercion is guarded for the same reason: `{"toString":
185
+ // null}` is valid JSON that throws "Cannot convert object to primitive
186
+ // value", which is the identical crash-to-allow with a different
187
+ // trigger. Degrading only the non-coercible entry to '' keeps
188
+ // stringification intact for every value that CAN coerce (numbers,
189
+ // arrays, plain objects), so nothing downstream — including
190
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
191
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
192
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
193
+ // `path` decision above rather than merely filling in when the field
194
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
195
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
196
+ // no `old_string`/`new_string` at all, so either field appearing in a
197
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
198
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
199
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
200
+ // reading '' and exiting at its `if (!content)` before it ever saw the
201
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
202
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
203
+ // it: a benign non-empty string shadows just as effectively as ''.
204
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
205
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
206
+ }
207
+ }
208
+ return data;
209
+ }
210
+
108
211
  let inputBuf = '';
109
212
  const stdinTimeout = setTimeout(() => process.exit(0), 5000);
110
213
  process.stdin.setEncoding('utf8');
@@ -112,7 +215,7 @@ process.stdin.on('data', chunk => { inputBuf += chunk; });
112
215
  process.stdin.on('end', () => {
113
216
  clearTimeout(stdinTimeout);
114
217
  try {
115
- const data = JSON.parse(inputBuf);
218
+ const data = normalizeKimiPayload(JSON.parse(inputBuf));
116
219
 
117
220
  const toolName = data.tool_name;
118
221
  const SCANNED_TOOLS = new Set(['Read', 'WebFetch', 'WebSearch']);
@@ -123,7 +226,11 @@ process.stdin.on('end', () => {
123
226
  // Source label + path-exclusion (path-exclusion applies to file reads only)
124
227
  let source;
125
228
  if (toolName === 'Read') {
126
- source = data.tool_input?.file_path || '';
229
+ // #2595 (review Major 3, sibling sweep): typed read — a non-string
230
+ // threw inside isExcludedPath()'s .replace() into the outer catch.
231
+ source = typeof data.tool_input?.file_path === 'string'
232
+ ? data.tool_input.file_path
233
+ : '';
127
234
  if (!source) process.exit(0);
128
235
  if (isExcludedPath(source)) process.exit(0);
129
236
  } else if (toolName === 'WebFetch') {
@@ -143,12 +143,15 @@ function readGsdState(dir) {
143
143
  function parseStateMd(content) {
144
144
  const state = {};
145
145
 
146
- // YAML frontmatter between --- markers (anchored at file start)
147
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
146
+ // YAML frontmatter between --- markers (anchored at file start).
147
+ // #2754: \r?\n (not literal \n) so a CRLF STATE.md (Windows-authored) parses
148
+ // identically to LF — pre-fix the literal-\n fence dropped the ENTIRE block.
149
+ // Mirrors the CRLF-safe extractFrontmatter in src/frontmatter.cts.
150
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
148
151
  if (fmMatch) {
149
152
  const fm = fmMatch[1];
150
153
  // Top-level scalar key: value
151
- for (const line of fm.split('\n')) {
154
+ for (const line of fm.split(/\r?\n/)) {
152
155
  const m = line.match(/^(\w+):\s*(.+)/);
153
156
  if (!m) continue;
154
157
  const [, key, val] = m;
@@ -169,10 +172,10 @@ function parseStateMd(content) {
169
172
  const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
170
173
  state.nextPhases = items.length > 0 ? items : null;
171
174
  } else {
172
- const npBlockMatch = fm.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m);
175
+ const npBlockMatch = fm.match(/^next_phases:\s*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)*)/m);
173
176
  if (npBlockMatch) {
174
177
  const items = npBlockMatch[1]
175
- .split('\n')
178
+ .split(/\r?\n/)
176
179
  .map(line => line.match(/^[ \t]*-[ \t]*(.+)$/))
177
180
  .filter(Boolean)
178
181
  .map(m => m[1].trim().replace(/^["']|["']$/g, ''))
@@ -181,7 +184,7 @@ function parseStateMd(content) {
181
184
  }
182
185
  }
183
186
  // progress nested block: completed_phases / total_phases / percent (2-space indent)
184
- const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m);
187
+ const progMatch = fm.match(/^progress:\s*\r?\n((?:[ \t]+\w+:.+\r?\n?)+)/m);
185
188
  if (progMatch) {
186
189
  const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m);
187
190
  const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m);
@@ -78,6 +78,100 @@ function workflowGuardEnabled(cwd) {
78
78
  }
79
79
  }
80
80
 
81
+ // Kimi CLI delivers the tool vocabulary the matcher was registered with —
82
+ // this guard's Kimi matcher is 'Shell|WriteFile|StrReplaceFile'
83
+ // (runtime-hooks-surface.cts), so tool_name arrives in Kimi vocabulary
84
+ // (possibly module-qualified) and neither the Bash branch nor the
85
+ // Write/Edit/MultiEdit allowlist below ever matched on Kimi (#2304).
86
+ // kimi-cli's Shell.Params names its field `command`
87
+ // (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash, so the
88
+ // Shell leg needs only the name mapping. This block is kept byte-identical
89
+ // with the copies in gsd-prompt-guard.js, gsd-read-guard.js,
90
+ // gsd-worktree-path-guard.js, and gsd-read-injection-scanner.js — a parity
91
+ // test binds them (tests/kimi-guard-normalization-parity.test.cjs). Inlined
92
+ // per guard (not hooks/lib/): hook scripts are staged as standalone files,
93
+ // and a sibling require is a staging dependency that can fail silently.
94
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
95
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
96
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
97
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
98
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
99
+ function normalizeKimiPayload(data) {
100
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
101
+ // payloads reached the `data.tool_name` read below and threw — falsifying
102
+ // this function's own "total over the inputs JSON can express" claim, which
103
+ // property (e) now tests directly. Harmless in practice (a null payload has
104
+ // nothing to guard, and the throw landed in the same fail-open catch as the
105
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
106
+ if (data === null || typeof data !== 'object') return data;
107
+ const raw = data.tool_name;
108
+ if (typeof raw !== 'string') return data;
109
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
110
+ if (!mapped) return data;
111
+ data.tool_name = mapped;
112
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
113
+ data.tool_response = data.tool_output;
114
+ }
115
+ const input = data.tool_input;
116
+ if (input && typeof input === 'object') {
117
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
118
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
119
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
120
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
121
+ // model's raw json-parsed
122
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
123
+ // tool.call() — after the hook has already decided. So a `file_path` in a
124
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
125
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
126
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
127
+ // guard reading an empty string and exiting 0, while the identical write
128
+ // without the extra key blocked — a bypass needing no crash at all. The same
129
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
130
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
131
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
132
+ // elsewhere, reached through the guard's own read rather than through
133
+ // normalization. Overwriting can only ever narrow what a guard inspects to
134
+ // the path that will actually be written, so it cannot under-block.
135
+ if (typeof input.path === 'string') {
136
+ input.file_path = input.path;
137
+ }
138
+ const edits = Array.isArray(input.edit) ? input.edit
139
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
140
+ if (edits.length) {
141
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
142
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
143
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
144
+ // reached each guard's outer `catch { process.exit(0) }` and silently
145
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
146
+ // never threw — `('x').old` is a legal read yielding undefined.)
147
+ //
148
+ // The String() coercion is guarded for the same reason: `{"toString":
149
+ // null}` is valid JSON that throws "Cannot convert object to primitive
150
+ // value", which is the identical crash-to-allow with a different
151
+ // trigger. Degrading only the non-coercible entry to '' keeps
152
+ // stringification intact for every value that CAN coerce (numbers,
153
+ // arrays, plain objects), so nothing downstream — including
154
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
155
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
156
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
157
+ // `path` decision above rather than merely filling in when the field
158
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
159
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
160
+ // no `old_string`/`new_string` at all, so either field appearing in a
161
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
162
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
163
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
164
+ // reading '' and exiting at its `if (!content)` before it ever saw the
165
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
166
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
167
+ // it: a benign non-empty string shadows just as effectively as ''.
168
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
169
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
170
+ }
171
+ }
172
+ return data;
173
+ }
174
+
81
175
  let input = '';
82
176
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
83
177
  process.stdin.setEncoding('utf8');
@@ -85,7 +179,7 @@ process.stdin.on('data', chunk => input += chunk);
85
179
  process.stdin.on('end', () => {
86
180
  clearTimeout(stdinTimeout);
87
181
  try {
88
- const data = JSON.parse(input);
182
+ const data = normalizeKimiPayload(JSON.parse(input));
89
183
  const toolName = data.tool_name;
90
184
  const cwd = data.cwd || process.cwd();
91
185
  const isWorkflowGuardEnabled = workflowGuardEnabled(cwd);
@@ -97,12 +191,15 @@ process.stdin.on('end', () => {
97
191
  const command = data.tool_input?.command || '';
98
192
  for (const gitCwd of forceGitAddCwds(command, cwd)) {
99
193
  const branch = currentBranch(gitCwd);
100
- if (branch.startsWith('worktree-agent-')) {
101
- process.stdout.write(JSON.stringify({
194
+ if (/^(worktree-)?agent-/.test(branch)) {
195
+ const output = {
102
196
  decision: 'block',
103
197
  code: 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN',
104
- reason: 'worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
105
- }));
198
+ reason: 'agent/worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
199
+ };
200
+ process.stdout.write(JSON.stringify(output));
201
+ // Kimi CLI's exit-2 protocol feeds stderr back to the model (#2304)
202
+ process.stderr.write(output.reason);
106
203
  process.exit(2);
107
204
  }
108
205
  }
@@ -122,7 +219,14 @@ process.stdin.on('end', () => {
122
219
  }
123
220
 
124
221
  // Check the file being edited
125
- const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
222
+ // #2595 (review Major 3, sibling sweep): typed read on BOTH fields. The
223
+ // `&& value` keeps the original truthiness fallback intact — an empty
224
+ // file_path must still fall through to `path`, which a bare typeof test
225
+ // would have broken.
226
+ const filePath =
227
+ (typeof data.tool_input?.file_path === 'string' && data.tool_input.file_path) ||
228
+ (typeof data.tool_input?.path === 'string' && data.tool_input.path) ||
229
+ '';
126
230
 
127
231
  // Allow edits to .planning/ files (GSD state management)
128
232
  if (filePath.includes('.planning/') || filePath.includes('.planning\\')) {
@@ -37,6 +37,100 @@ function nearestExistingDir(start) {
37
37
  return null;
38
38
  }
39
39
 
40
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
41
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
42
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
43
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
44
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
45
+ // on Kimi. The tool_input field names differ as well (kimi-cli
46
+ // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
47
+ // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
48
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
49
+ // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
50
+ // names; unknown names fall through untouched. Inlined per guard (not
51
+ // hooks/lib/): hook scripts are staged as standalone files, and a sibling
52
+ // require is a staging dependency that can fail silently.
53
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
54
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
55
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
56
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
57
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
58
+ function normalizeKimiPayload(data) {
59
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
60
+ // payloads reached the `data.tool_name` read below and threw — falsifying
61
+ // this function's own "total over the inputs JSON can express" claim, which
62
+ // property (e) now tests directly. Harmless in practice (a null payload has
63
+ // nothing to guard, and the throw landed in the same fail-open catch as the
64
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
65
+ if (data === null || typeof data !== 'object') return data;
66
+ const raw = data.tool_name;
67
+ if (typeof raw !== 'string') return data;
68
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
69
+ if (!mapped) return data;
70
+ data.tool_name = mapped;
71
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
72
+ data.tool_response = data.tool_output;
73
+ }
74
+ const input = data.tool_input;
75
+ if (input && typeof input === 'object') {
76
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
77
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
78
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
79
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
80
+ // model's raw json-parsed
81
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
82
+ // tool.call() — after the hook has already decided. So a `file_path` in a
83
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
84
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
85
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
86
+ // guard reading an empty string and exiting 0, while the identical write
87
+ // without the extra key blocked — a bypass needing no crash at all. The same
88
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
89
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
90
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
91
+ // elsewhere, reached through the guard's own read rather than through
92
+ // normalization. Overwriting can only ever narrow what a guard inspects to
93
+ // the path that will actually be written, so it cannot under-block.
94
+ if (typeof input.path === 'string') {
95
+ input.file_path = input.path;
96
+ }
97
+ const edits = Array.isArray(input.edit) ? input.edit
98
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
99
+ if (edits.length) {
100
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
101
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
102
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
103
+ // reached each guard's outer `catch { process.exit(0) }` and silently
104
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
105
+ // never threw — `('x').old` is a legal read yielding undefined.)
106
+ //
107
+ // The String() coercion is guarded for the same reason: `{"toString":
108
+ // null}` is valid JSON that throws "Cannot convert object to primitive
109
+ // value", which is the identical crash-to-allow with a different
110
+ // trigger. Degrading only the non-coercible entry to '' keeps
111
+ // stringification intact for every value that CAN coerce (numbers,
112
+ // arrays, plain objects), so nothing downstream — including
113
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
114
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
115
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
116
+ // `path` decision above rather than merely filling in when the field
117
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
118
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
119
+ // no `old_string`/`new_string` at all, so either field appearing in a
120
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
121
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
122
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
123
+ // reading '' and exiting at its `if (!content)` before it ever saw the
124
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
125
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
126
+ // it: a benign non-empty string shadows just as effectively as ''.
127
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
128
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
129
+ }
130
+ }
131
+ return data;
132
+ }
133
+
40
134
  let input = '';
41
135
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
42
136
  process.stdin.setEncoding('utf8');
@@ -44,7 +138,7 @@ process.stdin.on('data', chunk => input += chunk);
44
138
  process.stdin.on('end', () => {
45
139
  clearTimeout(stdinTimeout);
46
140
  try {
47
- const data = JSON.parse(input);
141
+ const data = normalizeKimiPayload(JSON.parse(input));
48
142
  const toolName = data.tool_name;
49
143
 
50
144
  // Only guard Edit, Write, and MultiEdit tool calls
@@ -72,13 +166,14 @@ process.stdin.on('end', () => {
72
166
  }
73
167
 
74
168
  // #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
75
- // are always on a `worktree-agent-*` branch (the positive allow-list enforced
76
- // by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
77
- // non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
78
- // guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
169
+ // are always on an `agent-*` or legacy `worktree-agent-*` branch (the positive
170
+ // allow-list enforced by worktree-branch-check.md, #2924, #1995). A manually-
171
+ // created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
172
+ // on the user's own branch, so the guard must be a no-op there. Detached HEAD
173
+ // / error → not GSD-managed → no-op.
79
174
  const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
80
175
  const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
81
- if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
176
+ if (!/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(branch)) {
82
177
  process.exit(0); // not a GSD-managed executor worktree — no-op
83
178
  }
84
179
 
@@ -91,12 +186,37 @@ process.stdin.on('end', () => {
91
186
  }
92
187
  const wtTopRaw = wtTopResult.stdout.trim();
93
188
 
94
- const rawFilePath = data.tool_input?.file_path || '';
189
+ // #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
190
+ // non-string through — `[]` and `{}` are truthy, so they survived the
191
+ // `!rawFilePath` check and threw inside path.isAbsolute() below, landing in
192
+ // this script's outer `catch { process.exit(0) }`. That is the same
193
+ // crash-to-allow #2547 closes elsewhere, reached through the guard's own
194
+ // read rather than through normalization, and it is NOT closed by making
195
+ // `path` authoritative: normalization returns early for native Claude Code
196
+ // payloads (KIMI_TOOL_NAMES has no 'Edit' entry), so `{"tool_name":"Edit",
197
+ // "tool_input":{"file_path":[]}}` reached it untouched — this guard's
198
+ // original #260 surface. Same shape as hooks/gsd-windsurf-pre-write.js:75.
199
+ const rawFilePath = typeof data.tool_input?.file_path === 'string'
200
+ ? data.tool_input.file_path
201
+ : '';
95
202
  if (!rawFilePath) {
96
203
  process.exit(0);
97
204
  }
98
205
 
99
- // Relative paths are always safe — they resolve relative to CWD inside the worktree
206
+ // Relative paths resolve against the tool's CWD, which is inside the worktree
207
+ // — so under the runtime this guard was written for they cannot leave it.
208
+ //
209
+ // #2595 (review Minor 5) — state the premise rather than leave it implicit,
210
+ // because THIS PR is what widened the guard's reach to Kimi. "Always safe"
211
+ // holds only while every runtime reaching here either rejects relative paths
212
+ // or resolves them against the worktree CWD. Claude Code's Edit/Write require
213
+ // an absolute file_path, so the original #260 surface satisfies it by
214
+ // construction. kimi-cli's StrReplaceFile takes `path` with no documented
215
+ // absoluteness guarantee, and its resolution behaviour is NOT verified here
216
+ // (no source available to this repo at 4a550ef beyond the schema). If it
217
+ // resolves relative paths against anything other than the tool CWD, a
218
+ // `../`-laden path exits 0 at this line and escapes the worktree. Stating a
219
+ // mechanism and an unverified premise — not asserting a live bypass.
100
220
  if (!path.isAbsolute(rawFilePath)) {
101
221
  process.exit(0);
102
222
  }
@@ -151,6 +271,8 @@ process.stdin.on('end', () => {
151
271
  `absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
152
272
  };
153
273
  process.stdout.write(JSON.stringify(output));
274
+ // Kimi feeds stderr (not stdout) back to the model on exit 2.
275
+ process.stderr.write(output.reason);
154
276
  process.exit(2);
155
277
  }
156
278
  // Outside all git repositories — fail open (#1342).
@@ -177,6 +299,8 @@ process.stdin.on('end', () => {
177
299
  };
178
300
 
179
301
  process.stdout.write(JSON.stringify(output));
302
+ // Kimi feeds stderr (not stdout) back to the model on exit 2.
303
+ process.stderr.write(output.reason);
180
304
  process.exit(2);
181
305
  } catch {
182
306
  // Silent fail — never block valid tool calls due to hook errors
@@ -0,0 +1,74 @@
1
+ // cursor-workspace.js — shared workspace resolution for Cursor lifecycle hooks (#2587).
2
+ //
3
+ // No `gsd-hook-version:` marker, deliberately — matching hooks/lib/git-cmd.js.
4
+ // Neither copy path that stages hooks/lib/*.js substitutes the version
5
+ // placeholder (copyLibDir stamps .sh files only), and the managed-hooks
6
+ // staleness scan covers top-level hooks/ names, not hooks/lib/. A marker here
7
+ // would ship to users as an unsubstituted literal.
8
+ //
9
+ // Cursor invokes hooks with cwd set to the Cursor config dir (~/.cursor) under
10
+ // the cursor-agent CLI, NOT the workspace. Every hook that resolves a project
11
+ // path from process.cwd() therefore missed: sessionStart could only emit its
12
+ // "no .planning/ workflow found" nudge, stop's verify-work reminder never fired,
13
+ // and subagentStart left every subagent without phase context.
14
+ //
15
+ // The hook payload carries the real path in `workspace_roots`. This module is
16
+ // the single place that turns that payload into a project root, so the three
17
+ // hooks cannot drift apart (they previously carried three copies of it).
18
+ //
19
+ // Consumers: gsd-cursor-session-start.js, gsd-cursor-stop.js,
20
+ // gsd-cursor-subagent-start.js. Staged into <config>/hooks/lib/ by
21
+ // writeCursorHooksJson (src/runtime-hooks-surface.cts) alongside the scripts
22
+ // that require it, and registered in the installer's GSD_HOOK_LIB_FILES so
23
+ // uninstall and the manifest manage it.
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ /**
31
+ * Resolve the workspace root a Cursor hook should report on.
32
+ *
33
+ * Search order:
34
+ * 1. Each entry of `workspace_roots` that actually carries .planning/STATE.md
35
+ * — so a multi-root workspace whose GSD project is not the first root
36
+ * still resolves.
37
+ * 2. process.cwd(), if IT carries .planning/STATE.md. cwd is a CANDIDATE, not
38
+ * merely the empty-roots fallback: an IDE invocation can supply
39
+ * workspace_roots AND run from the project, and searching roots alone
40
+ * would report "absent" for a project sitting right at cwd — narrower than
41
+ * the pre-fix always-cwd behavior this replaces.
42
+ * 3. The first declared root, else cwd — so the "absent" message still names
43
+ * a sensible directory when there is genuinely no project.
44
+ *
45
+ * Never throws: a malformed payload degrades to cwd rather than wedging the
46
+ * session. (fs.existsSync itself does not throw — it returns false for NUL
47
+ * bytes, over-long components, and non-directory ancestors.)
48
+ *
49
+ * @param {string} rawInput Raw stdin payload as received by the hook.
50
+ * @returns {string} Absolute-or-relative directory to resolve .planning/ against.
51
+ */
52
+ function resolveWorkspaceRoot(rawInput) {
53
+ let input = {};
54
+ try { input = JSON.parse(rawInput || '{}'); } catch { /* fall back to cwd */ }
55
+ const roots = Array.isArray(input.workspace_roots)
56
+ ? input.workspace_roots.filter((r) => typeof r === 'string' && r.length > 0)
57
+ : [];
58
+ for (const root of [...roots, process.cwd()]) {
59
+ if (fs.existsSync(path.join(root, '.planning', 'STATE.md'))) return root;
60
+ }
61
+ return roots[0] || process.cwd();
62
+ }
63
+
64
+ /**
65
+ * Convenience: the .planning/STATE.md path for the resolved workspace.
66
+ *
67
+ * @param {string} rawInput Raw stdin payload as received by the hook.
68
+ * @returns {string}
69
+ */
70
+ function resolveStatePath(rawInput) {
71
+ return path.join(resolveWorkspaceRoot(rawInput), '.planning', 'STATE.md');
72
+ }
73
+
74
+ module.exports = { resolveWorkspaceRoot, resolveStatePath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -65,7 +65,7 @@
65
65
  "eslint-plugin-no-only-tests": "^3.4.0",
66
66
  "fast-check": "^4.8.0",
67
67
  "globals": "^16.5.0",
68
- "js-yaml": "^4.2.0",
68
+ "js-yaml": "^4.2.1",
69
69
  "typescript": "^6.0.3",
70
70
  "typescript-eslint": "^8.60.0"
71
71
  },
@@ -92,7 +92,8 @@
92
92
  "gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write",
93
93
  "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
94
94
  "gen:registry": "node scripts/gen-registry.cjs --write",
95
- "gen:golden": "node scripts/gen-golden-install-parity-zcode.cjs && node scripts/gen-install-tree-fixtures.cjs",
95
+ "gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs",
96
+ "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree",
96
97
  "validate:registry": "node scripts/validate-registry.cjs",
97
98
  "prepack": "npm run build:lib",
98
99
  "prepare": "npm run build:lib",
@@ -103,7 +104,7 @@
103
104
  "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
104
105
  "lint:fix": "eslint . --fix",
105
106
  "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
106
- "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs",
107
+ "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs",
107
108
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
108
109
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
109
110
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -111,11 +112,10 @@
111
112
  "lint:test-file-count": "node scripts/lint-test-file-count.cjs",
112
113
  "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
113
114
  "lint:changeset": "node scripts/changeset/lint.cjs",
114
- "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check",
115
+ "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check",
115
116
  "lint:docs": "node scripts/lint-docs-required.cjs",
116
117
  "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
117
118
  "ci:test-scope": "node scripts/ci-test-scope.cjs",
118
- "size:baseline": "node scripts/update-size-baseline.cjs",
119
119
  "changeset": "node scripts/changeset/new.cjs",
120
120
  "changelog:render": "node scripts/changeset/cli.cjs render",
121
121
  "test": "node scripts/run-tests.cjs",
@@ -127,7 +127,7 @@
127
127
  "test:affected": "node scripts/run-affected-tests.cjs",
128
128
  "test:coverage": "c8 --check-coverage --lines 70 --branches 60 --reporter text --include 'gsd-core/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs",
129
129
  "test:coverage:scripts-floor": "c8 check-coverage --lines 55 --include 'scripts/**/*.cjs' --exclude 'tests/**' --all",
130
- "test:coverage:unit": "c8 --check-coverage --lines 70 --branches 60 --reporter text --include 'gsd-core/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs --suite unit && c8 check-coverage --per-file --lines 0 --functions 0 --statements 0 --branches 70 --include 'gsd-core/bin/lib/state.cjs' --include 'gsd-core/bin/lib/phase.cjs' --include 'gsd-core/bin/lib/verify.cjs' --include 'gsd-core/bin/lib/init.cjs' --exclude 'tests/**'",
130
+ "test:coverage:unit": "c8 --reporter text --reporter json-summary --include 'gsd-core/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs --suite unit && node scripts/check-coverage-gate.cjs",
131
131
  "test:coverage:all": "npm run test:coverage",
132
132
  "test:mutation": "stryker run",
133
133
  "test:mutation:since": "stryker run --incremental --since origin/next"