@opengsd/gsd-core 1.7.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 (261) 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 +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -21,6 +21,100 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
 
24
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
25
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
26
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
27
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
28
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
29
+ // on Kimi. The tool_input field names differ as well (kimi-cli
30
+ // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
31
+ // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
32
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
33
+ // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
34
+ // names; unknown names fall through untouched. Inlined per guard (not
35
+ // hooks/lib/): hook scripts are staged as standalone files, and a sibling
36
+ // require is a staging dependency that can fail silently.
37
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
38
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
39
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
40
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
41
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
42
+ function normalizeKimiPayload(data) {
43
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
44
+ // payloads reached the `data.tool_name` read below and threw — falsifying
45
+ // this function's own "total over the inputs JSON can express" claim, which
46
+ // property (e) now tests directly. Harmless in practice (a null payload has
47
+ // nothing to guard, and the throw landed in the same fail-open catch as the
48
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
49
+ if (data === null || typeof data !== 'object') return data;
50
+ const raw = data.tool_name;
51
+ if (typeof raw !== 'string') return data;
52
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
53
+ if (!mapped) return data;
54
+ data.tool_name = mapped;
55
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
56
+ data.tool_response = data.tool_output;
57
+ }
58
+ const input = data.tool_input;
59
+ if (input && typeof input === 'object') {
60
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
61
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
62
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
63
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
64
+ // model's raw json-parsed
65
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
66
+ // tool.call() — after the hook has already decided. So a `file_path` in a
67
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
68
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
69
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
70
+ // guard reading an empty string and exiting 0, while the identical write
71
+ // without the extra key blocked — a bypass needing no crash at all. The same
72
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
73
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
74
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
75
+ // elsewhere, reached through the guard's own read rather than through
76
+ // normalization. Overwriting can only ever narrow what a guard inspects to
77
+ // the path that will actually be written, so it cannot under-block.
78
+ if (typeof input.path === 'string') {
79
+ input.file_path = input.path;
80
+ }
81
+ const edits = Array.isArray(input.edit) ? input.edit
82
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
83
+ if (edits.length) {
84
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
85
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
86
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
87
+ // reached each guard's outer `catch { process.exit(0) }` and silently
88
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
89
+ // never threw — `('x').old` is a legal read yielding undefined.)
90
+ //
91
+ // The String() coercion is guarded for the same reason: `{"toString":
92
+ // null}` is valid JSON that throws "Cannot convert object to primitive
93
+ // value", which is the identical crash-to-allow with a different
94
+ // trigger. Degrading only the non-coercible entry to '' keeps
95
+ // stringification intact for every value that CAN coerce (numbers,
96
+ // arrays, plain objects), so nothing downstream — including
97
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
98
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
99
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
100
+ // `path` decision above rather than merely filling in when the field
101
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
102
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
103
+ // no `old_string`/`new_string` at all, so either field appearing in a
104
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
105
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
106
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
107
+ // reading '' and exiting at its `if (!content)` before it ever saw the
108
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
109
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
110
+ // it: a benign non-empty string shadows just as effectively as ''.
111
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
112
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
113
+ }
114
+ }
115
+ return data;
116
+ }
117
+
24
118
  let input = '';
25
119
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
26
120
  process.stdin.setEncoding('utf8');
@@ -28,7 +122,7 @@ process.stdin.on('data', chunk => input += chunk);
28
122
  process.stdin.on('end', () => {
29
123
  clearTimeout(stdinTimeout);
30
124
  try {
31
- const data = JSON.parse(input);
125
+ const data = normalizeKimiPayload(JSON.parse(input));
32
126
  const toolName = data.tool_name;
33
127
 
34
128
  // Only intercept Write and Edit tool calls
@@ -60,7 +154,11 @@ process.stdin.on('end', () => {
60
154
  process.exit(0);
61
155
  }
62
156
 
63
- const filePath = data.tool_input?.file_path || '';
157
+ // #2595 (review Major 3, sibling sweep): typed read — same class as the
158
+ // worktree guard's, advisory-only here (no exit(2) path in this hook).
159
+ const filePath = typeof data.tool_input?.file_path === 'string'
160
+ ? data.tool_input.file_path
161
+ : '';
64
162
  if (!filePath) {
65
163
  process.exit(0);
66
164
  }
@@ -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') {
@@ -11,6 +11,7 @@ const os = require('os');
11
11
  const childProcess = require('child_process');
12
12
  const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
13
13
  const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
14
+ const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
14
15
 
15
16
  // --- Config + last-command readers ------------------------------------------
16
17
 
@@ -142,12 +143,15 @@ function readGsdState(dir) {
142
143
  function parseStateMd(content) {
143
144
  const state = {};
144
145
 
145
- // YAML frontmatter between --- markers (anchored at file start)
146
- 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---/);
147
151
  if (fmMatch) {
148
152
  const fm = fmMatch[1];
149
153
  // Top-level scalar key: value
150
- for (const line of fm.split('\n')) {
154
+ for (const line of fm.split(/\r?\n/)) {
151
155
  const m = line.match(/^(\w+):\s*(.+)/);
152
156
  if (!m) continue;
153
157
  const [, key, val] = m;
@@ -168,10 +172,10 @@ function parseStateMd(content) {
168
172
  const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
169
173
  state.nextPhases = items.length > 0 ? items : null;
170
174
  } else {
171
- 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);
172
176
  if (npBlockMatch) {
173
177
  const items = npBlockMatch[1]
174
- .split('\n')
178
+ .split(/\r?\n/)
175
179
  .map(line => line.match(/^[ \t]*-[ \t]*(.+)$/))
176
180
  .filter(Boolean)
177
181
  .map(m => m[1].trim().replace(/^["']|["']$/g, ''))
@@ -180,7 +184,7 @@ function parseStateMd(content) {
180
184
  }
181
185
  }
182
186
  // progress nested block: completed_phases / total_phases / percent (2-space indent)
183
- 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);
184
188
  if (progMatch) {
185
189
  const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m);
186
190
  const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m);
@@ -319,6 +323,78 @@ function contextTokenSuffix(currentUsage) {
319
323
  return total > 0 ? ` (${formatTokens(total)})` : '';
320
324
  }
321
325
 
326
+ // --- Compact state format (opt-in) ---------------------------------------------
327
+
328
+ /**
329
+ * Collapse GSD's free-text status (often a multi-sentence narrative) to a
330
+ * single keyword, built on the canonical normalizer (#2162 approval
331
+ * condition): normalizeStateStatus() in state-document.cjs owns the status
332
+ * vocabulary (discussing / planning / executing / verifying / completed /
333
+ * paused) so the two can't drift. "paused" — the canonical stuck state — is
334
+ * uppercased to PAUSED, the one state worth shouting about. Statuses the
335
+ * normalizer passes through unrecognized fall back to their first word,
336
+ * capped at 16 chars so a rogue STATE.md can't blow up the line.
337
+ * Returns null for empty input.
338
+ */
339
+ const CANONICAL_STATUSES = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused'];
340
+
341
+ function shortGsdStatus(status) {
342
+ if (!status) return null;
343
+ const norm = normalizeStateStatus(status, null);
344
+ if (CANONICAL_STATUSES.includes(norm)) {
345
+ return norm === 'paused' ? 'PAUSED' : norm;
346
+ }
347
+ // Unrecognized free text passes through normalizeStateStatus verbatim —
348
+ // fall back to the first word, capped.
349
+ const first = String(norm).trim().split(/[\s\u2014\u2013-]+/)[0] || '';
350
+ return first ? first.slice(0, 16) : null;
351
+ }
352
+
353
+ /**
354
+ * Compact alternative to formatGsdState, selected via
355
+ * `statusline.state_format: "compact"`:
356
+ *
357
+ * "v1.12 · P7/12 · executing" (phase active)
358
+ * "v2.0 · P4.5 · BLOCKED" (no total known)
359
+ * "v2.0 · complete" (milestone done)
360
+ * "v2.0 · next execute-phase 4.5" (idle with a queued action)
361
+ *
362
+ * Drops the milestone name and progress bar — the biggest width costs in the
363
+ * default format — and collapses narrative statuses via shortGsdStatus().
364
+ * The default "full" format is untouched.
365
+ */
366
+ function formatGsdStateCompact(s) {
367
+ const parts = [];
368
+
369
+ if (s.milestone) parts.push(s.milestone);
370
+
371
+ const phaseId = s.activePhase || s.phaseNum;
372
+ if (phaseId) {
373
+ parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`);
374
+ }
375
+
376
+ // Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight
377
+ // phase (Scene 1, gated on activePhase ONLY — the legacy phaseNum shape
378
+ // still completes) wins over milestone-complete (Scene 3), even if a
379
+ // non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
380
+ const done = !s.activePhase && (Number(s.percent) === 100 ||
381
+ (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
382
+
383
+ if (done) {
384
+ parts.push('complete');
385
+ } else {
386
+ const st = shortGsdStatus(s.status);
387
+ if (st) {
388
+ parts.push(st);
389
+ } else if (!phaseId && s.nextAction) {
390
+ const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : '';
391
+ parts.push(`next ${s.nextAction}${phasesStr ? ' ' + phasesStr : ''}`);
392
+ }
393
+ }
394
+
395
+ return parts.join(' \u00b7 ');
396
+ }
397
+
322
398
  // --- Model name --------------------------------------------------------------
323
399
 
324
400
  /**
@@ -529,8 +605,9 @@ function runStatusline() {
529
605
  }
530
606
  }
531
607
 
532
- // GSD state (milestone · status · phase) — shown when no todo task
533
- const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {});
608
+ // GSD state (milestone · status · phase) — shown when no todo task.
609
+ // Format resolved below once config is read (statusline.state_format).
610
+ let gsdStateStr = '';
534
611
 
535
612
  // GSD update available?
536
613
  // Read only the per-package shared cache file (#607). The legacy
@@ -558,6 +635,7 @@ function runStatusline() {
558
635
  // Failure here must never break the statusline — wrap the entire lookup.
559
636
  let lastCmdSuffix = '';
560
637
  let position = 'end';
638
+ let stateFormat = 'full';
561
639
  let gitSuffix = '';
562
640
  try {
563
641
  if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
@@ -569,6 +647,7 @@ function runStatusline() {
569
647
  }
570
648
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
571
649
  if (cfgPos != null) position = cfgPos;
650
+ if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
572
651
  if (getConfigValue(cfg, 'statusline.show_git') === true) {
573
652
  gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
574
653
  }
@@ -576,6 +655,11 @@ function runStatusline() {
576
655
  // Never break the statusline on config/transcript/git errors
577
656
  }
578
657
 
658
+ if (!task) {
659
+ const state = readGsdState(dir) || {};
660
+ gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
661
+ }
662
+
579
663
  // Output
580
664
  const dirname = path.basename(dir);
581
665
  const middle = task
@@ -675,6 +759,7 @@ module.exports = {
675
759
  evaluateUpdateCache,
676
760
  formatTokens,
677
761
  contextTokenSuffix,
762
+ shortGsdStatus, formatGsdStateCompact,
678
763
  compactModelName,
679
764
  readGitStatus, parseGitStatus, buildGitSegment,
680
765
  };
@@ -690,6 +775,7 @@ function renderStatusline(data) {
690
775
 
691
776
  let lastCmdSuffix = '';
692
777
  let position = 'end';
778
+ let stateFormat = 'full';
693
779
  let gitSuffix = '';
694
780
  try {
695
781
  const cfg = readGsdConfig(dir);
@@ -701,12 +787,14 @@ function renderStatusline(data) {
701
787
  }
702
788
  const cfgPos = getConfigValue(cfg, 'statusline.context_position');
703
789
  if (cfgPos != null) position = cfgPos;
790
+ if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
704
791
  if (getConfigValue(cfg, 'statusline.show_git') === true) {
705
792
  gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
706
793
  }
707
794
  } catch (e) { /* swallow */ }
708
795
 
709
- const gsdStateStr = formatGsdState(readGsdState(dir) || {});
796
+ const state = readGsdState(dir) || {};
797
+ const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
710
798
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
711
799
  return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
712
800
  }
@@ -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\\')) {