@opengsd/gsd-core 1.7.0-rc.6 → 1.8.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 (195) 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 +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -30,7 +30,7 @@ const { planningDir, planningPaths } = planningWorkspace;
30
30
  const clock_cjs_1 = require("./clock.cjs");
31
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
32
  const frontmatter = require("./frontmatter.cjs");
33
- const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
33
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -38,6 +38,7 @@ const stateTransitionMod = require("./state-transition.cjs");
38
38
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
39
39
  const state_document_cjs_1 = require("./state-document.cjs");
40
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
41
42
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
42
43
  'Progress',
43
44
  'Total Plans in Phase',
@@ -143,8 +144,11 @@ function _stateLockBodyPid(lockPath) {
143
144
  }
144
145
  // Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
145
146
  let _stateStealSeq = 0;
146
- // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
147
- const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\r?\n\|(?:[- :\t]+\|)+[ \t]*\r?\n)((?:[ \t]*\|[^\n]*\n)*)(?=\r?\n|$)/i;
147
+ // The `byPhaseTablePattern` regex hoisted here for #320 (canonical-column-
148
+ // ORDER-only By-Phase table match) is retired (#2245 audit): its last caller
149
+ // — updatePerformanceMetricsSection's row-INSERT branch — now locates the
150
+ // table via findTableStartOffset/insertTableRow, name-addressed and
151
+ // header-order-agnostic like the update/sum halves of the same function.
148
152
  // ─── ADR-1372 T6: seam-based section splice helper ───────────────────────────
149
153
  // Shared stop predicates corresponding to the regex lookaheads used in state.cts:
150
154
  // STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2
@@ -166,6 +170,12 @@ function cmdStateLoad(cwd, raw) {
166
170
  state_exists: stateExists,
167
171
  roadmap_exists: roadmapExists,
168
172
  config_exists: configExists,
173
+ // #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
174
+ // spawned subagent's own cwd may differ from the orchestrator's.
175
+ // debug.md has no init.* call of its own; it reads this field from
176
+ // `state load` to build debug_file_path for its gsd-debug-session-manager
177
+ // spawns instead of hardcoding '.planning/debug/{slug}.md'.
178
+ debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(node_path_1.default.join(planDir, 'debug')),
169
179
  };
170
180
  // For --raw, output a condensed key=value format
171
181
  if (raw) {
@@ -381,29 +391,131 @@ function cmdStateRecordMetric(cwd, options, raw) {
381
391
  let _recorded = false;
382
392
  let created = false;
383
393
  readModifyWriteStateMd(statePath, (content) => {
384
- // Find Performance Metrics section and its table
385
- const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; // allow-adhoc-markdown: metrics-table write-path section-collect in state.cts; pending collectSection migration #1372
386
- const metricsMatch = content.match(metricsPattern);
387
394
  const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
388
- if (metricsMatch) {
389
- let tableBody = metricsMatch[2].trimEnd();
390
- if (tableBody.trim() === '' || tableBody.includes('None yet')) {
391
- tableBody = newRow;
395
+ // Find the "## Performance Metrics" section via the markdown-sectionizer
396
+ // seam (ADR-2143 §7) — supersedes the prior hand-rolled section+table
397
+ // regex.
398
+ const metricsSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => /^performance metrics$/i.test(h.text.trim()));
399
+ const eol = metricsSection && /\r\n/.test(metricsSection.body) ? '\r\n' : '\n';
400
+ const lines = metricsSection ? metricsSection.body.split(/\r?\n/) : [];
401
+ // Locate THIS command's OWN metrics table by its HEADER shape, using the
402
+ // exact same splitTableRow/isDelimiterRow header/delimiter-shape checks
403
+ // `parseMarkdownTable` uses. A live "## Performance Metrics" section
404
+ // (gsd-core/templates/state.md:39-56) also carries the "By Phase"
405
+ // velocity table (`| Phase | Plans | Total | Avg/Plan |`) — the prior
406
+ // "first table in the section" targeting spliced every per-plan row into
407
+ // THAT table instead, polluting it on EVERY plan completion
408
+ // (execute-plan.md:414 calls record-metric per-plan) (#2245/#2143).
409
+ // Matching the header cells to this command's own canonical
410
+ // `Plan | Duration | Tasks | Files` shape (case-insensitive/trimmed)
411
+ // finds the right table regardless of what else shares the section, and
412
+ // deliberately does NOT require `parseMarkdownTable(...).ok` (which
413
+ // additionally requires every DATA row's cell count to match the
414
+ // header) — a single ragged sibling row (a hand-edited stray/extra pipe)
415
+ // must not blind this scan (#2245 Blocker 2 parity with the other
416
+ // Phase-4 ragged-tolerance fixes: updateTableCell / findTableStartOffset).
417
+ const METRICS_HEADER = ['plan', 'duration', 'tasks', 'files'];
418
+ let headerIdx = -1;
419
+ for (let i = 0; i < lines.length - 1; i++) {
420
+ const trimmed = lines[i].trim();
421
+ if (!trimmed.startsWith('|') || trimmed.indexOf('|', 1) === -1)
422
+ continue;
423
+ const delimiterLine = lines[i + 1];
424
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|'))
425
+ continue;
426
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(lines[i]);
427
+ const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(delimiterLine);
428
+ if (!(0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells) || delimiterCells.length !== headerCells.length)
429
+ continue;
430
+ const normalized = headerCells.map((cell) => cell.trim().toLowerCase());
431
+ const isMetricsHeader = normalized.length === METRICS_HEADER.length
432
+ && normalized.every((cell, idx) => cell === METRICS_HEADER[idx]);
433
+ if (isMetricsHeader) {
434
+ headerIdx = i;
435
+ break;
436
+ }
437
+ }
438
+ const hasTable = headerIdx !== -1;
439
+ if (metricsSection && hasTable) {
440
+ const delimiterIdx = headerIdx + 1;
441
+ const prefixLines = lines.slice(0, delimiterIdx + 1);
442
+ // Ragged-tolerant row scan: every consecutive `|`-prefixed line
443
+ // following the delimiter counts as an existing row REGARDLESS of its
444
+ // cell count matching the header — a ragged sibling row must never
445
+ // blind this scan to the table's true last row (unlike
446
+ // `parsedTable.value.rows.length`, which this replaces). Anchored to
447
+ // the METRICS table's OWN header/delimiter (`headerIdx` above), never
448
+ // the section's first table (#2245/#2143).
449
+ let lastRowIdx = delimiterIdx;
450
+ for (let i = delimiterIdx + 1; i < lines.length; i++) {
451
+ if (!lines[i].trim().startsWith('|'))
452
+ break;
453
+ lastRowIdx = i;
454
+ }
455
+ const rowCount = lastRowIdx - delimiterIdx;
456
+ _recorded = true;
457
+ let newBody;
458
+ if (rowCount > 0) {
459
+ // Splice the new row immediately after the table's LAST existing data
460
+ // row — every other byte of the section, INCLUDING any trailing prose
461
+ // that follows the table (e.g. the default template's "**Recent
462
+ // Trend:**" subsection + "*Updated after each plan completion*"
463
+ // footer), is preserved verbatim. The prior implementation truncated
464
+ // the section body to header+delimiter+rows+newRow, silently dropping
465
+ // everything that followed the table on a live STATE.md (#2245
466
+ // Blocker 1 — a per-plan path, run after every plan execution).
467
+ // `lastRowIdx` (computed above by the ragged-tolerant scan) already
468
+ // equals `delimiterIdx + rowCount` by construction.
469
+ const before = lines.slice(0, lastRowIdx + 1);
470
+ const after = lines.slice(lastRowIdx + 1);
471
+ newBody = [...before, newRow, ...after].join(eol);
392
472
  }
393
473
  else {
394
- tableBody = tableBody + '\n' + newRow;
474
+ // No existing data rows (e.g. a "None yet" placeholder line instead of
475
+ // a real row) — replace the placeholder/table-body remainder with the
476
+ // new row, matching the section's prior (verified) collapse-to-
477
+ // first-row behavior for an otherwise-empty table.
478
+ // No trailing eol here: replaceSection's `content.slice(bodyEnd)`
479
+ // already supplies the newline(s) that followed the (trimEnd()-ed)
480
+ // section body.
481
+ newBody = prefixLines.join(eol) + eol + newRow;
395
482
  }
483
+ return (0, markdown_sectionizer_cjs_1.replaceSection)(content, metricsSection, newBody);
484
+ }
485
+ if (metricsSection) {
486
+ // Section EXISTS but carries no metrics table of its own — e.g. a live
487
+ // STATE.md whose "## Performance Metrics" section holds only the
488
+ // By-Phase velocity table (gsd-core/templates/state.md:48). Self-heal
489
+ // by appending a fresh Per-Plan Metrics table to the END of the
490
+ // section body — every existing byte (By-Phase table, Recent Trend,
491
+ // footer) is preserved verbatim, and no second "## Performance
492
+ // Metrics" heading is introduced. The section already existed, so
493
+ // `created` stays false (#2245/#2143).
396
494
  _recorded = true;
397
- return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
495
+ const newBody = metricsSection.body
496
+ + eol + '**Per-Plan Metrics:**'
497
+ + eol + eol
498
+ + '| Plan | Duration | Tasks | Files |'
499
+ + eol
500
+ + '|------|----------|-------|-------|'
501
+ + eol
502
+ + newRow
503
+ + eol;
504
+ return (0, markdown_sectionizer_cjs_1.replaceSection)(content, metricsSection, newBody);
398
505
  }
399
- // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
400
- // then append the row. Matches state begin-phase / advance-plan DWIM behavior.
506
+ // Section absent (or malformed) — DWIM: auto-create canonical
507
+ // ## Performance Metrics scaffold, then append the row. Matches state
508
+ // begin-phase / advance-plan DWIM behavior. Header corrected to this
509
+ // command's own canonical shape (`Plan | Duration | Tasks | Files`) —
510
+ // the prior scaffold's `| Phase | Plan | Duration | Notes |` header
511
+ // matched neither the appended row's shape nor the canonical table
512
+ // above (#2245/#2143).
401
513
  const scaffold = [
402
514
  '',
403
515
  '## Performance Metrics',
404
516
  '',
405
- '| Phase | Plan | Duration | Notes |',
406
- '|-------|------|----------|-------|',
517
+ '| Plan | Duration | Tasks | Files |',
518
+ '|------|----------|-------|-------|',
407
519
  newRow,
408
520
  '',
409
521
  ].join('\n');
@@ -447,18 +559,31 @@ function cmdStateUpdateProgress(cwd, raw) {
447
559
  const _totalPlans = totalPlans;
448
560
  const _totalSummaries = totalSummaries;
449
561
  readModifyWriteStateMd(statePath, (content) => {
450
- // Try **Progress:** bold format first, then plain Progress: format
451
- const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
452
- const plainProgressPattern = /^(Progress:\s*).*/im;
453
- if (boldProgressPattern.test(content)) {
454
- updated = true;
455
- return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
456
- }
457
- else if (plainProgressPattern.test(content)) {
458
- updated = true;
459
- return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
460
- }
461
- return content;
562
+ // #2177: match against the BODY only. With /i the patterns below would
563
+ // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
564
+ // eat its newline, mangling the nested block), while the body Progress: line
565
+ // — which frontmatter `percent` is re-derived from on every write — stays
566
+ // stale and silently reverts the update.
567
+ const body = stripFrontmatter(content);
568
+ const fmPrefix = content.slice(0, content.length - body.length);
569
+ // Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
570
+ // descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
571
+ const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
572
+ const replaceValue = (value) => machineSegment.test(value)
573
+ ? value.replace(machineSegment, progressStr)
574
+ : progressStr;
575
+ // Try **Progress:** bold format first, then plain Progress: format.
576
+ const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
577
+ const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
578
+ const pattern = boldProgressPattern.test(body)
579
+ ? boldProgressPattern
580
+ : plainProgressPattern.test(body)
581
+ ? plainProgressPattern
582
+ : null;
583
+ if (!pattern)
584
+ return content;
585
+ updated = true;
586
+ return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
462
587
  }, cwd);
463
588
  if (updated) {
464
589
  output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
@@ -884,6 +1009,15 @@ function cmdStateRecordSession(cwd, options, raw) {
884
1009
  // duplicate section alongside an existing one.
885
1010
  const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
886
1011
  const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
1012
+ // Track whether the chosen branch's rewrite actually matched. The detector
1013
+ // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
1014
+ // tolerant ($ under /m treats \r as a line terminator); the writer regexes
1015
+ // below must be too. If a writer regex silently fails to match (line-ending
1016
+ // mismatch, unexpected heading shape, ...), do NOT report success — the
1017
+ // caller would believe fields were persisted that were silently dropped
1018
+ // (#2450). The append branch always sets rewriteMatched=true (it always
1019
+ // mutates content).
1020
+ let rewriteMatched = false;
887
1021
  if (existingCanonicalSession) {
888
1022
  // Normalize in place: replace the ENTIRE BODY of the existing ## Session
889
1023
  // section (heading + all content up to the next ## heading or EOF) with
@@ -891,7 +1025,13 @@ function cmdStateRecordSession(cwd, options, raw) {
891
1025
  // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
892
1026
  // which correctly stops at the next section boundary without consuming it.
893
1027
  // A trailing blank line is added so the next ## heading keeps its spacing.
894
- content = content.replace(/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m, [
1028
+ //
1029
+ // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
1030
+ // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
1031
+ // updated.push(...) reported success — #2450. The detector regex on the
1032
+ // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
1033
+ // asymmetry armed the bug.
1034
+ const canonicalReplacement = [
895
1035
  '## Session',
896
1036
  '',
897
1037
  `**Last session:** ${now}`,
@@ -899,7 +1039,11 @@ function cmdStateRecordSession(cwd, options, raw) {
899
1039
  `**Resume file:** ${resumeValue}`,
900
1040
  '',
901
1041
  '',
902
- ].join('\n'));
1042
+ ].join('\n');
1043
+ content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
1044
+ rewriteMatched = true;
1045
+ return canonicalReplacement;
1046
+ });
903
1047
  }
904
1048
  else if (existingSessionContinuity) {
905
1049
  // #1101: a `## Session Continuity` section already exists (bootstrap
@@ -910,6 +1054,8 @@ function cmdStateRecordSession(cwd, options, raw) {
910
1054
  // (e.g. prose like "Next recommended action"). Fields already updated in
911
1055
  // place above (needs* false) are not re-inserted. A function replacement
912
1056
  // is used so `$`-bearing caller values are inserted literally (#3454).
1057
+ //
1058
+ // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
913
1059
  const linesToInsert = [];
914
1060
  if (needsLastSession)
915
1061
  linesToInsert.push(`**Last session:** ${now}`);
@@ -921,8 +1067,18 @@ function cmdStateRecordSession(cwd, options, raw) {
921
1067
  // Case-insensitive to match the `existingSessionContinuity` detection
922
1068
  // above (#1101 review F3) — otherwise a lowercase heading would detect
923
1069
  // but no-op the insert while still reporting the fields as updated.
924
- content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
1070
+ content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
1071
+ rewriteMatched = true;
1072
+ return heading + linesToInsert.join('\n') + '\n';
1073
+ });
925
1074
  }
1075
+ // No `else` branch: if linesToInsert.length === 0 the outer guard at
1076
+ // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
1077
+ // || needsLastSession)) could not have fired, so this whole block is
1078
+ // unreachable. Leaving `rewriteMatched = false` here is the fail-loud
1079
+ // posture — a future change to the outer guard or needs* computation
1080
+ // that makes this branch reachable will surface as a missing
1081
+ // updated[] entry (silent recorded:false) rather than re-arming #2450.
926
1082
  }
927
1083
  else {
928
1084
  // No session heading exists at all — append a new canonical section.
@@ -936,14 +1092,30 @@ function cmdStateRecordSession(cwd, options, raw) {
936
1092
  '',
937
1093
  ].join('\n');
938
1094
  content = content.trimEnd() + '\n' + scaffold;
1095
+ rewriteMatched = true;
1096
+ }
1097
+ // #2450 defensive invariant: only report sessionCreated/updated when the
1098
+ // chosen branch's rewrite actually mutated content. Unreachable when the
1099
+ // writer regexes above stay in sync with the CRLF-tolerant detector —
1100
+ // but unreachable-defensive is the right posture for a silent-success
1101
+ // gate. A no-op replace here means a future line-ending or shape drift
1102
+ // between detector and writer; fail to record rather than claim success.
1103
+ //
1104
+ // Scope limitation (not a regression of this fix): the gate covers only
1105
+ // the section-rewrite block. The earlier in-place stateReplaceField
1106
+ // successes at :1081/:1083/:1089/:1101/:1108/:1114 push to `updated`
1107
+ // unconditionally — those represent fields that DID land on disk via
1108
+ // same-line replace (CRLF-agnostic seam), so unconditional push is
1109
+ // correct. The class-defect防御 here is for the INSERT path only.
1110
+ if (rewriteMatched) {
1111
+ sessionCreated = true;
1112
+ if (needsLastSession)
1113
+ updated.push('Last session');
1114
+ if (needsStoppedAt)
1115
+ updated.push('Stopped At');
1116
+ if (needsResumeFile)
1117
+ updated.push('Resume File');
939
1118
  }
940
- sessionCreated = true;
941
- if (needsLastSession)
942
- updated.push('Last session');
943
- if (needsStoppedAt)
944
- updated.push('Stopped At');
945
- if (needsResumeFile)
946
- updated.push('Resume File');
947
1119
  }
948
1120
  return content;
949
1121
  }, cwd);
@@ -961,15 +1133,22 @@ function cmdStateRecordSession(cwd, options, raw) {
961
1133
  * Match the session section body from a STATE.md body. #1101: recognise the
962
1134
  * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
963
1135
  * block when both exist (legacy duplicate files), so the reader agrees with the
964
- * writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of
965
- * `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which
966
- * excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still
967
- * excludes `## Session Continuity Archive` (preserving the #2444 scoping).
968
- * Returns the match whose group 1 is the section body, or null.
1136
+ * writer (which updates `## Session` first). Level-2-exact heading match
1137
+ * (excludes an h3 `### Session Continuity`); the exact `'session continuity'`
1138
+ * text match still excludes `## Session Continuity Archive` (preserving the
1139
+ * #2444 scoping). Migrated onto the `collectSection` seam (#2143 audit,
1140
+ * epic #2143): CRLF-safe — the prior hand-rolled `[ \t]*\n` regex silently
1141
+ * failed to match a CRLF `## Session\r\n` heading line (the `\r` broke the
1142
+ * `[ \t]*\n` boundary); `tokenizeHeadings` strips the trailing `\r` before
1143
+ * heading-text extraction, so this now matches CRLF headings too.
1144
+ * Returns the section body, or null.
969
1145
  */
970
1146
  function matchSessionSection(body) {
971
- return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i) // allow-adhoc-markdown: read-only session-section extract in state.cts; pending collectSection migration #1372
972
- || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only session-continuity section extract in state.cts; pending collectSection migration #1372
1147
+ const isSession = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session';
1148
+ const isSessionContinuity = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
1149
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isSession, { levelBounded: true })
1150
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
1151
+ return section ? section.body : null;
973
1152
  }
974
1153
  function parseProsePhaseField(value) {
975
1154
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
@@ -1036,14 +1215,15 @@ function cmdStateSnapshot(cwd, raw) {
1036
1215
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1037
1216
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1038
1217
  const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1039
- // Extract decisions table
1218
+ // Extract decisions table — via the markdown-sectionizer/markdown-table
1219
+ // seams (ADR-2143 §7), cells addressed by column NAME rather than a
1220
+ // hand-rolled section+table regex.
1040
1221
  const decisions = [];
1041
- const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); // allow-adhoc-markdown: read-only decisions-table section-collect in state.cts; pending collectSection migration #1372
1042
- if (decisionsMatch) {
1043
- const tableBody = decisionsMatch[1];
1044
- const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
1045
- for (const row of rows) {
1046
- const cells = row.split('|').map(c => c.trim()).filter(Boolean);
1222
+ const decisionsSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => /^decisions made$/i.test(h.text.trim()));
1223
+ const decisionsTable = decisionsSection ? (0, markdown_table_cjs_1.parseMarkdownTable)(decisionsSection.body) : null;
1224
+ if (decisionsTable && decisionsTable.ok) {
1225
+ for (const row of decisionsTable.value.rows) {
1226
+ const cells = decisionsTable.value.columns.map((c) => (row[c] ?? '').trim()).filter(Boolean);
1047
1227
  if (cells.length >= 3) {
1048
1228
  decisions.push({
1049
1229
  phase: cells[0],
@@ -1055,10 +1235,9 @@ function cmdStateSnapshot(cwd, raw) {
1055
1235
  }
1056
1236
  // Extract blockers list
1057
1237
  const blockers = [];
1058
- const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only blockers section-collect in state.cts; pending collectSection migration #1372
1059
- if (blockersMatch) {
1060
- const blockersSection = blockersMatch[1];
1061
- const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
1238
+ const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
1239
+ if (blockersSection) {
1240
+ const items = blockersSection.body.match(/^-\s+(.+)$/gm) || [];
1062
1241
  for (const item of items) {
1063
1242
  blockers.push(item.replace(/^-\s+/, '').trim());
1064
1243
  }
@@ -1072,8 +1251,8 @@ function cmdStateSnapshot(cwd, raw) {
1072
1251
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1073
1252
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1074
1253
  const sessionMatch = matchSessionSection(body);
1075
- if (sessionMatch) {
1076
- const sessionSection = sessionMatch[1];
1254
+ if (sessionMatch !== null) {
1255
+ const sessionSection = sessionMatch;
1077
1256
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
1078
1257
  // (the form written by the DWIM auto-create / normalize path added for #944).
1079
1258
  const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
@@ -1191,18 +1370,19 @@ function buildStateFrontmatter(bodyContent, cwd) {
1191
1370
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1192
1371
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1193
1372
  const sessionSectionMatch = matchSessionSection(bodyContent);
1194
- const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
1373
+ const sessionBodyScope = sessionSectionMatch ?? bodyContent;
1195
1374
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
1196
1375
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
1197
1376
  let milestone = null;
1198
1377
  let milestoneName = null;
1199
1378
  if (cwd) {
1200
- try {
1201
- const info = getMilestoneInfo(cwd);
1202
- milestone = info.version;
1203
- milestoneName = info.name;
1204
- }
1205
- catch { /* intentionally empty */ }
1379
+ // DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
1380
+ // try/catch (roadmap-parser.cts) that already swallows every internal
1381
+ // failure and always returns a MilestoneInfo — it never throws, so this
1382
+ // wrapper could never be triggered.
1383
+ const info = getMilestoneInfo(cwd);
1384
+ milestone = info.version;
1385
+ milestoneName = info.name;
1206
1386
  }
1207
1387
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1208
1388
  let completedPhases = null;
@@ -1336,6 +1516,13 @@ function buildStateFrontmatter(bodyContent, cwd) {
1336
1516
  completedPlans = cached.completedPlans;
1337
1517
  milestoneUnbounded = cached.milestoneBounded === false;
1338
1518
  }
1519
+ /* best-effort (#2245 audit): this is a READ path building STATE.md's
1520
+ * display frontmatter. The real throw source is fs.readdirSync(phasesDir)
1521
+ * a few lines up — an inaccessible/racily-removed phases dir must not
1522
+ * crash `state show`; on failure this simply keeps whatever
1523
+ * frontmatter-derived totals/completedPhases/etc. were already set
1524
+ * above, a graceful degrade rather than a corrupted write (nothing is
1525
+ * persisted from this block). */
1339
1526
  }
1340
1527
  catch { /* intentionally empty */ }
1341
1528
  }
@@ -1391,19 +1578,6 @@ function buildStateFrontmatter(bodyContent, cwd) {
1391
1578
  fm['progress'] = progress;
1392
1579
  return fm;
1393
1580
  }
1394
- function stripFrontmatter(content) {
1395
- // Strip ALL frontmatter blocks at the start of the file.
1396
- // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
1397
- // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
1398
- let result = content;
1399
- while (true) {
1400
- const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
1401
- if (stripped === result)
1402
- break;
1403
- result = stripped;
1404
- }
1405
- return result;
1406
- }
1407
1581
  function syncStateFrontmatter(content, cwd) {
1408
1582
  // Read existing frontmatter BEFORE stripping — it may contain values
1409
1583
  // that the body no longer has (e.g., Status field removed by an agent).
@@ -1425,7 +1599,17 @@ function syncStateFrontmatter(content, cwd) {
1425
1599
  // existing frontmatter already holds; only an empty derived value falls through
1426
1600
  // to this guard (the primary #905 preserve path below handles that).
1427
1601
  const MILESTONE_NAME_PLACEHOLDER = 'milestone';
1428
- if (derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
1602
+ // #2135: widen the preserve guard. A bad derive is not always the literal
1603
+ // placeholder — getMilestoneInfo can return a delimiter-led fragment
1604
+ // ("— Active Milestone") when the roadmap regex mis-binds. Preserve the
1605
+ // existing curated name unless the derived value actually looks like a name:
1606
+ // non-empty, not the placeholder, and not punctuation-led.
1607
+ const derivedName = derivedFm['milestone_name'];
1608
+ const derivedLooksLikeName = typeof derivedName === 'string'
1609
+ && derivedName.length > 0
1610
+ && derivedName !== MILESTONE_NAME_PLACEHOLDER
1611
+ && !/^[\s—–:-]/.test(derivedName);
1612
+ if (!derivedLooksLikeName &&
1429
1613
  existingFm['milestone_name'] &&
1430
1614
  existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER) {
1431
1615
  derivedFm['milestone_name'] = existingFm['milestone_name'];
@@ -1472,6 +1656,15 @@ function syncStateFrontmatter(content, cwd) {
1472
1656
  if (!derivedFm['progress'] && existingFm['progress']) {
1473
1657
  derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
1474
1658
  }
1659
+ // #2202: carry forward any existing frontmatter key that the schema does not
1660
+ // own, so custom/unknown keys are not silently dropped on every mutating verb.
1661
+ // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
1662
+ // preserve guards above) still win.
1663
+ for (const key of Object.keys(existingFm)) {
1664
+ if (!(key in derivedFm) && existingFm[key] !== undefined) {
1665
+ derivedFm[key] = existingFm[key];
1666
+ }
1667
+ }
1475
1668
  const yamlStr = reconstructFrontmatter(derivedFm);
1476
1669
  return `---\n${yamlStr}\n---\n\n${body}`;
1477
1670
  }
@@ -1786,7 +1979,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1786
1979
  // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1787
1980
  // Archive prose) must not interfere with the delta comparison.
1788
1981
  const preSessionMatch = matchSessionSection(preBody);
1789
- const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1982
+ const preSessionScope = preSessionMatch ?? preBody;
1790
1983
  const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1791
1984
  // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
1792
1985
  // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
@@ -1815,7 +2008,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1815
2008
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1816
2009
  // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1817
2010
  const postSessionMatch = matchSessionSection(postBody);
1818
- const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
2011
+ const postSessionScope = postSessionMatch ?? postBody;
1819
2012
  const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1820
2013
  // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
1821
2014
  // current_phase_name delta comparison.
@@ -1830,6 +2023,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1830
2023
  const postFm = extractFrontmatter(synced);
1831
2024
  const preservation = applyStatePreservation({
1832
2025
  preFm, postFm, preFmSnapshot, resync,
2026
+ deriveProgressKeys: options?.deriveProgressKeys === true,
1833
2027
  preBodyStatus, postBodyStatus,
1834
2028
  preBodyStoppedAt, postBodyStoppedAt,
1835
2029
  preBodyPhaseSource, postBodyPhaseSource,
@@ -1971,6 +2165,37 @@ function cmdSignalResume(cwd, raw) {
1971
2165
  output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1972
2166
  }
1973
2167
  // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
2168
+ /**
2169
+ * Find the character offset where the FIRST GFM table whose header is a
2170
+ * superset of `required` column names begins (order-independent; extra
2171
+ * columns tolerated) — the position-aware counterpart to markdown-table's
2172
+ * `findTableWithColumns`, used to scope `updateTableCell` (which always
2173
+ * operates on "the first table in its input") to the RIGHT table when an
2174
+ * unrelated earlier table (that doesn't itself name every required column)
2175
+ * may precede it in the same document. Returns `null` when no such table is
2176
+ * found. Never trips the table-regex fingerprint (no `[^|]` cell-capture
2177
+ * class) and never throws.
2178
+ *
2179
+ * Ragged-tolerant (#2245 Blocker 2): accepts the offset the moment a HEADER
2180
+ * line names every required column — it deliberately does NOT additionally
2181
+ * require `parseMarkdownTable(text.slice(m.index)).ok`, which validates every
2182
+ * DATA row's cell count. A ragged sibling row anywhere in the table used to
2183
+ * make that whole-table parse fail, so the offset came back `null` and the
2184
+ * caller's `updateTableCell` calls (which scope to this offset) never even
2185
+ * ran against an otherwise-perfectly-findable row.
2186
+ */
2187
+ function findTableStartOffset(text, required) {
2188
+ const lineRe = /^[ \t]*\|.*\|[ \t]*$/gm;
2189
+ let m;
2190
+ while ((m = lineRe.exec(text)) !== null) {
2191
+ const trimmed = m[0].trim();
2192
+ const cols = trimmed.replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
2193
+ if (required.every((rq) => cols.includes(rq))) {
2194
+ return m.index;
2195
+ }
2196
+ }
2197
+ return null;
2198
+ }
1974
2199
  /**
1975
2200
  * Update the ## Performance Metrics section in STATE.md content.
1976
2201
  * Increments Velocity totals and upserts a By Phase table row.
@@ -1982,45 +2207,128 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
1982
2207
  // the same phase again upserts the same row, so the column sum is stable. The previous
1983
2208
  // blind-add (prevTotal + summaryCount) re-read the cumulative total each call and
1984
2209
  // double-counted on every re-run. (#1582)
1985
- const byPhaseMatch = content.match(byPhaseTablePattern);
1986
- if (byPhaseMatch) {
1987
- let tableBody = byPhaseMatch[2].trim();
2210
+ //
2211
+ // Located by column NAME via the markdown-table seam (ADR-2143 §7) —
2212
+ // supersedes the prior module-level byPhaseTablePattern regex for the
2213
+ // existence/lookup half of this logic.
2214
+ const byPhaseCols = ['Phase', 'Plans', 'Total', 'Avg/Plan'];
2215
+ // Ragged-tolerant (#2245 Blocker 2): scope to the table's start offset
2216
+ // (findTableStartOffset — itself now ragged-tolerant, see above) rather
2217
+ // than gating existence/lookup on findTableWithColumns, which requires the
2218
+ // WHOLE table to parse — a ragged row for a DIFFERENT phase used to
2219
+ // silently no-op every phase's upsert.
2220
+ const tableStart = findTableStartOffset(content, byPhaseCols);
2221
+ if (tableStart !== null) {
1988
2222
  // Match the existing row for this phase, tolerating leading-zero padding in either
1989
2223
  // direction (#1659): canonicalize a numeric phase to its integer form so a seeded
1990
2224
  // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
1991
2225
  const phaseNumStr = String(phaseNum);
1992
2226
  const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
1993
- const phaseRowPattern = new RegExp(`^\\|\\s*${canonCell}\\s*\\|.*$`, 'm');
1994
- const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1995
- if (phaseRowPattern.test(tableBody)) {
1996
- // Update existing row
1997
- tableBody = tableBody.replace(phaseRowPattern, newRow);
2227
+ const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
2228
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
2229
+ const before = content.slice(0, tableStart);
2230
+ let tableText = content.slice(tableStart);
2231
+ // Ragged-tolerant existence probe: a no-op updateTableCell write on the
2232
+ // identifying "Phase" column (its own tolerant row scan) decides whether
2233
+ // this phase's row already exists, without requiring every OTHER row in
2234
+ // the table to also parse cleanly.
2235
+ let rowExists = false;
2236
+ const existsProbe = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Phase', (current) => {
2237
+ rowExists = true;
2238
+ return current;
2239
+ });
2240
+ void existsProbe;
2241
+ if (rowExists) {
2242
+ // Update existing row — one updateTableCell call per column (Phase
2243
+ // itself may also change shape, e.g. "05" -> "5" per #1659).
2244
+ const phaseResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Phase', ` ${phaseNum} `);
2245
+ if (phaseResult.ok)
2246
+ tableText = phaseResult.value;
2247
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Plans', ` ${summaryCount} `);
2248
+ if (plansResult.ok)
2249
+ tableText = plansResult.value;
2250
+ const totalResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Total', ' - ');
2251
+ if (totalResult.ok)
2252
+ tableText = totalResult.value;
2253
+ const avgResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Avg/Plan', ' - ');
2254
+ if (avgResult.ok)
2255
+ tableText = avgResult.value;
2256
+ content = before + tableText;
1998
2257
  }
1999
2258
  else {
2000
- // Remove placeholder row and add new row
2001
- tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
2002
- tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
2259
+ // Row doesn't exist — INSERT a new row. Row insertion (unlike a cell
2260
+ // update) is outside updateTableCell's scope (ADR-2143 §7 Phase 4);
2261
+ // `insertTableRow` (markdown-table.cjs) is its name-addressed,
2262
+ // header-order-agnostic sibling (#2245 audit: this used to locate the
2263
+ // table via `byPhaseTablePattern`, a canonical-column-ORDER-only regex,
2264
+ // and build the row as a hardcoded positional literal — so a reordered/
2265
+ // superset By-Phase header, already tolerated above by
2266
+ // findTableStartOffset and read by-NAME in the update/sum halves,
2267
+ // silently inserted NOTHING).
2268
+ //
2269
+ // Drop a lone all-placeholder row first (e.g. the freshly-scaffolded
2270
+ // "| - | - | - | - |" seed row) — same convention the prior
2271
+ // canonical-order path used, generalized to any column order/count:
2272
+ // a row whose every PRESENT cell is "-" is the placeholder.
2273
+ const placeholderRow = (row) => Object.values(row).every((cell) => cell.trim() === '-');
2274
+ const withoutPlaceholder = (0, markdown_table_cjs_1.deleteTableRow)(tableText, placeholderRow);
2275
+ if (withoutPlaceholder.ok)
2276
+ tableText = withoutPlaceholder.value;
2277
+ // Map the By-Phase values onto the table's ACTUAL header columns by
2278
+ // NAME — an unrecognized column (a superset header) falls back to "-",
2279
+ // insertTableRow's default.
2280
+ const valueFor = (col) => {
2281
+ if (col === 'Phase')
2282
+ return String(phaseNum);
2283
+ if (col === 'Plans')
2284
+ return String(summaryCount);
2285
+ if (col === 'Total' || col === 'Avg/Plan')
2286
+ return '-';
2287
+ return undefined;
2288
+ };
2289
+ const insertResult = (0, markdown_table_cjs_1.insertTableRow)(tableText, valueFor);
2290
+ if (insertResult.ok)
2291
+ tableText = insertResult.value;
2292
+ content = before + tableText;
2003
2293
  }
2004
- content = content.replace(byPhaseTablePattern, (_match, tableHeader) => `${tableHeader}${tableBody}\n`);
2005
2294
  }
2006
2295
  // Velocity: Total plans completed — DERIVED as the sum of the By-Phase Plans column
2007
- // (the second cell) across all data rows. Idempotent by construction (re-running phase
2008
- // complete upserts the same row → same sum) and self-healing (a hand-edited inflated
2009
- // total is corrected to the true sum on the next completion). When the By-Phase table
2010
- // is absent, leave the velocity total unchanged rather than guess. (#1582)
2296
+ // across all data rows. Idempotent by construction (re-running phase complete upserts
2297
+ // the same row → same sum) and self-healing (a hand-edited inflated total is corrected
2298
+ // to the true sum on the next completion). When the By-Phase table is absent, leave the
2299
+ // velocity total unchanged rather than guess. (#1582)
2300
+ //
2301
+ // Ragged-tolerant AND name-addressed (#2245 audit): each data row is split via
2302
+ // `splitTableRow` and its "Plans" cell located by the HEADER's own column
2303
+ // order (not a fixed ordinal), so a reordered/superset By-Phase header is
2304
+ // summed correctly instead of silently reading the wrong cell. A row that's
2305
+ // too short to physically contain the "Plans" column is skipped, not
2306
+ // treated as an error — mirrors updateTableCell's ragged-row tolerance
2307
+ // (a hand-edited/ragged row for one phase must not blank out the derived
2308
+ // total for every phase). Still scoped via findTableStartOffset so the RIGHT
2309
+ // table is summed when an earlier unrelated table also has a "Phase"
2310
+ // column (#2012).
2011
2311
  if (/Total plans completed:\s*(\d+|\[N\])/.test(content)) {
2012
- const tableForSum = content.match(byPhaseTablePattern);
2013
- if (tableForSum) {
2312
+ const sumTableStart = findTableStartOffset(content, byPhaseCols);
2313
+ if (sumTableStart !== null) {
2314
+ const tableLines = content.slice(sumTableStart).split(/\r?\n/);
2315
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(tableLines[0] ?? '');
2316
+ const plansIdx = headerCells.indexOf('Plans');
2014
2317
  let sum = 0;
2015
- for (const row of tableForSum[2].split(/\r?\n/)) {
2016
- // Data rows look like `| <phase> | <plans> | … |`, optionally indented (the
2017
- // byPhaseTablePattern data-row capture allows `[ \t]*` leading whitespace, so the
2018
- // sum must too or hand-edited/legacy indented rows are silently skipped — #1582
2019
- // codex review). Header (`| Phase | Plans | …`) and separator (`| --- | --- | …`)
2020
- // rows have a non-numeric second cell and are skipped; non-numeric cells → 0.
2021
- const cellMatch = row.match(/^\s*\|\s*[^|]+\s*\|\s*(\d+)\s*\|/);
2022
- if (cellMatch)
2023
- sum += parseInt(cellMatch[1], 10);
2318
+ if (plansIdx !== -1) {
2319
+ // The delimiter row is skipped by NAME (isDelimiterRow), not by a
2320
+ // hardcoded "always line index 1" assumption, so this stays
2321
+ // self-consistent with the ragged-tolerant read below.
2322
+ const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(tableLines[1] ?? '');
2323
+ const dataStart = (0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells) ? 2 : 1;
2324
+ for (const row of tableLines.slice(dataStart)) {
2325
+ if (!row.trim().startsWith('|'))
2326
+ break;
2327
+ const cells = (0, markdown_table_cjs_1.splitTableRow)(row);
2328
+ if (plansIdx < cells.length && /^\d+$/.test(cells[plansIdx])) {
2329
+ sum += parseInt(cells[plansIdx], 10);
2330
+ }
2331
+ }
2024
2332
  }
2025
2333
  content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${sum}`);
2026
2334
  }
@@ -2059,7 +2367,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2059
2367
  const result = transitionCore(content, intent, deps);
2060
2368
  updated = result.updated;
2061
2369
  return result.content;
2062
- }, cwd, { resync: false });
2370
+ }, cwd, { resync: false, deriveProgressKeys: true });
2063
2371
  output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2064
2372
  }
2065
2373
  /**
@@ -2137,7 +2445,11 @@ function cmdStateValidate(cwd, raw) {
2137
2445
  drift['verification_status'] = { state_status: status, verification: 'passed' };
2138
2446
  }
2139
2447
  }
2140
- catch { /* intentionally empty */ }
2448
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
2449
+ * warnings scan across N VERIFICATION.md files — one unreadable file
2450
+ * (permission/race) must not abort the scan of the rest; it's simply
2451
+ * excluded from drift detection. */
2452
+ }
2141
2453
  }
2142
2454
  // Check if all plans have summaries but status still says executing
2143
2455
  if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
@@ -2148,7 +2460,14 @@ function cmdStateValidate(cwd, raw) {
2148
2460
  }
2149
2461
  }
2150
2462
  }
2151
- catch { /* intentionally empty */ }
2463
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a read-only
2464
+ * diagnostic scan of the current phase's directory (readdirSync +
2465
+ * scanPhasePlans). A disk-scan failure here means drift detection for
2466
+ * this phase is skipped for this run, degrading to "no warnings from
2467
+ * that scan" rather than crashing the validate command — the same
2468
+ * degrade-on-scan-failure pattern buildStateFrontmatter's own disk scan
2469
+ * already uses. */
2470
+ }
2152
2471
  }
2153
2472
  const valid = warnings.length === 0;
2154
2473
  output({ valid, warnings, drift }, raw, undefined);
@@ -2237,33 +2556,33 @@ function cmdStateSync(cwd, options, raw) {
2237
2556
  }
2238
2557
  // Determine total phases from ROADMAP (may be larger than realized disk dirs).
2239
2558
  // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
2559
+ // DEAD catch removed (#2245 audit): every operation in this block is a regex
2560
+ // exec/test over an already-read string plus pure Set/Math ops — none of
2561
+ // which can throw — so the try/catch could never be triggered.
2240
2562
  let syncTotalPhases = null;
2241
- try {
2242
- let roadmapPhaseCount = 0;
2243
- if (syncRoadmapScope !== null) {
2244
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2245
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
2246
- let m;
2247
- while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2248
- // Only count tokens that contain at least one digit — excludes
2249
- // pure-word section headings (Overview, Details) while keeping
2250
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2251
- if (!/\d/.test(m[1]))
2252
- continue;
2253
- // #1514: retired/folded phases are struck through; exclude from total.
2254
- if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
2255
- continue;
2256
- roadmapPhaseCount++;
2257
- }
2258
- }
2259
- if (roadmapPhaseCount > 0) {
2260
- syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
2261
- }
2262
- else {
2263
- syncTotalPhases = entries.length;
2563
+ let roadmapPhaseCount = 0;
2564
+ if (syncRoadmapScope !== null) {
2565
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2566
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
2567
+ let m;
2568
+ while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2569
+ // Only count tokens that contain at least one digit — excludes
2570
+ // pure-word section headings (Overview, Details) while keeping
2571
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2572
+ if (!/\d/.test(m[1]))
2573
+ continue;
2574
+ // #1514: retired/folded phases are struck through; exclude from total.
2575
+ if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
2576
+ continue;
2577
+ roadmapPhaseCount++;
2264
2578
  }
2265
2579
  }
2266
- catch { /* intentionally empty */ }
2580
+ if (roadmapPhaseCount > 0) {
2581
+ syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
2582
+ }
2583
+ else {
2584
+ syncTotalPhases = entries.length;
2585
+ }
2267
2586
  // ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last
2268
2587
  // Activity) are the pure `syncCore` in src/state-transition.cts.
2269
2588
  // #1761: when a milestone version is set in frontmatter but the ROADMAP has no
@@ -2383,7 +2702,7 @@ function cmdStatePrune(cwd, options, raw) {
2383
2702
  }, cwd);
2384
2703
  // Write archived entries to STATE-ARCHIVE.md
2385
2704
  if (archived.length > 0) {
2386
- const timestamp = clock_cjs_1.realClock.today();
2705
+ const timestamp = clock_cjs_1.realClock.localToday();
2387
2706
  let archiveContent = (0, shell_command_projection_cjs_1.platformReadSync)(archivePath);
2388
2707
  if (archiveContent === null) {
2389
2708
  archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
@@ -2562,7 +2881,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2562
2881
  output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
2563
2882
  return;
2564
2883
  }
2565
- const today = clock_cjs_1.realClock.today();
2884
+ const today = clock_cjs_1.realClock.localToday();
2566
2885
  const updated = [];
2567
2886
  readModifyWriteStateMd(statePath, (content) => {
2568
2887
  const currentPhase = resolvedPhase;