@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
@@ -149,6 +149,81 @@ function atomicWriteFileSync(target, data, options) {
149
149
  }
150
150
  }
151
151
  // ---------------------------------------------------------------------------
152
+ // CommonJS package.json marker for staged .js hook scripts (#2717)
153
+ //
154
+ // Node resolves the nearest package.json walking up from a .js file. When a
155
+ // runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any
156
+ // parent — declares {"type":"module"}, Node loads GSD's staged CommonJS hook
157
+ // scripts as ESM and every require() fails with "require is not defined",
158
+ // silently disabling that runtime's lifecycle hooks.
159
+ //
160
+ // installSharedHooksBundle writes this marker for the 12 runtimes that go
161
+ // through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall)
162
+ // and codex (the !isCodex gate) stage their .js hooks via the dedicated paths
163
+ // below and never reached it. These helpers decouple the marker write from the
164
+ // shared bundle so any code path that stages .js hooks can ensure the marker
165
+ // lands in the SAME directory as the scripts (#2717).
166
+ //
167
+ // The marker content is byte-identical to installSharedHooksBundle's
168
+ // (bin/install.js installSharedHooksBundle): {"type":"commonjs"}\n.
169
+ // ---------------------------------------------------------------------------
170
+ /** The exact marker content GSD writes, matching installSharedHooksBundle. */
171
+ const COMMONJS_MARKER_CONTENT = '{"type":"commonjs"}\n';
172
+ /**
173
+ * Ensure a `package.json` forcing CommonJS mode exists in `dir` (the directory
174
+ * holding GSD-staged `.js` hook scripts). Idempotent: a no-op if the marker is
175
+ * already present with GSD's content. Overwrites only when the file is absent
176
+ * or already carries GSD's exact marker — it never clobbers a distinct
177
+ * user-authored package.json (it leaves such a file in place; the user owns it).
178
+ *
179
+ * @param dir - absolute path to the directory holding the staged .js hooks
180
+ * @returns `true` if the marker is present after the call (written or already there)
181
+ */
182
+ function ensureCommonJsMarker(dir) {
183
+ const markerPath = node_path_1.default.join(dir, 'package.json');
184
+ try {
185
+ if (node_fs_1.default.existsSync(markerPath)) {
186
+ const existing = node_fs_1.default.readFileSync(markerPath, 'utf8');
187
+ // Already GSD's marker (tolerant of trailing-whitespace variants) — done.
188
+ if (existing.trim() === '{"type":"commonjs"}')
189
+ return true;
190
+ // A distinct package.json the user owns — do NOT clobber. The hook will
191
+ // load as whatever type the user declared; that is the user's choice.
192
+ return false;
193
+ }
194
+ node_fs_1.default.writeFileSync(markerPath, COMMONJS_MARKER_CONTENT);
195
+ return true;
196
+ }
197
+ catch {
198
+ // Best-effort: a marker write failure must not fail the whole install.
199
+ return false;
200
+ }
201
+ }
202
+ /**
203
+ * Remove the CommonJS marker from `dir` on uninstall — but ONLY if it carries
204
+ * GSD's exact marker content. A user-authored package.json is never deleted.
205
+ * Mirrors the kimi uninstall guard in bin/install.js.
206
+ *
207
+ * @param dir - absolute path to the directory that held the staged .js hooks
208
+ * @returns `true` if a GSD-owned marker was removed
209
+ */
210
+ function removeCommonJsMarkerIfGsdOwned(dir) {
211
+ const markerPath = node_path_1.default.join(dir, 'package.json');
212
+ try {
213
+ if (!node_fs_1.default.existsSync(markerPath))
214
+ return false;
215
+ const content = node_fs_1.default.readFileSync(markerPath, 'utf8').trim();
216
+ if (content === '{"type":"commonjs"}') {
217
+ node_fs_1.default.unlinkSync(markerPath);
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ // ---------------------------------------------------------------------------
152
227
  // parseTomlValue + findMultilineBasicStringClose
153
228
  // (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state)
154
229
  // ---------------------------------------------------------------------------
@@ -309,6 +384,22 @@ function normalizeNodePath(execPath, opts) {
309
384
  if (existsSync(shim))
310
385
  return shim;
311
386
  }
387
+ // volta pins a concrete node image at <VOLTA_HOME>/tools/image/node/<ver>/bin/node
388
+ // (Windows: <VOLTA_HOME>/tools/image/node/<ver>/node.exe — volta's own layout
389
+ // puts node.exe at the image root, no bin/). `volta uninstall node@<ver>` prunes
390
+ // that image, so a baked hook command 404s — the same ephemeral-path failure
391
+ // #977 fixed for fnm and #1619 for mise. The stable alias is the shim
392
+ // <VOLTA_HOME>/bin/node, a symlink to volta-shim that always resolves to the
393
+ // active pin. Derive <VOLTA_HOME> from execPath rather than the env so a custom
394
+ // VOLTA_HOME and the Windows %LOCALAPPDATA%\Volta default both work (#2185's
395
+ // reasoning), and only rewrite when the shim exists — otherwise fall back to
396
+ // the raw execPath unchanged.
397
+ const voltaMatch = normalizedForMatch.match(/^(.*)\/tools\/image\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/);
398
+ if (voltaMatch) {
399
+ const shim = `${voltaMatch[1]}/bin/node${voltaMatch[2] || ''}`;
400
+ if (existsSync(shim))
401
+ return shim;
402
+ }
312
403
  return execPath;
313
404
  }
314
405
  function resolveNodeRunner(opts) {
@@ -940,6 +1031,48 @@ function writeCursorHooksJson(targetDir, src, opts) {
940
1031
  installedScripts.add(script);
941
1032
  }
942
1033
  }
1034
+ // Stage the hooks/lib/ helpers the staged scripts require (#2587). Cursor sets
1035
+ // hostBehaviors.skipSharedHooksInstall, so it never reaches the installer's
1036
+ // bulk hooks/lib copy — without this, a script requiring './lib/…' would throw
1037
+ // MODULE_NOT_FOUND at load, BEFORE its own try/catch, and wedge every Cursor
1038
+ // session on the one runtime these hooks exist for. Driven off what the staged
1039
+ // scripts actually require so a future helper cannot be silently omitted.
1040
+ const requiredLibFiles = new Set();
1041
+ for (const script of installedScripts) {
1042
+ const staged = node_fs_1.default.readFileSync(node_path_1.default.join(hooksDir, script), 'utf8');
1043
+ // Tolerant of interior whitespace and either quote style: a hook author
1044
+ // writing `require( "./lib/x.js" )` must still get its helper staged, since
1045
+ // a miss here surfaces as MODULE_NOT_FOUND at hook load, not at install.
1046
+ const re = /require\(\s*['"]\.\/lib\/([A-Za-z0-9._-]+)['"]\s*\)/g;
1047
+ let m;
1048
+ while ((m = re.exec(staged)) !== null)
1049
+ requiredLibFiles.add(m[1]);
1050
+ }
1051
+ if (requiredLibFiles.size > 0) {
1052
+ const srcLibDir = node_path_1.default.join(srcHooksDir, 'lib');
1053
+ const destLibDir = node_path_1.default.join(hooksDir, 'lib');
1054
+ node_fs_1.default.mkdirSync(destLibDir, { recursive: true });
1055
+ for (const libFile of requiredLibFiles) {
1056
+ const libSrc = node_path_1.default.join(srcLibDir, libFile);
1057
+ if (!node_fs_1.default.existsSync(libSrc)) {
1058
+ // FAIL LOUD. Skipping here would ship hook scripts whose top-level
1059
+ // require() throws before their own try/catch, wedging every session —
1060
+ // and the install would still exit 0, so nobody would know until a user
1061
+ // hit it. A missing helper source is a packaging bug; surface it.
1062
+ throw new Error(`hooks/lib/${libFile} is required by a staged Cursor hook but is missing from ${srcLibDir}. `
1063
+ + 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.');
1064
+ }
1065
+ let libContent = node_fs_1.default.readFileSync(libSrc, 'utf8');
1066
+ libContent = libContent.replace(/gsd:/gi, 'gsd-');
1067
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destLibDir, libFile), libContent);
1068
+ }
1069
+ }
1070
+ // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1071
+ // scripts. Cursor sets skipSharedHooksInstall, so it never reaches
1072
+ // installSharedHooksBundle (the only other writer of this marker); without
1073
+ // it, a ~/.cursor/package.json declaring {"type":"module"} makes Node load
1074
+ // these require()-using scripts as ESM and every Cursor hook fails silently.
1075
+ ensureCommonJsMarker(hooksDir);
943
1076
  const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
944
1077
  const commands = {};
945
1078
  for (const ev of events) {
@@ -971,6 +1104,14 @@ function removeCursorHooksJson(targetDir) {
971
1104
  const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
972
1105
  if (!hasAnyEvents) {
973
1106
  node_fs_1.default.unlinkSync(hooksJsonPath);
1107
+ // #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
1108
+ // only if it still carries GSD's exact content (a user-authored
1109
+ // package.json is never deleted). Best-effort: a failure here must not
1110
+ // mask the hooks.json removal above.
1111
+ try {
1112
+ removeCommonJsMarkerIfGsdOwned(node_path_1.default.join(targetDir, 'hooks'));
1113
+ }
1114
+ catch { /* leave it */ }
974
1115
  return { changed: true };
975
1116
  }
976
1117
  }
@@ -1120,6 +1261,12 @@ function writeWindsurfHooksJson(targetDir, src, opts) {
1120
1261
  installedScripts.add(script);
1121
1262
  }
1122
1263
  }
1264
+ // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1265
+ // scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
1266
+ // installSharedHooksBundle (the only other writer of this marker); without
1267
+ // it, a config-root package.json declaring {"type":"module"} makes Node load
1268
+ // these require()-using scripts as ESM and the Windsurf hooks fail silently.
1269
+ ensureCommonJsMarker(hooksDir);
1123
1270
  const hookOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
1124
1271
  const commands = {};
1125
1272
  for (const ev of WINDSURF_HOOK_EVENTS) {
@@ -1158,6 +1305,13 @@ function removeWindsurfHooksJson(targetDir) {
1158
1305
  const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
1159
1306
  if (!hasAnyEvents) {
1160
1307
  node_fs_1.default.unlinkSync(hooksJsonPath);
1308
+ // #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
1309
+ // only if it still carries GSD's exact content (a user-authored
1310
+ // package.json is never deleted). Best-effort.
1311
+ try {
1312
+ removeCommonJsMarkerIfGsdOwned(node_path_1.default.join(targetDir, 'hooks'));
1313
+ }
1314
+ catch { /* leave it */ }
1161
1315
  return { changed: true };
1162
1316
  }
1163
1317
  }
@@ -1947,6 +2101,8 @@ module.exports = {
1947
2101
  GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
1948
2102
  GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
1949
2103
  GSD_WINDSURF_HOOK_SCRIPTS,
2104
+ ensureCommonJsMarker,
2105
+ removeCommonJsMarkerIfGsdOwned,
1950
2106
  GSD_WINDSURF_HOOK_MARKER,
1951
2107
  // Copilot
1952
2108
  buildCopilotHookConfig,
@@ -40,6 +40,7 @@ const FALLBACK_ALIASES = {
40
40
  qwen: ['qwen', 'qwen-code', 'qwen-cli'],
41
41
  hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
42
42
  kimi: ['kimi'],
43
+ 'kimi-code': ['kimi-code', 'kimicode', 'kimi_code'],
43
44
  codebuddy: ['codebuddy', 'codebuddy-cli'],
44
45
  cline: ['cline', 'cline-cli'],
45
46
  };
@@ -248,6 +249,7 @@ const RUNTIME_LABELS = {
248
249
  qwen: 'Qwen Code',
249
250
  hermes: 'Hermes Agent',
250
251
  kimi: 'Kimi CLI',
252
+ 'kimi-code': 'Kimi Code',
251
253
  codebuddy: 'CodeBuddy',
252
254
  cline: 'Cline',
253
255
  zcode: 'ZCode',
@@ -303,6 +305,7 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS = {
303
305
  codebuddy: "'.codebuddy'",
304
306
  cline: "'.cline'",
305
307
  kimi: "'.config', 'agents'",
308
+ 'kimi-code': "'.kimi-code'",
306
309
  zcode: "'.zcode'",
307
310
  // pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
308
311
  // parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
@@ -337,8 +340,18 @@ function getGlobalConfigHomeFragment(runtime) {
337
340
  // folds the shared-hooks-install skip).
338
341
  const RUNTIME_FLAG_IDS = Object.freeze([
339
342
  'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
340
- 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode', 'pi',
343
+ 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'kimi-code', 'zcode', 'pi',
341
344
  ]);
345
+ /**
346
+ * Convert a runtime id (kebab-case, e.g. 'kimi-code') to its `is<Foo>` flag
347
+ * name in PascalCase (e.g. 'isKimiCode'). The first letter is capitalised and
348
+ * every `-[a-z]` boundary is folded to its uppercase twin. Single-word ids
349
+ * (the prior 16: opencode, kilo, codex, …) are unaffected — only hyphenated
350
+ * ids like 'kimi-code' (#2454) hit the folding branch.
351
+ */
352
+ function runtimeIdToFlagName(id) {
353
+ return 'is' + id.charAt(0).toUpperCase() + id.slice(1).replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
354
+ }
342
355
  /**
343
356
  * Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
344
357
  * id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
@@ -349,7 +362,7 @@ const RUNTIME_FLAG_IDS = Object.freeze([
349
362
  function runtimeFlags(runtime) {
350
363
  const flags = {};
351
364
  for (const id of RUNTIME_FLAG_IDS) {
352
- flags['is' + id.charAt(0).toUpperCase() + id.slice(1)] = runtime === id;
365
+ flags[runtimeIdToFlagName(id)] = runtime === id;
353
366
  }
354
367
  return Object.freeze(flags);
355
368
  }
@@ -52,6 +52,9 @@ const { planningPaths } = planningWorkspace;
52
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
53
  const frontmatter = require("./frontmatter.cjs");
54
54
  const { extractFrontmatter } = frontmatter;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
56
+ const phaseLifecycle = require("./phase-lifecycle.cjs");
57
+ const { deriveProgressFromRoadmap } = phaseLifecycle;
55
58
  // eslint-disable-next-line @typescript-eslint/no-require-imports
56
59
  const stateDocument = require("./state-document.cjs");
57
60
  const { stateExtractField } = stateDocument;
@@ -221,7 +224,7 @@ function readStateFile(statePath) {
221
224
  catch {
222
225
  return null;
223
226
  }
224
- const fm = extractFrontmatter(content);
227
+ const fm = extractFrontmatter(content, statePath);
225
228
  const body = content.replace(/^---[\s\S]*?---\s*/, '');
226
229
  return { fm, body };
227
230
  }
@@ -245,6 +248,8 @@ function detectSignals(cwd, now = Date.now) {
245
248
  has_git: git.has_git,
246
249
  verify_failed: false,
247
250
  stale_activity: false,
251
+ roadmap_total_phases: null,
252
+ roadmap_completed_phases: null,
248
253
  };
249
254
  if (!hasPlanning)
250
255
  return empty;
@@ -291,6 +296,24 @@ function detectSignals(cwd, now = Date.now) {
291
296
  // STATUS: marker on the current phase's summary/verify artifact.
292
297
  const verifyFailed = /\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') ||
293
298
  detectVerifyFailed(cwd, currentPhaseRaw);
299
+ // #2427: derive global phase counts from ROADMAP.md's Progress table. These
300
+ // are preferred over STATE.md's cached milestone-scoped `total_phases` (which
301
+ // goes stale when phases are appended after a milestone switch) for the
302
+ // completion check. Null when ROADMAP.md is absent or has no parseable
303
+ // Progress table — isComplete falls back to the legacy comparison in that case.
304
+ let roadmapTotalPhases = null;
305
+ let roadmapCompletedPhases = null;
306
+ if (hasRoadmap) {
307
+ try {
308
+ const roadmapContent = node_fs_1.default.readFileSync(paths.roadmap, 'utf8');
309
+ const derived = deriveProgressFromRoadmap(roadmapContent);
310
+ roadmapTotalPhases = derived.totalPhases;
311
+ roadmapCompletedPhases = derived.completedPhases;
312
+ }
313
+ catch {
314
+ /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
315
+ }
316
+ }
294
317
  return {
295
318
  current_phase: parseIntOrNull(currentPhaseRaw),
296
319
  total_phases: parseIntOrNull(totalPhasesRaw),
@@ -305,14 +328,56 @@ function detectSignals(cwd, now = Date.now) {
305
328
  has_git: git.has_git,
306
329
  verify_failed: verifyFailed,
307
330
  stale_activity: staleActivity,
331
+ roadmap_total_phases: roadmapTotalPhases,
332
+ roadmap_completed_phases: roadmapCompletedPhases,
308
333
  };
309
334
  }
310
335
  // ─── Situation classification ─────────────────────────────────────────────────
311
- /** True when the workflow has fully completed all phases. */
336
+ /**
337
+ * True when the workflow has fully completed all phases.
338
+ *
339
+ * #2427: completion is grounded in ROADMAP.md's Progress table (global,
340
+ * authoritative, never stale) when available, with a legacy fallback to
341
+ * STATE.md's cached `total_phases` when the roadmap has no parseable Progress
342
+ * table (e.g. a fresh project or a non-standard roadmap layout). The status
343
+ * regex was tightened to require milestone-level completion language
344
+ * (`milestone complete` / `all phases complete` / `complete(d)`) and no longer
345
+ * matches per-phase messages like "Phase X shipped — PR #N" that falsely
346
+ * satisfied the pre-fix alternation (`\bcomplete(d)?|done|shipped\b`).
347
+ */
312
348
  function isComplete(s) {
313
- if (s.total_phases === null || s.current_phase === null)
314
- return false;
315
- return s.current_phase >= s.total_phases && /\bcomplete(d)?|done|shipped\b/i.test(s.status);
349
+ // Prefer ROADMAP-derived counts (global, authoritative) over STATE.md's
350
+ // cached milestone-scoped total_phases (stale-prone). Fall back to legacy
351
+ // when the roadmap has no Progress table.
352
+ if (s.roadmap_total_phases !== null && s.roadmap_completed_phases !== null) {
353
+ if (s.roadmap_total_phases === 0)
354
+ return false;
355
+ if (s.roadmap_completed_phases < s.roadmap_total_phases)
356
+ return false;
357
+ }
358
+ else {
359
+ // Legacy path: STATE.md comparison. Still subject to the two-scale bug,
360
+ // but only fires when ROADMAP.md is absent or has no Progress table.
361
+ if (s.total_phases === null || s.current_phase === null)
362
+ return false;
363
+ if (s.current_phase < s.total_phases)
364
+ return false;
365
+ }
366
+ // Status regex: require milestone-level completion language. The pre-fix
367
+ // regex matched any "shipped" / "done" substring (per-phase language).
368
+ // Tightened to match:
369
+ // - "milestone complete" (ADR-2207 terminal status — usually written as
370
+ // "<version> milestone complete", e.g. "v1.0 milestone complete"; the
371
+ // substring match handles both forms)
372
+ // - "all phases complete" (ADR-2207 intermediate terminal)
373
+ // - "complete" / "completed" (legacy short form — STATE.md milestone
374
+ // status is a single value, not a per-phase log)
375
+ // Intentionally does NOT match "done" alone even though normalizeStateStatus
376
+ // (state-document.cts) treats "done" as "completed" — in the milestone
377
+ // status field, "done" is per-phase noise (e.g. "Phase X done"), not a
378
+ // milestone-completion signal. Mirrors workstream-inventory-builder.cts's
379
+ // terminal pattern \bmilestone\s+complete\b.
380
+ return /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.test(s.status);
316
381
  }
317
382
  /** Idle-stranded: clean tree, committed work not shipped, optionally stale. */
318
383
  function isIdleStranded(s) {
@@ -18,6 +18,7 @@ exports.shouldPreserveExistingProgress = shouldPreserveExistingProgress;
18
18
  exports.normalizeProgressNumbers = normalizeProgressNumbers;
19
19
  exports.isStateTemplateDefault = isStateTemplateDefault;
20
20
  exports.stateReplaceFieldIfTemplate = stateReplaceFieldIfTemplate;
21
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
21
22
  // Internal helpers
22
23
  function escapeRegex(str) {
23
24
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -40,22 +41,167 @@ function isTableSeparatorRow(firstCell) {
40
41
  // A separator cell contains only dashes, colons (alignment hints), and whitespace.
41
42
  return /^[\s\-:]+$/.test(firstCell.trim());
42
43
  }
44
+ function countLeading(str) {
45
+ const match = /^[ \t]*/.exec(str);
46
+ return match ? match[0].length : 0;
47
+ }
48
+ /**
49
+ * Canonicalize one UTF-16 code unit per the ECMAScript non-unicode
50
+ * `Canonicalize` abstract operation, which governs how a case-insensitive
51
+ * (`/i`, no `u` flag) RegExp compares characters: take `ch.toUpperCase()`.
52
+ * The uppercasing is REJECTED (the original character is kept as-is) in
53
+ * either of two cases: (1) `ch.toUpperCase()` does not produce exactly one
54
+ * character (e.g. "ß" -> "SS" — a multi-character case-fold can never be a
55
+ * per-character regex match, so Canonicalize leaves it alone), or (2) it
56
+ * produces exactly one character but the original character's code point is
57
+ * >= 128 while the uppercased character's code point is < 128 (this is what
58
+ * stops a non-ASCII character from folding onto an ASCII one under `/i` —
59
+ * e.g. KELVIN SIGN U+212A uppercases to ASCII "K" (U+004B), so this rule
60
+ * rejects the fold and keeps U+212A, meaning `/k/i`/`/K/i` do NOT match
61
+ * U+212A). Otherwise, the uppercased character is used. Plain
62
+ * `.toLowerCase()`/`.toUpperCase()` folds both of these cases, which is
63
+ * exactly why they diverge from real regex `/i` semantics.
64
+ */
65
+ function canonicalizeCharForCaselessCompare(ch) {
66
+ const upper = ch.toUpperCase();
67
+ if (upper.length !== 1) {
68
+ return ch;
69
+ }
70
+ if (ch.charCodeAt(0) >= 128 && upper.charCodeAt(0) < 128) {
71
+ return ch;
72
+ }
73
+ return upper;
74
+ }
75
+ /**
76
+ * Canonicalize a whole string, one UTF-16 code unit at a time, per the
77
+ * ECMAScript non-unicode `Canonicalize` rule (see
78
+ * canonicalizeCharForCaselessCompare) so that two strings compare equal
79
+ * under this function iff a non-`u`-flag `/i` RegExp would treat them as
80
+ * the same literal text. This is the correct replacement for
81
+ * `.toLowerCase()` when replicating a non-`u` `/i` regex: `.toLowerCase()`
82
+ * folds some non-ASCII characters (e.g. KELVIN SIGN U+212A) onto their
83
+ * ASCII counterparts, which real `/i` regex semantics do not. Iteration is
84
+ * by UTF-16 code unit (not code point) to match how a non-`u` regex engine
85
+ * itself operates on surrogate halves individually.
86
+ */
87
+ function canonicalizeForCaselessCompare(str) {
88
+ let result = '';
89
+ for (let i = 0; i < str.length; i++) {
90
+ result += canonicalizeCharForCaselessCompare(str[i]);
91
+ }
92
+ return result;
93
+ }
43
94
  /**
44
- * Build a regex that matches a pipe-table row `| FieldName | value |` for the
45
- * given (already-escaped) field name. The match is case-insensitive and
46
- * tolerates variable amounts of whitespace around the cell contents.
95
+ * Return true when the caller's raw (untrimmed) `fieldName` may be considered
96
+ * to match a row's raw (untrimmed) field cell text. Faithfully replicates the
97
+ * backtracking of the regex this function replaced: `^(\|[ \t]*)(FieldName)
98
+ * ([ \t]*\|...)`. Group 1 (`\|[ \t]*`, greedy but backtrackable) can hand any
99
+ * PREFIX of the cell's leading `[ \t]` run over to group 2 (the literal,
100
+ * case-insensitive `fieldName` text) — so `fieldName` is tried at every offset
101
+ * `j` from 0 up to the length of that leading run. For a given `j` to be a
102
+ * genuine match, two things must hold: `rawCell.slice(j, j + fieldName.length)`
103
+ * must equal `fieldName` case-insensitively (group 2), AND everything left
104
+ * over after it — `rawCell.slice(j + fieldName.length)` — must be entirely
105
+ * `[ \t]` characters, because group 3 (`[ \t]*\|`) must consume that leftover
106
+ * as whitespace before it can reach the delimiter pipe.
47
107
  *
48
- * Capture group 1: leading pipe + whitespace before the field cell
49
- * Capture group 2: the field name cell text (trimmed)
50
- * Capture group 3: whitespace between field cell and separator pipe
51
- * Capture group 4: the value cell text (trimmed)
52
- * Capture group 5: trailing whitespace + closing pipe(s)
108
+ * A simple count-of-leading/trailing-whitespace comparison is NOT equivalent:
109
+ * it ignores that group 2 is a literal-character match, not a whitespace-
110
+ * class match, so it can produce false positives whenever `fieldName`'s own
111
+ * padding is a different run of `[ \t]` characters than the cell's (e.g.
112
+ * `fieldName` padded with spaces against a cell padded with tabs) — caught by
113
+ * differential fuzzing against the regex this replaces.
53
114
  *
54
- * We use a single-line match (`m` flag so ^ anchors work on each line) to
55
- * avoid cross-row replacement.
115
+ * The case-insensitive comparison itself is done via
116
+ * canonicalizeForCaselessCompare, NOT `.toLowerCase()`: the replaced regex
117
+ * used `/i` WITHOUT the `u` flag, whose case-folding is the ECMAScript
118
+ * non-unicode `Canonicalize` operation. `.toLowerCase()` folds some non-ASCII
119
+ * characters onto ASCII ones (e.g. KELVIN SIGN U+212A -> "k") that `/i`
120
+ * (no `u`) does NOT fold, so `.toLowerCase()` alone would NOT faithfully
121
+ * replicate the old regex's semantics; canonicalizeForCaselessCompare does.
56
122
  */
57
- function tableRowPattern(escapedFieldName) {
58
- return new RegExp(`^(\\|[ \\t]*)(${escapedFieldName})([ \\t]*\\|[ \\t]*)([^|\\n]*?)([ \\t]*\\|[ \\t]*)$`, 'im');
123
+ function fieldNameMatchesRawCell(fieldName, rawCell) {
124
+ const n = fieldName.length;
125
+ const cellLength = rawCell.length;
126
+ if (n > cellLength)
127
+ return false;
128
+ const leadingRun = countLeading(rawCell);
129
+ const maxOffset = Math.min(leadingRun, cellLength - n);
130
+ const canonicalFieldName = canonicalizeForCaselessCompare(fieldName);
131
+ for (let j = 0; j <= maxOffset; j++) {
132
+ if (canonicalizeForCaselessCompare(rawCell.slice(j, j + n)) !== canonicalFieldName)
133
+ continue;
134
+ if (/^[ \t]*$/.test(rawCell.slice(j + n)))
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ /**
140
+ * Locate the value cell of a pipe-table row `| FieldName | value |` for the
141
+ * given field name, by scanning `content` line by line (no whole-document
142
+ * regex). Only a strict two-column row (exactly 3 `|` chars, starting the
143
+ * line, ending the line after trailing space/tab is stripped) is considered;
144
+ * this is what makes a 3-column row or an unescaped-pipe-bearing value cell
145
+ * fail to match, mirroring the previous regex's behaviour. Separator rows
146
+ * (`| --- | --- |`) are skipped, not matched. The match is case-insensitive.
147
+ * A line terminator is `\r\n`, a lone `\r`, or a lone `\n` — matching the `m`
148
+ * flag semantics of the regex this function replaced. Returns the byte range
149
+ * of the value cell (after trimming surrounding space/tab) so the caller can
150
+ * splice it directly.
151
+ */
152
+ function locateFieldRow(content, fieldName) {
153
+ let lineStart = 0;
154
+ while (lineStart <= content.length) {
155
+ // A line terminator is `\r\n`, a lone `\r`, or a lone `\n` (JS treats a
156
+ // bare `\r` as a line terminator too — the regex this replaced used the
157
+ // `m` flag, which honors all three). Scan for whichever of `\r`/`\n`
158
+ // occurs first; if it's `\r` immediately followed by `\n`, the terminator
159
+ // is 2 chars wide, otherwise 1.
160
+ let terminatorIndex = -1;
161
+ let terminatorLength = 0;
162
+ for (let i = lineStart; i < content.length; i++) {
163
+ const ch = content[i];
164
+ if (ch === '\n') {
165
+ terminatorIndex = i;
166
+ terminatorLength = 1;
167
+ break;
168
+ }
169
+ if (ch === '\r') {
170
+ terminatorIndex = i;
171
+ terminatorLength = content[i + 1] === '\n' ? 2 : 1;
172
+ break;
173
+ }
174
+ }
175
+ const lineEnd = terminatorIndex === -1 ? content.length : terminatorIndex;
176
+ const line = content.slice(lineStart, lineEnd);
177
+ if (line.startsWith('|')) {
178
+ const pipeCount = (line.match(/\|/g) || []).length;
179
+ const trimmedEnd = line.replace(/[ \t]+$/, '');
180
+ if (pipeCount === 3 && trimmedEnd.endsWith('|')) {
181
+ const cells = (0, markdown_table_cjs_1.splitTableRow)(line);
182
+ if (cells.length === 2 && !isTableSeparatorRow(cells[0])) {
183
+ // Line has exactly 3 pipes (enforced above): opening pipe, the
184
+ // field/value separator pipe, and the row-closing pipe.
185
+ const fieldValueSeparatorPipe = line.indexOf('|', line.indexOf('|') + 1);
186
+ const rawCell = line.slice(1, fieldValueSeparatorPipe);
187
+ if (fieldNameMatchesRawCell(fieldName, rawCell)) {
188
+ const rowClosingPipe = line.indexOf('|', fieldValueSeparatorPipe + 1);
189
+ let valueStart = lineStart + fieldValueSeparatorPipe + 1;
190
+ while (content[valueStart] === ' ' || content[valueStart] === '\t')
191
+ valueStart++;
192
+ let valueEnd = lineStart + rowClosingPipe;
193
+ while (valueEnd - 1 >= valueStart && (content[valueEnd - 1] === ' ' || content[valueEnd - 1] === '\t'))
194
+ valueEnd--;
195
+ return { valueStart, valueEnd, rawValue: content.slice(valueStart, valueEnd) };
196
+ }
197
+ }
198
+ }
199
+ }
200
+ if (terminatorIndex === -1)
201
+ break;
202
+ lineStart = terminatorIndex + terminatorLength;
203
+ }
204
+ return null;
59
205
  }
60
206
  function stateExtractField(content, fieldName) {
61
207
  const escaped = escapeRegex(fieldName);
@@ -71,9 +217,9 @@ function stateExtractField(content, fieldName) {
71
217
  return plainMatch[1].trim();
72
218
  // Pipe-table format: | FieldName | value |
73
219
  // (Separator rows such as `| --- | --- |` are excluded.)
74
- const tableMatch = content.match(tableRowPattern(escaped));
75
- if (tableMatch && !isTableSeparatorRow(tableMatch[2]))
76
- return tableMatch[4].trim();
220
+ const hit = locateFieldRow(content, fieldName);
221
+ if (hit)
222
+ return hit.rawValue.trim();
77
223
  return null;
78
224
  }
79
225
  function stateReplaceField(content, fieldName, newValue) {
@@ -90,11 +236,9 @@ function stateReplaceField(content, fieldName, newValue) {
90
236
  }
91
237
  // Pipe-table format: | FieldName | value |
92
238
  // Preserve the surrounding pipe/whitespace structure; only swap the value cell.
93
- const tblPat = tableRowPattern(escaped);
94
- const tblMatch = content.match(tblPat);
95
- if (tblMatch && !isTableSeparatorRow(tblMatch[2])) {
96
- // Reconstruct the row, preserving the original surrounding whitespace/pipes.
97
- return content.replace(tblPat, (_m, leadPipe, fieldCell, midPipe, _oldVal, trailPipe) => `${leadPipe}${fieldCell}${midPipe}${newValue}${trailPipe}`);
239
+ const hit = locateFieldRow(content, fieldName);
240
+ if (hit) {
241
+ return content.slice(0, hit.valueStart) + newValue + content.slice(hit.valueEnd);
98
242
  }
99
243
  return null;
100
244
  }
@@ -153,11 +297,14 @@ function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
153
297
  return false;
154
298
  const existing = existingProgress;
155
299
  const derived = derivedProgress;
156
- // total_phases is intentionally excluded from the ratchet: it must always
157
- // take the freshly derived value so it can correct downward (#1446).
158
- // Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
300
+ // total_phases (#1446) and total_plans (#2440) are intentionally excluded
301
+ // from the ratchet: both must always take the freshly derived value so they
302
+ // can correct in BOTH directions. total_plans legitimately moves up (a new
303
+ // phase adds plans) and down (milestone reorganization removes phases).
304
+ // Ratcheting it freezes stale values. Only completed_phases and
305
+ // completed_plans keep ratchet behaviour — they are monotonic (once a
306
+ // phase/plan is complete, it stays complete).
159
307
  return (existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
160
- existingProgressExceedsDerived(existing, derived, 'total_plans') ||
161
308
  existingProgressExceedsDerived(existing, derived, 'completed_plans'));
162
309
  }
163
310
  function normalizeProgressNumbers(progress) {