@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -12,6 +12,17 @@ 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
14
  const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
15
+ // #2850: reuse the existing workstream resolution seams rather than
16
+ // re-implementing CLI>env>store precedence or path construction inline.
17
+ // peekActiveWorkstream is the read-only sibling of the store-tier lookup
18
+ // resolveActiveWorkstream defaults to (getActiveWorkstream) — that default
19
+ // self-heals a stale/invalid pointer by deleting it, which is correct for a
20
+ // command but not for a renderer invoked on every prompt. Injecting it via
21
+ // resolveActiveWorkstream's own `getStored` override keeps the CLI>env>store
22
+ // precedence itself fully reused (untouched); only the store tier's *write*
23
+ // side effect is removed.
24
+ const { resolveActiveWorkstream, peekActiveWorkstream } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
25
+ const { listAvailableWorkstreams, planningPaths } = require('../gsd-core/bin/lib/planning-workspace.cjs');
15
26
 
16
27
  // --- Config + last-command readers ------------------------------------------
17
28
 
@@ -102,21 +113,65 @@ function readLastSlashCommand(transcriptPath) {
102
113
  // --- GSD state reader -------------------------------------------------------
103
114
 
104
115
  /**
105
- * Walk up from dir looking for .planning/STATE.md.
106
- * Returns parsed state object or null.
116
+ * Read and parse a STATE.md if it exists. Returns the parsed state object,
117
+ * `null` when the file is absent, or `null` on any read/parse failure (never
118
+ * throws) — the single shared shape for both the flat and workstream reads
119
+ * in readGsdState() below.
120
+ */
121
+ function readStateFileOrNull(statePath) {
122
+ if (!fs.existsSync(statePath)) return null;
123
+ try {
124
+ return parseStateMd(fs.readFileSync(statePath, 'utf8'));
125
+ } catch (e) {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Walk up from dir looking for .planning/STATE.md (flat mode). If an ancestor
132
+ * has no flat STATE.md but IS in workstream mode (.planning/workstreams/
133
+ * present — the single-source-of-truth check `listAvailableWorkstreams`
134
+ * shares with the init.progress/phase.complete #1912/#2028 guards, so this
135
+ * can't drift from how every other GSD command detects the mode), resolve
136
+ * the active workstream and read that workstream's STATE.md instead (#2850).
137
+ *
138
+ * Resolution reuses `resolveActiveWorkstream` (active-workstream-store.cjs)
139
+ * called with an empty args array, so only its env>store precedence applies
140
+ * here — the CLI leg is inert for this renderer, which never receives argv.
141
+ * The store tier is `peekActiveWorkstream`, a READ-ONLY sibling of the
142
+ * default `getActiveWorkstream`: the default self-heals a stale/invalid
143
+ * pointer by deleting it, which is correct for a command but not for a
144
+ * renderer invoked on every prompt — a render must never write or delete.
145
+ *
146
+ * Returns:
147
+ * - the parsed state object when a flat or workstream STATE.md is found
148
+ * - { noActiveWorkstream: true } when workstream mode is active at an
149
+ * ancestor but no workstream can be resolved — an observable signal so
150
+ * this is distinguishable from "GSD isn't installed here" (#2850)
151
+ * - null when no .planning marker is found at all (GSD not present), or
152
+ * when a workstream DOES resolve but its STATE.md doesn't exist yet
153
+ * (negative space: mirrors flat-mode's own silent pre-STATE.md window)
107
154
  */
108
155
  function readGsdState(dir) {
109
156
  const home = os.homedir();
110
157
  let current = dir;
111
158
  for (let i = 0; i < 10; i++) {
112
- const candidate = path.join(current, '.planning', 'STATE.md');
113
- if (fs.existsSync(candidate)) {
159
+ const flatState = readStateFileOrNull(path.join(current, '.planning', 'STATE.md'));
160
+ if (flatState !== null) return flatState;
161
+
162
+ if (listAvailableWorkstreams(current).length > 0) {
163
+ let resolvedWs = null;
114
164
  try {
115
- return parseStateMd(fs.readFileSync(candidate, 'utf8'));
165
+ resolvedWs = resolveActiveWorkstream(current, [], process.env, { getStored: peekActiveWorkstream }).ws;
116
166
  } catch (e) {
117
- return null;
167
+ resolvedWs = null;
118
168
  }
169
+
170
+ if (!resolvedWs) return { noActiveWorkstream: true };
171
+
172
+ return readStateFileOrNull(planningPaths(current, resolvedWs).state);
119
173
  }
174
+
120
175
  const parent = path.dirname(current);
121
176
  if (parent === current || current === home) break;
122
177
  current = parent;
@@ -217,6 +272,10 @@ function parseStateMd(content) {
217
272
  return state;
218
273
  }
219
274
 
275
+ // #2850: shared literal for formatGsdState/formatGsdStateCompact's "nothing
276
+ // resolvable" signal — one source of truth so the two renderers can't drift.
277
+ const NO_ACTIVE_WORKSTREAM_LABEL = 'no active workstream';
278
+
220
279
  /**
221
280
  * Render a 10-segment milestone progress bar (matches the context meter style).
222
281
  *
@@ -249,6 +308,10 @@ function renderProgressBar(percent) {
249
308
  * progress.percent is present in frontmatter; absent → empty string.
250
309
  */
251
310
  function formatGsdState(s) {
311
+ // #2850: workstream mode with nothing resolvable — an observable signal,
312
+ // never silent emptiness (distinguishes from "GSD isn't installed here").
313
+ if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
314
+
252
315
  const parts = [];
253
316
 
254
317
  // Milestone segment: version + name + (opt-in) progress bar
@@ -364,6 +427,9 @@ function shortGsdStatus(status) {
364
427
  * The default "full" format is untouched.
365
428
  */
366
429
  function formatGsdStateCompact(s) {
430
+ // #2850: mirrors formatGsdState's observable "nothing resolvable" signal.
431
+ if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
432
+
367
433
  const parts = [];
368
434
 
369
435
  if (s.milestone) parts.push(s.milestone);
@@ -173,7 +173,8 @@ process.stdin.on('end', () => {
173
173
  // / error → not GSD-managed → no-op.
174
174
  const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
175
175
  const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
176
- if (!/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(branch)) {
176
+ // #3021: accept worktree-wf_<runid>-<n> branches (Workflow backend's naming).
177
+ if (!/^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/.test(branch)) {
177
178
  process.exit(0); // not a GSD-managed executor worktree — no-op
178
179
  }
179
180
 
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // GSD Write Guard — PreToolUse hook
4
+ // Blocks a whole-file Write that catastrophically shrinks a curated .planning/
5
+ // artifact (ROADMAP.md, milestone roadmaps, STATE.md).
6
+ //
7
+ // Problem (#973, fix 3 of 3): a planner read a ~16-line window of ROADMAP.md
8
+ // and Write-overwrote the whole 292-line file with it — three milestones of
9
+ // committed history destroyed. Fixes 1 and 2 (PR #989) are instructions to a
10
+ // model: they lower the probability of a clobber but cannot prevent one, and
11
+ // they protect only the agents that were audited. This hook is enforced by
12
+ // code rather than by instruction: it compares the pending Write payload
13
+ // against the file on disk and hard-blocks a catastrophic shrink BEFORE it
14
+ // happens. An advisory will not do — #973 records an agent reading the
15
+ // advisory, classifying it as non-binding, and reasoning past it while
16
+ // holding a false model of what Write does.
17
+ //
18
+ // The guarantee is bounded, and the bound is worth stating where the code
19
+ // lives: this stops accidental and single-shot collapse, not a determined
20
+ // agent. The sentinel hatch below is a plain file, so an agent that would
21
+ // reason past an advisory can arm one with a single Bash call it is already
22
+ // permitted to make. What ships is the conversion of "ignore a sentence" into
23
+ // "take one deliberate, path-bound, single-use, auditable action" — a real
24
+ // improvement against the confused-agent threat #973 records, not a defense
25
+ // against an evader.
26
+ //
27
+ // Deliberately narrow trigger:
28
+ // - Write only (Edit/MultiEdit are scoped by construction);
29
+ // - the target already exists on disk;
30
+ // - the target is a curated .planning/ artifact — the project ROADMAP.md,
31
+ // milestone roadmaps (.planning/milestones/*-ROADMAP.md), and STATE.md.
32
+ // NOT arbitrary markdown: free-prose docs get legitimately rewritten
33
+ // wholesale, and a guard that fires on those trains override-fatigue
34
+ // until nobody reads it.
35
+ //
36
+ // Threshold: block when the pending payload carries fewer than SHRINK_RATIO
37
+ // (40%) of the on-disk line count. The docs-update fix-loop's 90% bar is far
38
+ // too permissive for a curated artifact — the #973 incident was a ~94.5%
39
+ // collapse and clears a 90% bar only barely. The same ~40%/floor-40 tuning
40
+ // has run clean (no false positives) as a commit-time twin downstream.
41
+ //
42
+ // Floor: files under FLOOR_LINES are exempt, so a 10 → 2 line stub never
43
+ // trips the ratio check.
44
+ //
45
+ // Escape hatches — both named in the block message; a guard whose bypass is
46
+ // undocumented gets bypassed with the blunt instrument instead, with every
47
+ // other guard disabled at the same time:
48
+ // - GSD_ALLOW_PLANNING_SHRINK=1 (env) — for a human running interactively,
49
+ // where the variable can actually reach the hook's environment.
50
+ // - .planning/.gsd-allow-shrink (single-use sentinel file) — for workflow
51
+ // steps. A PreToolUse hook inherits the RUNTIME's environment, so a
52
+ // per-step env prefix can never reach it (#2255 round 5 M1); the sentinel
53
+ // is a transport that code consults, not prose an agent obeys. The step
54
+ // writes the target's path into the sentinel; at the block point the
55
+ // guard checks it is fresh (15 min) and names the pending target, then
56
+ // CONSUMES it and allows that one write. Path-bound + single-use +
57
+ // freshness is what keeps it from becoming a standing unlock left on disk.
58
+ //
59
+ // Known design limits (out of #2255's scope by review, disclosed here AND in
60
+ // the changeset + USER-GUIDE — round 9 required the user-facing docs to match):
61
+ // - Stateless per-write: sequential shrinks (292→120→50) each clear the 40%
62
+ // floor against CURRENT disk state, so cumulative erosion is invisible.
63
+ // - Unconditionally case-insensitive matching (required on the
64
+ // case-insensitive filesystems macOS/Windows default to): on
65
+ // case-sensitive Linux a genuinely distinct '.planning/roadmap.md' is
66
+ // also treated as curated. Narrow, accepted cost.
67
+ // (A third limit — a symlinked path into a curated file escaping the lexical
68
+ // match — was closed in round 9: the target is realpath-resolved before the
69
+ // curated match.)
70
+ //
71
+ // Triggers on: Write tool calls
72
+ // Action: BLOCK (decision: 'block', exit 2) on catastrophic shrink of a curated file
73
+ // No-op: other tools, new files, non-curated paths, sub-floor files, override set,
74
+ // hook errors (silent fail)
75
+
76
+ const fs = require('fs');
77
+ const path = require('path');
78
+
79
+ // Block when the pending payload has fewer than this fraction of the on-disk
80
+ // line count (0.4 → a Write shrinking a file below 40% of its current size).
81
+ const SHRINK_RATIO = 0.4;
82
+
83
+ // Files with fewer lines than this are exempt — small stubs get legitimately
84
+ // rewritten far below any ratio.
85
+ const FLOOR_LINES = 40;
86
+
87
+ // Curated .planning/ artifacts, matched against the resolved target path with
88
+ // separators normalized to '/'. Deliberately a closed set (see header).
89
+ // Case-insensitive: on the case-insensitive filesystems macOS and Windows
90
+ // default to, a differently-cased path is the SAME real file — a Write to
91
+ // '.planning/roadmap.md' clobbers ROADMAP.md while a case-sensitive match
92
+ // waves it through.
93
+ const CURATED_PATTERNS = [
94
+ /(?:^|\/)\.planning\/ROADMAP\.md$/i,
95
+ /(?:^|\/)\.planning\/STATE\.md$/i,
96
+ /(?:^|\/)\.planning\/milestones\/[^/]+-ROADMAP\.md$/i,
97
+ ];
98
+
99
+ // Count logical lines, ignoring a single trailing newline so that
100
+ // "a\nb\n" and "a\nb" both count as 2.
101
+ function countLines(text) {
102
+ if (!text) return 0;
103
+ const lines = text.split('\n');
104
+ if (lines[lines.length - 1] === '') lines.pop();
105
+ return lines.length;
106
+ }
107
+
108
+ function isOverrideSet() {
109
+ const v = process.env.GSD_ALLOW_PLANNING_SHRINK;
110
+ return typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
111
+ }
112
+
113
+ // Single-use sentinel (see header). Consulted ONLY at the shrink-block point —
114
+ // a write that would pass anyway never burns the token, so first-shrink-wins
115
+ // for the write the workflow armed it for.
116
+ const SENTINEL_NAME = '.gsd-allow-shrink';
117
+ const SENTINEL_REL = '.planning/' + SENTINEL_NAME;
118
+ const SENTINEL_TTL_MS = 15 * 60 * 1000;
119
+
120
+ function consumeSentinelFor(filePath, normalized) {
121
+ try {
122
+ // The curated match guarantees the target lives under a .planning/ dir;
123
+ // normalized is filePath with separators flipped, so offsets line up.
124
+ const m = normalized.match(/^(.*\/\.planning)\//i);
125
+ if (!m) return false;
126
+ const planningDir = filePath.slice(0, m[1].length);
127
+ const sentinelPath = path.join(planningDir, SENTINEL_NAME);
128
+ let st;
129
+ try {
130
+ st = fs.statSync(sentinelPath);
131
+ } catch {
132
+ return false; // not armed
133
+ }
134
+ if (Date.now() - st.mtimeMs > SENTINEL_TTL_MS) {
135
+ // A stale token is a leftover, not an authorization — housekeep it.
136
+ try { fs.unlinkSync(sentinelPath); } catch { /* best-effort */ }
137
+ return false;
138
+ }
139
+ const token = fs.readFileSync(sentinelPath, 'utf8').split('\n')[0].trim();
140
+ if (!token) return false;
141
+ // Path-bound: the token names exactly one file, resolved against the
142
+ // .planning/ dir's parent (repo root) — same case-insensitive stance as
143
+ // the curated match itself.
144
+ const namedNorm = path.resolve(path.join(planningDir, '..'), token).replace(/\\/g, '/').toLowerCase();
145
+ if (namedNorm !== normalized.toLowerCase()) {
146
+ return false; // armed for a different file — leave it for that write
147
+ }
148
+ // Consume BEFORE allowing: even if the Write then fails, the safe
149
+ // direction is a spent token, never a lingering one.
150
+ fs.unlinkSync(sentinelPath);
151
+ return true;
152
+ } catch {
153
+ // Any sentinel-machinery error means "not exempt" — the guard's normal
154
+ // (blocking) flow proceeds; the hatch may never fail a guard open.
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // m2 (round 5): the block emission must itself be exception-safe. An EPIPE
160
+ // from writeSync inside the outer try would land in the fail-OPEN catch —
161
+ // the one outcome the fail-closed branches exist to prevent. The decision
162
+ // stands regardless of whether the payload could be delivered.
163
+ function emitBlock(output) {
164
+ try {
165
+ // writeSync: pipe writes via process.stdout/stderr are async on Windows
166
+ // and process.exit() does not flush them — a truncated block payload is
167
+ // a guard that silently half-fired.
168
+ fs.writeSync(1, JSON.stringify(output));
169
+ // Kimi feeds stderr (not stdout) back to the model on exit 2.
170
+ fs.writeSync(2, output.reason);
171
+ } catch {
172
+ // Emission failed; the block still stands.
173
+ }
174
+ process.exit(2);
175
+ }
176
+
177
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
178
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
179
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
180
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
181
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
182
+ // on Kimi. The tool_input field names differ as well (kimi-cli
183
+ // src/kimi_cli/tools/file/write.py): WriteFile takes `path`/`content`, and
184
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
185
+ // mapping. Only WriteFile is mapped: this guard exits 0 for any tool but
186
+ // Write, so an Edit-class mapping here would be dead code. Accepts bare and
187
+ // module-qualified ('kimi_cli.tools.file:WriteFile') names; unknown names fall
188
+ // through untouched. Inlined per guard (not hooks/lib/): hook scripts are
189
+ // staged as standalone files, and a sibling require is a staging dependency
190
+ // that can fail silently.
191
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
192
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
193
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
194
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
195
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write']]);
196
+ function normalizeKimiPayload(data) {
197
+ // #2595: total over everything JSON can express — JSON.parse('null') is
198
+ // null, and reading .tool_name off a primitive would throw into the outer
199
+ // fail-open catch. A null payload has nothing to guard; pass it through
200
+ // deliberately rather than by crash.
201
+ if (data === null || typeof data !== 'object') return data;
202
+ const raw = data.tool_name;
203
+ if (typeof raw !== 'string') return data;
204
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
205
+ if (!mapped) return data;
206
+ data.tool_name = mapped;
207
+ const input = data.tool_input;
208
+ if (input && typeof input === 'object') {
209
+ // #2595 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
210
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's
211
+ // WriteFile schema carries no `file_path` at all (src/kimi_cli/tools/
212
+ // file/write.py), so a `file_path` in a Kimi payload is ALWAYS
213
+ // model-supplied; under the old `=== undefined` condition a payload
214
+ // pairing a curated `path` with a spurious `file_path: ""` left this
215
+ // guard reading '' and exiting 0 while kimi-cli wrote to `path` — a
216
+ // one-key bypass needing no crash. Overwriting can only narrow what the
217
+ // guard inspects to the path that will actually be written.
218
+ if (typeof input.path === 'string') {
219
+ input.file_path = input.path;
220
+ }
221
+ }
222
+ return data;
223
+ }
224
+
225
+ let input = '';
226
+ const stdinTimeout = setTimeout(() => process.exit(0), 3000);
227
+ process.stdin.setEncoding('utf8');
228
+ process.stdin.on('data', chunk => input += chunk);
229
+ process.stdin.on('end', () => {
230
+ clearTimeout(stdinTimeout);
231
+ try {
232
+ const data = normalizeKimiPayload(JSON.parse(input));
233
+
234
+ // A null/primitive payload has nothing to guard — exit deliberately
235
+ // rather than throwing into the fail-open catch below (#2595 class).
236
+ if (data === null || typeof data !== 'object') {
237
+ process.exit(0);
238
+ }
239
+
240
+ // Only whole-file Write is catastrophic-by-construction; Edit/MultiEdit
241
+ // replace bounded spans and are out of scope by design (#2255).
242
+ if (data.tool_name !== 'Write') {
243
+ process.exit(0);
244
+ }
245
+
246
+ if (isOverrideSet()) {
247
+ process.exit(0); // documented escape hatch — legitimate reset in progress
248
+ }
249
+
250
+ // Typed read (#2547 class): `[]`/`{}` are truthy, pass a `!value`
251
+ // early-out, then throw inside path.resolve() — crash-to-allow via the
252
+ // outer catch. A non-string path field degrades to '' and exits here.
253
+ const rawInput = data.tool_input;
254
+ const rawFilePath = typeof rawInput?.file_path === 'string' ? rawInput.file_path : '';
255
+ const content = rawInput?.content;
256
+ if (!rawFilePath || typeof content !== 'string') {
257
+ process.exit(0);
258
+ }
259
+
260
+ // Resolve relative paths against the session cwd (the same base the
261
+ // runtime uses), then normalize separators for the curated match.
262
+ const cwd = data.cwd || process.cwd();
263
+ let filePath = path.resolve(cwd, rawFilePath);
264
+ // Resolve symlinks before the curated match (round 9, Minor 1): a Write
265
+ // to a non-curated path that symlinks into a curated file was not
266
+ // matched, while writeFileSync follows the link and clobbers the real
267
+ // target. ENOENT (new file) keeps the lexical resolution; any other
268
+ // realpath error also keeps it, and the read below then fails closed.
269
+ try {
270
+ filePath = fs.realpathSync(filePath);
271
+ } catch { /* keep the lexical path */ }
272
+ const normalized = filePath.replace(/\\/g, '/');
273
+
274
+ if (!CURATED_PATTERNS.some(re => re.test(normalized))) {
275
+ process.exit(0); // not a curated planning artifact
276
+ }
277
+
278
+ // Only guard overwrites — creating a curated file fresh is fine.
279
+ // ENOENT alone fails open (no baseline to protect); any OTHER read error
280
+ // (EACCES, EISDIR, ELOOP, EMFILE, a Windows lock) fails CLOSED — a guard
281
+ // that waves a curated Write through on a transient read error is not
282
+ // enforced by code at all, it is a race away from #973.
283
+ let onDisk;
284
+ try {
285
+ onDisk = fs.readFileSync(filePath, 'utf8');
286
+ } catch (err) {
287
+ if (err && err.code === 'ENOENT') {
288
+ process.exit(0); // does not exist — new-file Write, nothing to clobber
289
+ }
290
+ emitBlock({
291
+ decision: 'block',
292
+ readError: err && err.code ? String(err.code) : 'UNKNOWN',
293
+ overrideEnvVar: 'GSD_ALLOW_PLANNING_SHRINK',
294
+ overrideSentinel: SENTINEL_REL,
295
+ reason:
296
+ `Write guard: could not read '${filePath}' to compare against the pending ` +
297
+ `Write (${err && err.code ? err.code : 'unknown read error'}). ` +
298
+ `'${path.basename(filePath)}' is a curated planning artifact, so this guard ` +
299
+ `fails closed rather than risk a blind overwrite. Retry once the file is ` +
300
+ `readable, or — if this overwrite is intentional — re-run with the ` +
301
+ `environment variable GSD_ALLOW_PLANNING_SHRINK=1 to bypass this guard once.`,
302
+ });
303
+ }
304
+
305
+ const oldLines = countLines(onDisk);
306
+ const newLines = countLines(content);
307
+
308
+ if (oldLines < FLOOR_LINES) {
309
+ process.exit(0); // sub-floor stub — ratio checks are meaningless here
310
+ }
311
+
312
+ if (newLines >= oldLines * SHRINK_RATIO) {
313
+ process.exit(0); // shrink (if any) is within tolerance
314
+ }
315
+
316
+ // The mechanical hatch for workflow steps (see header): consulted only
317
+ // here, at the block point, so a within-tolerance write never burns it.
318
+ if (consumeSentinelFor(filePath, normalized)) {
319
+ process.exit(0); // armed for exactly this file, fresh, now consumed
320
+ }
321
+
322
+ const pct = Math.round((newLines / oldLines) * 100);
323
+ // Typed fields (oldLines/newLines/overrideEnvVar/overrideSentinel) ride
324
+ // alongside the free-form reason so consumers — including this repo's
325
+ // tests — never have to regex the prose (CONTRIBUTING.md: no raw text
326
+ // matching).
327
+ emitBlock({
328
+ decision: 'block',
329
+ oldLines,
330
+ newLines,
331
+ overrideEnvVar: 'GSD_ALLOW_PLANNING_SHRINK',
332
+ overrideSentinel: SENTINEL_REL,
333
+ // Round 9 Major 2: the denial deliberately does NOT explain how to arm
334
+ // the sentinel — #973 was an agent reasoning past an advisory, and a
335
+ // block message that prints the bypass recipe hands that same agent a
336
+ // mechanical self-authorization at the moment it is blocked. The
337
+ // sentinel transport stays documented where humans and the workflow
338
+ // engine read (USER-GUIDE, complete-milestone.md); the typed
339
+ // overrideSentinel field above stays for the binding tests. The env
340
+ // var stays named per #2255's acceptance criterion ("the override must
341
+ // be real and its name must appear in the block message") — it cannot
342
+ // reach a hook from a per-step prefix, so naming it does not hand the
343
+ // blocked agent a same-tool bypass.
344
+ reason:
345
+ `Write guard: this Write would shrink '${filePath}' from ${oldLines} lines to ` +
346
+ `${newLines} (${pct}% of current). '${path.basename(filePath)}' is a curated planning ` +
347
+ `artifact; a whole-file Write this much smaller usually means the payload was built ` +
348
+ `from a partial read of the file and would destroy the sections outside that window ` +
349
+ `(#973: a planner collapsed ROADMAP.md 292 → 16 lines this way). To fix: use Edit for ` +
350
+ `a scoped change, or Read the full file and include every section in the Write. ` +
351
+ `Intentional milestone resets go through the workflow's documented escape hatch; ` +
352
+ `interactively, re-run with the environment variable GSD_ALLOW_PLANNING_SHRINK=1 ` +
353
+ `to bypass this guard once.`,
354
+ });
355
+ } catch {
356
+ // Silent fail — never block valid tool calls due to hook errors
357
+ process.exit(0);
358
+ }
359
+ });
package/hooks/hooks.json CHANGED
@@ -21,6 +21,18 @@
21
21
  "hooks": [
22
22
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js\"", "timeout": 5 }
23
23
  ]
24
+ },
25
+ {
26
+ "matcher": "Write",
27
+ "hooks": [
28
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js\"", "timeout": 5 }
29
+ ]
30
+ },
31
+ {
32
+ "matcher": "Agent|Task",
33
+ "hooks": [
34
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-agent-isolation-guard.js\"", "timeout": 5 }
35
+ ]
24
36
  }
25
37
  ],
26
38
  "PostToolUse": [