@opengsd/gsd-core 1.7.0-rc.5 → 1.7.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -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
@@ -381,29 +385,131 @@ function cmdStateRecordMetric(cwd, options, raw) {
381
385
  let _recorded = false;
382
386
  let created = false;
383
387
  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
388
  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;
389
+ // Find the "## Performance Metrics" section via the markdown-sectionizer
390
+ // seam (ADR-2143 §7) — supersedes the prior hand-rolled section+table
391
+ // regex.
392
+ const metricsSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => /^performance metrics$/i.test(h.text.trim()));
393
+ const eol = metricsSection && /\r\n/.test(metricsSection.body) ? '\r\n' : '\n';
394
+ const lines = metricsSection ? metricsSection.body.split(/\r?\n/) : [];
395
+ // Locate THIS command's OWN metrics table by its HEADER shape, using the
396
+ // exact same splitTableRow/isDelimiterRow header/delimiter-shape checks
397
+ // `parseMarkdownTable` uses. A live "## Performance Metrics" section
398
+ // (gsd-core/templates/state.md:39-56) also carries the "By Phase"
399
+ // velocity table (`| Phase | Plans | Total | Avg/Plan |`) — the prior
400
+ // "first table in the section" targeting spliced every per-plan row into
401
+ // THAT table instead, polluting it on EVERY plan completion
402
+ // (execute-plan.md:414 calls record-metric per-plan) (#2245/#2143).
403
+ // Matching the header cells to this command's own canonical
404
+ // `Plan | Duration | Tasks | Files` shape (case-insensitive/trimmed)
405
+ // finds the right table regardless of what else shares the section, and
406
+ // deliberately does NOT require `parseMarkdownTable(...).ok` (which
407
+ // additionally requires every DATA row's cell count to match the
408
+ // header) — a single ragged sibling row (a hand-edited stray/extra pipe)
409
+ // must not blind this scan (#2245 Blocker 2 parity with the other
410
+ // Phase-4 ragged-tolerance fixes: updateTableCell / findTableStartOffset).
411
+ const METRICS_HEADER = ['plan', 'duration', 'tasks', 'files'];
412
+ let headerIdx = -1;
413
+ for (let i = 0; i < lines.length - 1; i++) {
414
+ const trimmed = lines[i].trim();
415
+ if (!trimmed.startsWith('|') || trimmed.indexOf('|', 1) === -1)
416
+ continue;
417
+ const delimiterLine = lines[i + 1];
418
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|'))
419
+ continue;
420
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(lines[i]);
421
+ const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(delimiterLine);
422
+ if (!(0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells) || delimiterCells.length !== headerCells.length)
423
+ continue;
424
+ const normalized = headerCells.map((cell) => cell.trim().toLowerCase());
425
+ const isMetricsHeader = normalized.length === METRICS_HEADER.length
426
+ && normalized.every((cell, idx) => cell === METRICS_HEADER[idx]);
427
+ if (isMetricsHeader) {
428
+ headerIdx = i;
429
+ break;
430
+ }
431
+ }
432
+ const hasTable = headerIdx !== -1;
433
+ if (metricsSection && hasTable) {
434
+ const delimiterIdx = headerIdx + 1;
435
+ const prefixLines = lines.slice(0, delimiterIdx + 1);
436
+ // Ragged-tolerant row scan: every consecutive `|`-prefixed line
437
+ // following the delimiter counts as an existing row REGARDLESS of its
438
+ // cell count matching the header — a ragged sibling row must never
439
+ // blind this scan to the table's true last row (unlike
440
+ // `parsedTable.value.rows.length`, which this replaces). Anchored to
441
+ // the METRICS table's OWN header/delimiter (`headerIdx` above), never
442
+ // the section's first table (#2245/#2143).
443
+ let lastRowIdx = delimiterIdx;
444
+ for (let i = delimiterIdx + 1; i < lines.length; i++) {
445
+ if (!lines[i].trim().startsWith('|'))
446
+ break;
447
+ lastRowIdx = i;
448
+ }
449
+ const rowCount = lastRowIdx - delimiterIdx;
450
+ _recorded = true;
451
+ let newBody;
452
+ if (rowCount > 0) {
453
+ // Splice the new row immediately after the table's LAST existing data
454
+ // row — every other byte of the section, INCLUDING any trailing prose
455
+ // that follows the table (e.g. the default template's "**Recent
456
+ // Trend:**" subsection + "*Updated after each plan completion*"
457
+ // footer), is preserved verbatim. The prior implementation truncated
458
+ // the section body to header+delimiter+rows+newRow, silently dropping
459
+ // everything that followed the table on a live STATE.md (#2245
460
+ // Blocker 1 — a per-plan path, run after every plan execution).
461
+ // `lastRowIdx` (computed above by the ragged-tolerant scan) already
462
+ // equals `delimiterIdx + rowCount` by construction.
463
+ const before = lines.slice(0, lastRowIdx + 1);
464
+ const after = lines.slice(lastRowIdx + 1);
465
+ newBody = [...before, newRow, ...after].join(eol);
392
466
  }
393
467
  else {
394
- tableBody = tableBody + '\n' + newRow;
468
+ // No existing data rows (e.g. a "None yet" placeholder line instead of
469
+ // a real row) — replace the placeholder/table-body remainder with the
470
+ // new row, matching the section's prior (verified) collapse-to-
471
+ // first-row behavior for an otherwise-empty table.
472
+ // No trailing eol here: replaceSection's `content.slice(bodyEnd)`
473
+ // already supplies the newline(s) that followed the (trimEnd()-ed)
474
+ // section body.
475
+ newBody = prefixLines.join(eol) + eol + newRow;
395
476
  }
477
+ return (0, markdown_sectionizer_cjs_1.replaceSection)(content, metricsSection, newBody);
478
+ }
479
+ if (metricsSection) {
480
+ // Section EXISTS but carries no metrics table of its own — e.g. a live
481
+ // STATE.md whose "## Performance Metrics" section holds only the
482
+ // By-Phase velocity table (gsd-core/templates/state.md:48). Self-heal
483
+ // by appending a fresh Per-Plan Metrics table to the END of the
484
+ // section body — every existing byte (By-Phase table, Recent Trend,
485
+ // footer) is preserved verbatim, and no second "## Performance
486
+ // Metrics" heading is introduced. The section already existed, so
487
+ // `created` stays false (#2245/#2143).
396
488
  _recorded = true;
397
- return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
489
+ const newBody = metricsSection.body
490
+ + eol + '**Per-Plan Metrics:**'
491
+ + eol + eol
492
+ + '| Plan | Duration | Tasks | Files |'
493
+ + eol
494
+ + '|------|----------|-------|-------|'
495
+ + eol
496
+ + newRow
497
+ + eol;
498
+ return (0, markdown_sectionizer_cjs_1.replaceSection)(content, metricsSection, newBody);
398
499
  }
399
- // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
400
- // then append the row. Matches state begin-phase / advance-plan DWIM behavior.
500
+ // Section absent (or malformed) — DWIM: auto-create canonical
501
+ // ## Performance Metrics scaffold, then append the row. Matches state
502
+ // begin-phase / advance-plan DWIM behavior. Header corrected to this
503
+ // command's own canonical shape (`Plan | Duration | Tasks | Files`) —
504
+ // the prior scaffold's `| Phase | Plan | Duration | Notes |` header
505
+ // matched neither the appended row's shape nor the canonical table
506
+ // above (#2245/#2143).
401
507
  const scaffold = [
402
508
  '',
403
509
  '## Performance Metrics',
404
510
  '',
405
- '| Phase | Plan | Duration | Notes |',
406
- '|-------|------|----------|-------|',
511
+ '| Plan | Duration | Tasks | Files |',
512
+ '|------|----------|-------|-------|',
407
513
  newRow,
408
514
  '',
409
515
  ].join('\n');
@@ -447,18 +553,31 @@ function cmdStateUpdateProgress(cwd, raw) {
447
553
  const _totalPlans = totalPlans;
448
554
  const _totalSummaries = totalSummaries;
449
555
  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;
556
+ // #2177: match against the BODY only. With /i the patterns below would
557
+ // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
558
+ // eat its newline, mangling the nested block), while the body Progress: line
559
+ // — which frontmatter `percent` is re-derived from on every write — stays
560
+ // stale and silently reverts the update.
561
+ const body = stripFrontmatter(content);
562
+ const fmPrefix = content.slice(0, content.length - body.length);
563
+ // Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
564
+ // descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
565
+ const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
566
+ const replaceValue = (value) => machineSegment.test(value)
567
+ ? value.replace(machineSegment, progressStr)
568
+ : progressStr;
569
+ // Try **Progress:** bold format first, then plain Progress: format.
570
+ const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
571
+ const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
572
+ const pattern = boldProgressPattern.test(body)
573
+ ? boldProgressPattern
574
+ : plainProgressPattern.test(body)
575
+ ? plainProgressPattern
576
+ : null;
577
+ if (!pattern)
578
+ return content;
579
+ updated = true;
580
+ return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
462
581
  }, cwd);
463
582
  if (updated) {
464
583
  output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
@@ -961,15 +1080,22 @@ function cmdStateRecordSession(cwd, options, raw) {
961
1080
  * Match the session section body from a STATE.md body. #1101: recognise the
962
1081
  * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
963
1082
  * 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.
1083
+ * writer (which updates `## Session` first). Level-2-exact heading match
1084
+ * (excludes an h3 `### Session Continuity`); the exact `'session continuity'`
1085
+ * text match still excludes `## Session Continuity Archive` (preserving the
1086
+ * #2444 scoping). Migrated onto the `collectSection` seam (#2143 audit,
1087
+ * epic #2143): CRLF-safe — the prior hand-rolled `[ \t]*\n` regex silently
1088
+ * failed to match a CRLF `## Session\r\n` heading line (the `\r` broke the
1089
+ * `[ \t]*\n` boundary); `tokenizeHeadings` strips the trailing `\r` before
1090
+ * heading-text extraction, so this now matches CRLF headings too.
1091
+ * Returns the section body, or null.
969
1092
  */
970
1093
  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
1094
+ const isSession = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session';
1095
+ const isSessionContinuity = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
1096
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isSession, { levelBounded: true })
1097
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
1098
+ return section ? section.body : null;
973
1099
  }
974
1100
  function parseProsePhaseField(value) {
975
1101
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
@@ -1036,14 +1162,15 @@ function cmdStateSnapshot(cwd, raw) {
1036
1162
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1037
1163
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1038
1164
  const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1039
- // Extract decisions table
1165
+ // Extract decisions table — via the markdown-sectionizer/markdown-table
1166
+ // seams (ADR-2143 §7), cells addressed by column NAME rather than a
1167
+ // hand-rolled section+table regex.
1040
1168
  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);
1169
+ const decisionsSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => /^decisions made$/i.test(h.text.trim()));
1170
+ const decisionsTable = decisionsSection ? (0, markdown_table_cjs_1.parseMarkdownTable)(decisionsSection.body) : null;
1171
+ if (decisionsTable && decisionsTable.ok) {
1172
+ for (const row of decisionsTable.value.rows) {
1173
+ const cells = decisionsTable.value.columns.map((c) => (row[c] ?? '').trim()).filter(Boolean);
1047
1174
  if (cells.length >= 3) {
1048
1175
  decisions.push({
1049
1176
  phase: cells[0],
@@ -1055,10 +1182,9 @@ function cmdStateSnapshot(cwd, raw) {
1055
1182
  }
1056
1183
  // Extract blockers list
1057
1184
  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) || [];
1185
+ const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
1186
+ if (blockersSection) {
1187
+ const items = blockersSection.body.match(/^-\s+(.+)$/gm) || [];
1062
1188
  for (const item of items) {
1063
1189
  blockers.push(item.replace(/^-\s+/, '').trim());
1064
1190
  }
@@ -1072,8 +1198,8 @@ function cmdStateSnapshot(cwd, raw) {
1072
1198
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1073
1199
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1074
1200
  const sessionMatch = matchSessionSection(body);
1075
- if (sessionMatch) {
1076
- const sessionSection = sessionMatch[1];
1201
+ if (sessionMatch !== null) {
1202
+ const sessionSection = sessionMatch;
1077
1203
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
1078
1204
  // (the form written by the DWIM auto-create / normalize path added for #944).
1079
1205
  const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
@@ -1191,18 +1317,19 @@ function buildStateFrontmatter(bodyContent, cwd) {
1191
1317
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1192
1318
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1193
1319
  const sessionSectionMatch = matchSessionSection(bodyContent);
1194
- const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
1320
+ const sessionBodyScope = sessionSectionMatch ?? bodyContent;
1195
1321
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
1196
1322
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
1197
1323
  let milestone = null;
1198
1324
  let milestoneName = null;
1199
1325
  if (cwd) {
1200
- try {
1201
- const info = getMilestoneInfo(cwd);
1202
- milestone = info.version;
1203
- milestoneName = info.name;
1204
- }
1205
- catch { /* intentionally empty */ }
1326
+ // DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
1327
+ // try/catch (roadmap-parser.cts) that already swallows every internal
1328
+ // failure and always returns a MilestoneInfo — it never throws, so this
1329
+ // wrapper could never be triggered.
1330
+ const info = getMilestoneInfo(cwd);
1331
+ milestone = info.version;
1332
+ milestoneName = info.name;
1206
1333
  }
1207
1334
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1208
1335
  let completedPhases = null;
@@ -1336,6 +1463,13 @@ function buildStateFrontmatter(bodyContent, cwd) {
1336
1463
  completedPlans = cached.completedPlans;
1337
1464
  milestoneUnbounded = cached.milestoneBounded === false;
1338
1465
  }
1466
+ /* best-effort (#2245 audit): this is a READ path building STATE.md's
1467
+ * display frontmatter. The real throw source is fs.readdirSync(phasesDir)
1468
+ * a few lines up — an inaccessible/racily-removed phases dir must not
1469
+ * crash `state show`; on failure this simply keeps whatever
1470
+ * frontmatter-derived totals/completedPhases/etc. were already set
1471
+ * above, a graceful degrade rather than a corrupted write (nothing is
1472
+ * persisted from this block). */
1339
1473
  }
1340
1474
  catch { /* intentionally empty */ }
1341
1475
  }
@@ -1391,19 +1525,6 @@ function buildStateFrontmatter(bodyContent, cwd) {
1391
1525
  fm['progress'] = progress;
1392
1526
  return fm;
1393
1527
  }
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
1528
  function syncStateFrontmatter(content, cwd) {
1408
1529
  // Read existing frontmatter BEFORE stripping — it may contain values
1409
1530
  // that the body no longer has (e.g., Status field removed by an agent).
@@ -1425,7 +1546,17 @@ function syncStateFrontmatter(content, cwd) {
1425
1546
  // existing frontmatter already holds; only an empty derived value falls through
1426
1547
  // to this guard (the primary #905 preserve path below handles that).
1427
1548
  const MILESTONE_NAME_PLACEHOLDER = 'milestone';
1428
- if (derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
1549
+ // #2135: widen the preserve guard. A bad derive is not always the literal
1550
+ // placeholder — getMilestoneInfo can return a delimiter-led fragment
1551
+ // ("— Active Milestone") when the roadmap regex mis-binds. Preserve the
1552
+ // existing curated name unless the derived value actually looks like a name:
1553
+ // non-empty, not the placeholder, and not punctuation-led.
1554
+ const derivedName = derivedFm['milestone_name'];
1555
+ const derivedLooksLikeName = typeof derivedName === 'string'
1556
+ && derivedName.length > 0
1557
+ && derivedName !== MILESTONE_NAME_PLACEHOLDER
1558
+ && !/^[\s—–:-]/.test(derivedName);
1559
+ if (!derivedLooksLikeName &&
1429
1560
  existingFm['milestone_name'] &&
1430
1561
  existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER) {
1431
1562
  derivedFm['milestone_name'] = existingFm['milestone_name'];
@@ -1472,6 +1603,15 @@ function syncStateFrontmatter(content, cwd) {
1472
1603
  if (!derivedFm['progress'] && existingFm['progress']) {
1473
1604
  derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
1474
1605
  }
1606
+ // #2202: carry forward any existing frontmatter key that the schema does not
1607
+ // own, so custom/unknown keys are not silently dropped on every mutating verb.
1608
+ // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
1609
+ // preserve guards above) still win.
1610
+ for (const key of Object.keys(existingFm)) {
1611
+ if (!(key in derivedFm) && existingFm[key] !== undefined) {
1612
+ derivedFm[key] = existingFm[key];
1613
+ }
1614
+ }
1475
1615
  const yamlStr = reconstructFrontmatter(derivedFm);
1476
1616
  return `---\n${yamlStr}\n---\n\n${body}`;
1477
1617
  }
@@ -1786,7 +1926,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1786
1926
  // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1787
1927
  // Archive prose) must not interfere with the delta comparison.
1788
1928
  const preSessionMatch = matchSessionSection(preBody);
1789
- const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1929
+ const preSessionScope = preSessionMatch ?? preBody;
1790
1930
  const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1791
1931
  // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
1792
1932
  // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
@@ -1815,7 +1955,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1815
1955
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1816
1956
  // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1817
1957
  const postSessionMatch = matchSessionSection(postBody);
1818
- const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1958
+ const postSessionScope = postSessionMatch ?? postBody;
1819
1959
  const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1820
1960
  // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
1821
1961
  // current_phase_name delta comparison.
@@ -1971,6 +2111,37 @@ function cmdSignalResume(cwd, raw) {
1971
2111
  output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1972
2112
  }
1973
2113
  // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
2114
+ /**
2115
+ * Find the character offset where the FIRST GFM table whose header is a
2116
+ * superset of `required` column names begins (order-independent; extra
2117
+ * columns tolerated) — the position-aware counterpart to markdown-table's
2118
+ * `findTableWithColumns`, used to scope `updateTableCell` (which always
2119
+ * operates on "the first table in its input") to the RIGHT table when an
2120
+ * unrelated earlier table (that doesn't itself name every required column)
2121
+ * may precede it in the same document. Returns `null` when no such table is
2122
+ * found. Never trips the table-regex fingerprint (no `[^|]` cell-capture
2123
+ * class) and never throws.
2124
+ *
2125
+ * Ragged-tolerant (#2245 Blocker 2): accepts the offset the moment a HEADER
2126
+ * line names every required column — it deliberately does NOT additionally
2127
+ * require `parseMarkdownTable(text.slice(m.index)).ok`, which validates every
2128
+ * DATA row's cell count. A ragged sibling row anywhere in the table used to
2129
+ * make that whole-table parse fail, so the offset came back `null` and the
2130
+ * caller's `updateTableCell` calls (which scope to this offset) never even
2131
+ * ran against an otherwise-perfectly-findable row.
2132
+ */
2133
+ function findTableStartOffset(text, required) {
2134
+ const lineRe = /^[ \t]*\|.*\|[ \t]*$/gm;
2135
+ let m;
2136
+ while ((m = lineRe.exec(text)) !== null) {
2137
+ const trimmed = m[0].trim();
2138
+ const cols = trimmed.replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
2139
+ if (required.every((rq) => cols.includes(rq))) {
2140
+ return m.index;
2141
+ }
2142
+ }
2143
+ return null;
2144
+ }
1974
2145
  /**
1975
2146
  * Update the ## Performance Metrics section in STATE.md content.
1976
2147
  * Increments Velocity totals and upserts a By Phase table row.
@@ -1982,45 +2153,128 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
1982
2153
  // the same phase again upserts the same row, so the column sum is stable. The previous
1983
2154
  // blind-add (prevTotal + summaryCount) re-read the cumulative total each call and
1984
2155
  // double-counted on every re-run. (#1582)
1985
- const byPhaseMatch = content.match(byPhaseTablePattern);
1986
- if (byPhaseMatch) {
1987
- let tableBody = byPhaseMatch[2].trim();
2156
+ //
2157
+ // Located by column NAME via the markdown-table seam (ADR-2143 §7) —
2158
+ // supersedes the prior module-level byPhaseTablePattern regex for the
2159
+ // existence/lookup half of this logic.
2160
+ const byPhaseCols = ['Phase', 'Plans', 'Total', 'Avg/Plan'];
2161
+ // Ragged-tolerant (#2245 Blocker 2): scope to the table's start offset
2162
+ // (findTableStartOffset — itself now ragged-tolerant, see above) rather
2163
+ // than gating existence/lookup on findTableWithColumns, which requires the
2164
+ // WHOLE table to parse — a ragged row for a DIFFERENT phase used to
2165
+ // silently no-op every phase's upsert.
2166
+ const tableStart = findTableStartOffset(content, byPhaseCols);
2167
+ if (tableStart !== null) {
1988
2168
  // Match the existing row for this phase, tolerating leading-zero padding in either
1989
2169
  // direction (#1659): canonicalize a numeric phase to its integer form so a seeded
1990
2170
  // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
1991
2171
  const phaseNumStr = String(phaseNum);
1992
2172
  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);
2173
+ const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
2174
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
2175
+ const before = content.slice(0, tableStart);
2176
+ let tableText = content.slice(tableStart);
2177
+ // Ragged-tolerant existence probe: a no-op updateTableCell write on the
2178
+ // identifying "Phase" column (its own tolerant row scan) decides whether
2179
+ // this phase's row already exists, without requiring every OTHER row in
2180
+ // the table to also parse cleanly.
2181
+ let rowExists = false;
2182
+ const existsProbe = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Phase', (current) => {
2183
+ rowExists = true;
2184
+ return current;
2185
+ });
2186
+ void existsProbe;
2187
+ if (rowExists) {
2188
+ // Update existing row — one updateTableCell call per column (Phase
2189
+ // itself may also change shape, e.g. "05" -> "5" per #1659).
2190
+ const phaseResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Phase', ` ${phaseNum} `);
2191
+ if (phaseResult.ok)
2192
+ tableText = phaseResult.value;
2193
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Plans', ` ${summaryCount} `);
2194
+ if (plansResult.ok)
2195
+ tableText = plansResult.value;
2196
+ const totalResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Total', ' - ');
2197
+ if (totalResult.ok)
2198
+ tableText = totalResult.value;
2199
+ const avgResult = (0, markdown_table_cjs_1.updateTableCell)(tableText, rowMatch, 'Avg/Plan', ' - ');
2200
+ if (avgResult.ok)
2201
+ tableText = avgResult.value;
2202
+ content = before + tableText;
1998
2203
  }
1999
2204
  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;
2205
+ // Row doesn't exist — INSERT a new row. Row insertion (unlike a cell
2206
+ // update) is outside updateTableCell's scope (ADR-2143 §7 Phase 4);
2207
+ // `insertTableRow` (markdown-table.cjs) is its name-addressed,
2208
+ // header-order-agnostic sibling (#2245 audit: this used to locate the
2209
+ // table via `byPhaseTablePattern`, a canonical-column-ORDER-only regex,
2210
+ // and build the row as a hardcoded positional literal — so a reordered/
2211
+ // superset By-Phase header, already tolerated above by
2212
+ // findTableStartOffset and read by-NAME in the update/sum halves,
2213
+ // silently inserted NOTHING).
2214
+ //
2215
+ // Drop a lone all-placeholder row first (e.g. the freshly-scaffolded
2216
+ // "| - | - | - | - |" seed row) — same convention the prior
2217
+ // canonical-order path used, generalized to any column order/count:
2218
+ // a row whose every PRESENT cell is "-" is the placeholder.
2219
+ const placeholderRow = (row) => Object.values(row).every((cell) => cell.trim() === '-');
2220
+ const withoutPlaceholder = (0, markdown_table_cjs_1.deleteTableRow)(tableText, placeholderRow);
2221
+ if (withoutPlaceholder.ok)
2222
+ tableText = withoutPlaceholder.value;
2223
+ // Map the By-Phase values onto the table's ACTUAL header columns by
2224
+ // NAME — an unrecognized column (a superset header) falls back to "-",
2225
+ // insertTableRow's default.
2226
+ const valueFor = (col) => {
2227
+ if (col === 'Phase')
2228
+ return String(phaseNum);
2229
+ if (col === 'Plans')
2230
+ return String(summaryCount);
2231
+ if (col === 'Total' || col === 'Avg/Plan')
2232
+ return '-';
2233
+ return undefined;
2234
+ };
2235
+ const insertResult = (0, markdown_table_cjs_1.insertTableRow)(tableText, valueFor);
2236
+ if (insertResult.ok)
2237
+ tableText = insertResult.value;
2238
+ content = before + tableText;
2003
2239
  }
2004
- content = content.replace(byPhaseTablePattern, (_match, tableHeader) => `${tableHeader}${tableBody}\n`);
2005
2240
  }
2006
2241
  // 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)
2242
+ // across all data rows. Idempotent by construction (re-running phase complete upserts
2243
+ // the same row → same sum) and self-healing (a hand-edited inflated total is corrected
2244
+ // to the true sum on the next completion). When the By-Phase table is absent, leave the
2245
+ // velocity total unchanged rather than guess. (#1582)
2246
+ //
2247
+ // Ragged-tolerant AND name-addressed (#2245 audit): each data row is split via
2248
+ // `splitTableRow` and its "Plans" cell located by the HEADER's own column
2249
+ // order (not a fixed ordinal), so a reordered/superset By-Phase header is
2250
+ // summed correctly instead of silently reading the wrong cell. A row that's
2251
+ // too short to physically contain the "Plans" column is skipped, not
2252
+ // treated as an error — mirrors updateTableCell's ragged-row tolerance
2253
+ // (a hand-edited/ragged row for one phase must not blank out the derived
2254
+ // total for every phase). Still scoped via findTableStartOffset so the RIGHT
2255
+ // table is summed when an earlier unrelated table also has a "Phase"
2256
+ // column (#2012).
2011
2257
  if (/Total plans completed:\s*(\d+|\[N\])/.test(content)) {
2012
- const tableForSum = content.match(byPhaseTablePattern);
2013
- if (tableForSum) {
2258
+ const sumTableStart = findTableStartOffset(content, byPhaseCols);
2259
+ if (sumTableStart !== null) {
2260
+ const tableLines = content.slice(sumTableStart).split(/\r?\n/);
2261
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(tableLines[0] ?? '');
2262
+ const plansIdx = headerCells.indexOf('Plans');
2014
2263
  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);
2264
+ if (plansIdx !== -1) {
2265
+ // The delimiter row is skipped by NAME (isDelimiterRow), not by a
2266
+ // hardcoded "always line index 1" assumption, so this stays
2267
+ // self-consistent with the ragged-tolerant read below.
2268
+ const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(tableLines[1] ?? '');
2269
+ const dataStart = (0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells) ? 2 : 1;
2270
+ for (const row of tableLines.slice(dataStart)) {
2271
+ if (!row.trim().startsWith('|'))
2272
+ break;
2273
+ const cells = (0, markdown_table_cjs_1.splitTableRow)(row);
2274
+ if (plansIdx < cells.length && /^\d+$/.test(cells[plansIdx])) {
2275
+ sum += parseInt(cells[plansIdx], 10);
2276
+ }
2277
+ }
2024
2278
  }
2025
2279
  content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${sum}`);
2026
2280
  }
@@ -2137,7 +2391,11 @@ function cmdStateValidate(cwd, raw) {
2137
2391
  drift['verification_status'] = { state_status: status, verification: 'passed' };
2138
2392
  }
2139
2393
  }
2140
- catch { /* intentionally empty */ }
2394
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
2395
+ * warnings scan across N VERIFICATION.md files — one unreadable file
2396
+ * (permission/race) must not abort the scan of the rest; it's simply
2397
+ * excluded from drift detection. */
2398
+ }
2141
2399
  }
2142
2400
  // Check if all plans have summaries but status still says executing
2143
2401
  if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
@@ -2148,7 +2406,14 @@ function cmdStateValidate(cwd, raw) {
2148
2406
  }
2149
2407
  }
2150
2408
  }
2151
- catch { /* intentionally empty */ }
2409
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a read-only
2410
+ * diagnostic scan of the current phase's directory (readdirSync +
2411
+ * scanPhasePlans). A disk-scan failure here means drift detection for
2412
+ * this phase is skipped for this run, degrading to "no warnings from
2413
+ * that scan" rather than crashing the validate command — the same
2414
+ * degrade-on-scan-failure pattern buildStateFrontmatter's own disk scan
2415
+ * already uses. */
2416
+ }
2152
2417
  }
2153
2418
  const valid = warnings.length === 0;
2154
2419
  output({ valid, warnings, drift }, raw, undefined);
@@ -2237,33 +2502,33 @@ function cmdStateSync(cwd, options, raw) {
2237
2502
  }
2238
2503
  // Determine total phases from ROADMAP (may be larger than realized disk dirs).
2239
2504
  // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
2505
+ // DEAD catch removed (#2245 audit): every operation in this block is a regex
2506
+ // exec/test over an already-read string plus pure Set/Math ops — none of
2507
+ // which can throw — so the try/catch could never be triggered.
2240
2508
  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;
2509
+ let roadmapPhaseCount = 0;
2510
+ if (syncRoadmapScope !== null) {
2511
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2512
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
2513
+ let m;
2514
+ while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2515
+ // Only count tokens that contain at least one digit — excludes
2516
+ // pure-word section headings (Overview, Details) while keeping
2517
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2518
+ if (!/\d/.test(m[1]))
2519
+ continue;
2520
+ // #1514: retired/folded phases are struck through; exclude from total.
2521
+ if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
2522
+ continue;
2523
+ roadmapPhaseCount++;
2264
2524
  }
2265
2525
  }
2266
- catch { /* intentionally empty */ }
2526
+ if (roadmapPhaseCount > 0) {
2527
+ syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
2528
+ }
2529
+ else {
2530
+ syncTotalPhases = entries.length;
2531
+ }
2267
2532
  // ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last
2268
2533
  // Activity) are the pure `syncCore` in src/state-transition.cts.
2269
2534
  // #1761: when a milestone version is set in frontmatter but the ROADMAP has no
@@ -2383,7 +2648,7 @@ function cmdStatePrune(cwd, options, raw) {
2383
2648
  }, cwd);
2384
2649
  // Write archived entries to STATE-ARCHIVE.md
2385
2650
  if (archived.length > 0) {
2386
- const timestamp = clock_cjs_1.realClock.today();
2651
+ const timestamp = clock_cjs_1.realClock.localToday();
2387
2652
  let archiveContent = (0, shell_command_projection_cjs_1.platformReadSync)(archivePath);
2388
2653
  if (archiveContent === null) {
2389
2654
  archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
@@ -2562,7 +2827,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2562
2827
  output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
2563
2828
  return;
2564
2829
  }
2565
- const today = clock_cjs_1.realClock.today();
2830
+ const today = clock_cjs_1.realClock.localToday();
2566
2831
  const updated = [];
2567
2832
  readModifyWriteStateMd(statePath, (content) => {
2568
2833
  const currentPhase = resolvedPhase;