@opengsd/gsd-core 1.5.0 → 1.6.0-rc.1

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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -34,6 +34,7 @@ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
36
  const state_document_cjs_1 = require("./state-document.cjs");
37
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
37
38
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
38
39
  'Progress',
39
40
  'Total Plans in Phase',
@@ -66,6 +67,14 @@ process.on('exit', () => {
66
67
  });
67
68
  // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
68
69
  const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
70
+ // ─── ADR-1372 T6: seam-based section splice helper ───────────────────────────
71
+ // Shared stop predicates corresponding to the regex lookaheads used in state.cts:
72
+ // STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2
73
+ // STOP_H2_H3 : (?=\n###?|\n##[^#]|$) — stops at level 2 or 3
74
+ // STOP_H2_ONLY : (?=\n##[^#]|$) — stops at level 2 only
75
+ const STOP_H2_PLUS = (lv) => lv >= 2;
76
+ const STOP_H2_H3 = (lv) => lv === 2 || lv === 3;
77
+ const STOP_H2_ONLY = (lv) => lv === 2;
69
78
  function cmdStateLoad(cwd, raw) {
70
79
  const config = loadConfig(cwd);
71
80
  const planDir = planningPaths(cwd).planning;
@@ -261,11 +270,25 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
261
270
  * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
262
271
  */
263
272
  function updateCurrentPositionFields(content, fields) {
264
- const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
265
- const posMatch = content.match(posPattern);
266
- if (!posMatch)
273
+ // ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the
274
+ // untrimmed body span, apply field edits, then splice the modified body back in.
275
+ // Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2.
276
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
277
+ const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
278
+ if (posIdx === -1)
267
279
  return content;
268
- let posBody = posMatch[2];
280
+ const posHeading = headings[posIdx];
281
+ const lines = content.split('\n');
282
+ const posHeadingLine = lines[posHeading.line - 1];
283
+ const posBodyStart = posHeading.offset + posHeadingLine.length + 1;
284
+ let posBodyEnd = content.length;
285
+ for (let j = posIdx + 1; j < headings.length; j++) {
286
+ if (STOP_H2_PLUS(headings[j].level)) {
287
+ posBodyEnd = headings[j].offset - 1;
288
+ break;
289
+ }
290
+ }
291
+ let posBody = content.slice(posBodyStart, posBodyEnd);
269
292
  const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
270
293
  const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
271
294
  if (fields.status) {
@@ -339,7 +362,8 @@ function updateCurrentPositionFields(content, fields) {
339
362
  posBody = replaced;
340
363
  }
341
364
  }
342
- return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
365
+ // Splice the modified body back in place of the original untrimmed span.
366
+ return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd);
343
367
  }
344
368
  function cmdStateAdvancePlan(cwd, raw) {
345
369
  const statePath = planningPaths(cwd).state;
@@ -435,7 +459,7 @@ function cmdStateRecordMetric(cwd, options, raw) {
435
459
  let created = false;
436
460
  readModifyWriteStateMd(statePath, (content) => {
437
461
  // Find Performance Metrics section and its table
438
- const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
462
+ 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
439
463
  const metricsMatch = content.match(metricsPattern);
440
464
  const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
441
465
  if (metricsMatch) {
@@ -545,16 +569,34 @@ function cmdStateAddDecision(cwd, options, raw) {
545
569
  let _added = false;
546
570
  let created = false;
547
571
  readModifyWriteStateMd(statePath, (content) => {
548
- // Find Decisions section (various heading patterns)
549
- const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
550
- const match = content.match(sectionPattern);
551
- if (match) {
552
- let sectionBody = match[2];
572
+ // ADR-1372 T6: find Decisions section via tokenizeHeadings; stop at level 2 or 3.
573
+ // Mirrors /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
574
+ const decisionsPred = (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text);
575
+ const sectionBody = (() => {
576
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
577
+ const i = hs.findIndex(h => decisionsPred(h.level, h.text));
578
+ if (i === -1)
579
+ return null;
580
+ const h = hs[i];
581
+ const ls = content.split('\n');
582
+ const hl = ls[h.line - 1];
583
+ const bs = h.offset + hl.length + 1;
584
+ let se = content.length;
585
+ for (let j = i + 1; j < hs.length; j++) {
586
+ if (STOP_H2_H3(hs[j].level)) {
587
+ se = hs[j].offset - 1;
588
+ break;
589
+ }
590
+ }
591
+ return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) };
592
+ })();
593
+ if (sectionBody !== null) {
594
+ let newBody = sectionBody.body;
553
595
  // Remove placeholders
554
- sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
555
- sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
596
+ newBody = newBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
597
+ newBody = newBody.trimEnd() + '\n' + entry + '\n';
556
598
  _added = true;
557
- return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
599
+ return content.slice(0, sectionBody.bodyStart) + newBody + content.slice(sectionBody.bodyEnd);
558
600
  }
559
601
  // Section absent — DWIM: auto-create canonical ## Decisions scaffold,
560
602
  // then append the entry. Matches state begin-phase / advance-plan DWIM behavior.
@@ -598,14 +640,33 @@ function cmdStateAddBlocker(cwd, text, raw) {
598
640
  let _added = false;
599
641
  let created = false;
600
642
  readModifyWriteStateMd(statePath, (content) => {
601
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
602
- const match = content.match(sectionPattern);
603
- if (match) {
604
- let sectionBody = match[2];
643
+ // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
644
+ // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
645
+ const blockersPred = (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(text);
646
+ const sectionSpan = (() => {
647
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
648
+ const i = hs.findIndex(h => blockersPred(h.level, h.text));
649
+ if (i === -1)
650
+ return null;
651
+ const h = hs[i];
652
+ const ls = content.split('\n');
653
+ const hl = ls[h.line - 1];
654
+ const bs = h.offset + hl.length + 1;
655
+ let se = content.length;
656
+ for (let j = i + 1; j < hs.length; j++) {
657
+ if (STOP_H2_H3(hs[j].level)) {
658
+ se = hs[j].offset - 1;
659
+ break;
660
+ }
661
+ }
662
+ return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) };
663
+ })();
664
+ if (sectionSpan !== null) {
665
+ let sectionBody = sectionSpan.body;
605
666
  sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
606
667
  sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
607
668
  _added = true;
608
- return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
669
+ return content.slice(0, sectionSpan.bodyStart) + sectionBody + content.slice(sectionSpan.bodyEnd);
609
670
  }
610
671
  // Section absent — DWIM: auto-create canonical ### Blockers scaffold.
611
672
  const scaffold = [
@@ -666,18 +727,47 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
666
727
  // Section boundaries mirror the sibling handlers (add-decision/add-blocker):
667
728
  // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
668
729
  // so following sections are preserved without data loss (see the CRLF test).
730
+ //
731
+ // ADR-1372 T6: accPattern and subPattern migrated to tokenizeHeadings.
732
+ // accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i
733
+ // → stop at level 2 only (STOP_H2_ONLY)
734
+ // subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i
735
+ // → applied to accBody; stop at level 2 or 3 (STOP_H2_H3)
669
736
  readModifyWriteStateMd(statePath, (content) => {
670
- const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
671
- const accMatch = content.match(accPattern);
672
- if (accMatch) {
673
- const accHeader = accMatch[1];
674
- const accBody = accMatch[2];
737
+ // Locate ## Accumulated Context and extract its untrimmed body span.
738
+ const accHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
739
+ const accIdx = accHs.findIndex(h => h.level === 2 && /^accumulated\s+context$/i.test(h.text));
740
+ if (accIdx !== -1) {
741
+ const accH = accHs[accIdx];
742
+ const contentLines = content.split('\n');
743
+ const accHL = contentLines[accH.line - 1];
744
+ const accBodyStart = accH.offset + accHL.length + 1;
745
+ let accBodyEnd = content.length;
746
+ for (let j = accIdx + 1; j < accHs.length; j++) {
747
+ if (STOP_H2_ONLY(accHs[j].level)) {
748
+ accBodyEnd = accHs[j].offset - 1;
749
+ break;
750
+ }
751
+ }
752
+ const accBody = content.slice(accBodyStart, accBodyEnd);
675
753
  // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
676
- // Bounded by the next h3/h2 or the end of the section body.
677
- const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
678
- const subMatch = accBody.match(subPattern);
679
- if (subMatch) {
680
- let subBody = subMatch[2];
754
+ // tokenizeHeadings is applied to accBody to scope the search.
755
+ // Stop predicate mirrors (?=\n###?|$): level 2 or 3.
756
+ const subHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(accBody);
757
+ const subIdx = subHs.findIndex(h => h.level === 3 && /^roadmap\s+evolution$/i.test(h.text));
758
+ if (subIdx !== -1) {
759
+ const subH = subHs[subIdx];
760
+ const accLines = accBody.split('\n');
761
+ const subHL = accLines[subH.line - 1];
762
+ const subBodyStart = subH.offset + subHL.length + 1;
763
+ let subBodyEnd = accBody.length;
764
+ for (let j = subIdx + 1; j < subHs.length; j++) {
765
+ if (STOP_H2_H3(subHs[j].level)) {
766
+ subBodyEnd = subHs[j].offset - 1;
767
+ break;
768
+ }
769
+ }
770
+ let subBody = accBody.slice(subBodyStart, subBodyEnd);
681
771
  // Dedupe: exact (trimmed) line already present is a no-op replay.
682
772
  if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
683
773
  duplicate = true;
@@ -685,14 +775,15 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
685
775
  }
686
776
  subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
687
777
  subBody = subBody.trimEnd() + '\n' + entry + '\n';
688
- const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
689
- return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
778
+ // Splice subBody into accBody, then splice newAccBody into content.
779
+ const newAccBody = accBody.slice(0, subBodyStart) + subBody + accBody.slice(subBodyEnd);
780
+ return content.slice(0, accBodyStart) + newAccBody + content.slice(accBodyEnd);
690
781
  }
691
782
  // Subsection missing — append it at the end of the Accumulated Context body.
692
783
  subsectionCreated = true;
693
784
  const trimmedAcc = accBody.trimEnd();
694
785
  const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
695
- return content.replace(accPattern, () => `${accHeader}${block}`);
786
+ return content.slice(0, accBodyStart) + block + content.slice(accBodyEnd);
696
787
  }
697
788
  // No `## Accumulated Context` — DWIM: create both at end of file.
698
789
  // Mirrors the add-decision / add-blocker auto-create behavior.
@@ -732,25 +823,37 @@ function cmdStateResolveBlocker(cwd, text, raw) {
732
823
  }
733
824
  let resolved = false;
734
825
  readModifyWriteStateMd(statePath, (content) => {
735
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
736
- const match = content.match(sectionPattern);
737
- if (match) {
738
- const sectionBody = match[2];
739
- const lines = sectionBody.split('\n');
740
- const filtered = lines.filter(line => {
741
- if (!line.startsWith('- '))
742
- return true;
743
- return !line.toLowerCase().includes(text.toLowerCase());
744
- });
745
- let newBody = filtered.join('\n');
746
- // If section is now empty, add placeholder
747
- if (!newBody.trim() || !newBody.includes('- ')) {
748
- newBody = 'None\n';
826
+ // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
827
+ // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
828
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
829
+ const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
830
+ if (i === -1)
831
+ return content;
832
+ const h = hs[i];
833
+ const ls = content.split('\n');
834
+ const hl = ls[h.line - 1];
835
+ const bs = h.offset + hl.length + 1;
836
+ let se = content.length;
837
+ for (let j = i + 1; j < hs.length; j++) {
838
+ if (STOP_H2_H3(hs[j].level)) {
839
+ se = hs[j].offset - 1;
840
+ break;
749
841
  }
750
- resolved = true;
751
- return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
752
842
  }
753
- return content;
843
+ const sectionBody = content.slice(bs, se);
844
+ const lines = sectionBody.split('\n');
845
+ const filtered = lines.filter(line => {
846
+ if (!line.startsWith('- '))
847
+ return true;
848
+ return !line.toLowerCase().includes(text.toLowerCase());
849
+ });
850
+ let newBody = filtered.join('\n');
851
+ // If section is now empty, add placeholder
852
+ if (!newBody.trim() || !newBody.includes('- ')) {
853
+ newBody = 'None\n';
854
+ }
855
+ resolved = true;
856
+ return content.slice(0, bs) + newBody + content.slice(se);
754
857
  }, cwd);
755
858
  if (resolved) {
756
859
  output({ resolved: true, blocker: text }, raw, 'true');
@@ -942,8 +1045,8 @@ function cmdStateRecordSession(cwd, options, raw) {
942
1045
  * Returns the match whose group 1 is the section body, or null.
943
1046
  */
944
1047
  function matchSessionSection(body) {
945
- return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
946
- || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
1048
+ 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
1049
+ || 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
947
1050
  }
948
1051
  function parseProsePhaseField(value) {
949
1052
  if (!value)
@@ -1018,7 +1121,7 @@ function cmdStateSnapshot(cwd, raw) {
1018
1121
  const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1019
1122
  // Extract decisions table
1020
1123
  const decisions = [];
1021
- const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
1124
+ 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
1022
1125
  if (decisionsMatch) {
1023
1126
  const tableBody = decisionsMatch[1];
1024
1127
  const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
@@ -1035,7 +1138,7 @@ function cmdStateSnapshot(cwd, raw) {
1035
1138
  }
1036
1139
  // Extract blockers list
1037
1140
  const blockers = [];
1038
- const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
1141
+ 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
1039
1142
  if (blockersMatch) {
1040
1143
  const blockersSection = blockersMatch[1];
1041
1144
  const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
@@ -1195,7 +1298,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
1195
1298
  // Only count tokens that contain at least one digit — excludes
1196
1299
  // pure-word section headings (Overview, Details) while keeping
1197
1300
  // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1198
- if (/\d/.test(m[1]))
1301
+ // Also exclude 999.x backlog phases. Mirrors init.cts filter.
1302
+ if (/\d/.test(m[1]) && !/^999\b/.test(m[1]))
1199
1303
  roadmapPhaseCount++;
1200
1304
  }
1201
1305
  }
@@ -1759,11 +1863,23 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1759
1863
  updated.push('Current focus');
1760
1864
  }
1761
1865
  // Update ## Current Position section (#1104, #1365)
1762
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1763
- const positionMatch = body.match(positionPattern);
1764
- if (positionMatch) {
1765
- const header = positionMatch[1];
1766
- let posBody = positionMatch[2];
1866
+ // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
1867
+ // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
1868
+ const posHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
1869
+ const posIdx = posHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
1870
+ if (posIdx !== -1) {
1871
+ const posH = posHs[posIdx];
1872
+ const bodyLines = body.split('\n');
1873
+ const posHL = bodyLines[posH.line - 1];
1874
+ const posBodyStart = posH.offset + posHL.length + 1;
1875
+ let posBodyEnd = body.length;
1876
+ for (let j = posIdx + 1; j < posHs.length; j++) {
1877
+ if (STOP_H2_PLUS(posHs[j].level)) {
1878
+ posBodyEnd = posHs[j].offset - 1;
1879
+ break;
1880
+ }
1881
+ }
1882
+ let posBody = body.slice(posBodyStart, posBodyEnd);
1767
1883
  // Update or insert Phase line
1768
1884
  const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1769
1885
  if (/^Phase:/m.test(posBody)) {
@@ -1816,22 +1932,33 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1816
1932
  if (replaced !== null)
1817
1933
  posBody = replaced;
1818
1934
  }
1819
- body = body.replace(positionPattern, () => `${header}${posBody}`);
1935
+ body = body.slice(0, posBodyStart) + posBody + body.slice(posBodyEnd);
1820
1936
  updated.push('Current Position');
1821
1937
  }
1822
1938
  }
1823
1939
  else {
1824
1940
  // Resume path: only update Last activity timestamp in Current Position
1825
1941
  // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
1826
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1827
- const positionMatch = body.match(positionPattern);
1828
- if (positionMatch) {
1829
- const header = positionMatch[1];
1830
- let posBody = positionMatch[2];
1942
+ // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
1943
+ const posHsR = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
1944
+ const posIdxR = posHsR.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
1945
+ if (posIdxR !== -1) {
1946
+ const posHR = posHsR[posIdxR];
1947
+ const bodyLinesR = body.split('\n');
1948
+ const posHLR = bodyLinesR[posHR.line - 1];
1949
+ const posBodyStartR = posHR.offset + posHLR.length + 1;
1950
+ let posBodyEndR = body.length;
1951
+ for (let j = posIdxR + 1; j < posHsR.length; j++) {
1952
+ if (STOP_H2_PLUS(posHsR[j].level)) {
1953
+ posBodyEndR = posHsR[j].offset - 1;
1954
+ break;
1955
+ }
1956
+ }
1957
+ let posBody = body.slice(posBodyStartR, posBodyEndR);
1831
1958
  const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
1832
1959
  if (/^Last activity:/im.test(posBody)) {
1833
1960
  posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
1834
- body = body.replace(positionPattern, () => `${header}${posBody}`);
1961
+ body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
1835
1962
  updated.push('Last activity (resume)');
1836
1963
  }
1837
1964
  else {
@@ -1840,7 +1967,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1840
1967
  ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
1841
1968
  if (replaced !== null) {
1842
1969
  posBody = replaced;
1843
- body = body.replace(positionPattern, () => `${header}${posBody}`);
1970
+ body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
1844
1971
  updated.push('Last activity (resume)');
1845
1972
  }
1846
1973
  }
@@ -2016,14 +2143,28 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2016
2143
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
2017
2144
  const existingFm = extractFrontmatter(content);
2018
2145
  const body = stripFrontmatter(content);
2019
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2146
+ // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
2147
+ // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
2020
2148
  const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
2021
2149
  `Plan: —\n` +
2022
2150
  `Status: Defining requirements\n` +
2023
2151
  `Last activity: ${today} — Milestone ${version} started\n\n`;
2024
2152
  let newBody;
2025
- if (positionPattern.test(body)) {
2026
- newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
2153
+ const msPosHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
2154
+ const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
2155
+ if (msPosIdx !== -1) {
2156
+ const msPosH = msPosHs[msPosIdx];
2157
+ const msBodyLines = body.split('\n');
2158
+ const msPosHL = msBodyLines[msPosH.line - 1];
2159
+ const msPosBodyStart = msPosH.offset + msPosHL.length + 1;
2160
+ let msPosBodyEnd = body.length;
2161
+ for (let j = msPosIdx + 1; j < msPosHs.length; j++) {
2162
+ if (STOP_H2_PLUS(msPosHs[j].level)) {
2163
+ msPosBodyEnd = msPosHs[j].offset - 1;
2164
+ break;
2165
+ }
2166
+ }
2167
+ newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd);
2027
2168
  }
2028
2169
  else {
2029
2170
  const preface = body.trim().length > 0 ? body : '# Project State\n';
@@ -2284,16 +2425,41 @@ function cmdStatePrune(cwd, options, raw) {
2284
2425
  const archived = [];
2285
2426
  // Shared pruning logic applied to both dry-run and real passes.
2286
2427
  // Returns { newContent, archivedSections }.
2428
+ // ADR-1372 T6: all four inline section-collect regexes replaced with
2429
+ // tokenizeHeadings + untrimmed-span splicing for byte-identical writes.
2287
2430
  function prunePass(content) {
2288
2431
  const sections = [];
2432
+ // Helper: locate a heading matching pred, extract untrimmed body [bs, se),
2433
+ // apply transform, and splice back. Returns updated content.
2434
+ // All prune-section patterns stop at level 2 or 3 (STOP_H2_H3).
2435
+ function pruneSectionSpan(c, pred, transform, sectionName) {
2436
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c);
2437
+ const i = hs.findIndex(h => pred(h.level, h.text));
2438
+ if (i === -1)
2439
+ return c;
2440
+ const h = hs[i];
2441
+ const ls = c.split('\n');
2442
+ const hl = ls[h.line - 1];
2443
+ const bs = h.offset + hl.length + 1;
2444
+ let se = c.length;
2445
+ for (let j = i + 1; j < hs.length; j++) {
2446
+ if (STOP_H2_H3(hs[j].level)) {
2447
+ se = hs[j].offset - 1;
2448
+ break;
2449
+ }
2450
+ }
2451
+ const body = c.slice(bs, se);
2452
+ const { keep, archive } = transform(body);
2453
+ if (archive.length > 0) {
2454
+ sections.push({ section: sectionName, count: archive.length, lines: archive });
2455
+ return c.slice(0, bs) + keep.join('\n') + c.slice(se);
2456
+ }
2457
+ return c;
2458
+ }
2289
2459
  // Prune Decisions section: entries like "- [Phase N]: ..."
2290
- const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2291
- const decMatch = content.match(decisionPattern);
2292
- if (decMatch) {
2293
- const lines = decMatch[2].split('\n');
2294
- const keep = [];
2295
- const archive = [];
2296
- for (const line of lines) {
2460
+ content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => {
2461
+ const keep = [], archive = [];
2462
+ for (const line of body.split('\n')) {
2297
2463
  const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
2298
2464
  if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2299
2465
  archive.push(line);
@@ -2302,19 +2468,12 @@ function cmdStatePrune(cwd, options, raw) {
2302
2468
  keep.push(line);
2303
2469
  }
2304
2470
  }
2305
- if (archive.length > 0) {
2306
- sections.push({ section: 'Decisions', count: archive.length, lines: archive });
2307
- content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
2308
- }
2309
- }
2471
+ return { keep, archive };
2472
+ }, 'Decisions');
2310
2473
  // Prune Recently Completed section: entries mentioning phase numbers
2311
- const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2312
- const recMatch = content.match(recentPattern);
2313
- if (recMatch) {
2314
- const lines = recMatch[2].split('\n');
2315
- const keep = [];
2316
- const archive = [];
2317
- for (const line of lines) {
2474
+ content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => {
2475
+ const keep = [], archive = [];
2476
+ for (const line of body.split('\n')) {
2318
2477
  const phaseMatch = line.match(/Phase\s+(\d+)/i);
2319
2478
  if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2320
2479
  archive.push(line);
@@ -2323,20 +2482,13 @@ function cmdStatePrune(cwd, options, raw) {
2323
2482
  keep.push(line);
2324
2483
  }
2325
2484
  }
2326
- if (archive.length > 0) {
2327
- sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
2328
- content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
2329
- }
2330
- }
2485
+ return { keep, archive };
2486
+ }, 'Recently Completed');
2331
2487
  // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
2332
2488
  // or "[RESOLVED]" prefix) with a phase reference older than cutoff
2333
- const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2334
- const blockersMatch = content.match(blockersPattern);
2335
- if (blockersMatch) {
2336
- const lines = blockersMatch[2].split('\n');
2337
- const keep = [];
2338
- const archive = [];
2339
- for (const line of lines) {
2489
+ content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => {
2490
+ const keep = [], archive = [];
2491
+ for (const line of body.split('\n')) {
2340
2492
  const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
2341
2493
  const phaseMatch = line.match(/Phase\s+(\d+)/i);
2342
2494
  if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
@@ -2346,20 +2498,13 @@ function cmdStatePrune(cwd, options, raw) {
2346
2498
  keep.push(line);
2347
2499
  }
2348
2500
  }
2349
- if (archive.length > 0) {
2350
- sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
2351
- content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
2352
- }
2353
- }
2501
+ return { keep, archive };
2502
+ }, 'Blockers (resolved)');
2354
2503
  // Prune Performance Metrics table rows: keep only rows for phases > cutoff.
2355
2504
  // Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
2356
- const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2357
- const metricsMatch = content.match(metricsPattern);
2358
- if (metricsMatch) {
2359
- const sectionLines = metricsMatch[2].split('\n');
2360
- const keep = [];
2361
- const archive = [];
2362
- for (const line of sectionLines) {
2505
+ content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => {
2506
+ const keep = [], archive = [];
2507
+ for (const line of body.split('\n')) {
2363
2508
  // Table data row: starts with | followed by a number (phase)
2364
2509
  const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
2365
2510
  if (tableRowMatch) {
@@ -2376,11 +2521,8 @@ function cmdStatePrune(cwd, options, raw) {
2376
2521
  keep.push(line);
2377
2522
  }
2378
2523
  }
2379
- if (archive.length > 0) {
2380
- sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
2381
- content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`);
2382
- }
2383
- }
2524
+ return { keep, archive };
2525
+ }, 'Performance Metrics');
2384
2526
  return { newContent: content, archivedSections: sections };
2385
2527
  }
2386
2528
  if (dryRun) {
@@ -2501,50 +2643,64 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2501
2643
  updated.push('Last Activity Description');
2502
2644
  }
2503
2645
  // Update ## Current Position section
2504
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2505
- const positionMatch = body.match(positionPattern);
2506
- if (positionMatch) {
2507
- const header = positionMatch[1];
2508
- let posBody = positionMatch[2];
2509
- // Update Phase line to show COMPLETE
2510
- const newPhase = `Phase: ${currentPhase} — COMPLETE`;
2511
- if (/^Phase:/m.test(posBody)) {
2512
- posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2513
- }
2514
- else {
2515
- // Pipe-table format in Current Position (#1255)
2516
- // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
2517
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
2518
- if (replaced !== null)
2519
- posBody = replaced;
2520
- }
2521
- // Update Status line if present
2522
- const newStatus = `Status: Phase ${currentPhase} complete`;
2523
- if (/^Status:/m.test(posBody)) {
2524
- posBody = posBody.replace(/^Status:.*$/m, newStatus);
2525
- }
2526
- else {
2527
- // Pipe-table format in Current Position (#1255)
2528
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
2529
- if (replaced !== null)
2530
- posBody = replaced;
2531
- }
2532
- // Update Last activity line if present
2533
- const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
2534
- if (/^Last activity:/im.test(posBody)) {
2535
- posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2536
- }
2537
- else {
2538
- // Pipe-table format in Current Position (#1255)
2539
- // Value must match the inline branch (date + narrative), not bare date.
2540
- const activityValue = `${today} — Phase ${currentPhase} marked complete`;
2541
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2542
- ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2543
- if (replaced !== null)
2544
- posBody = replaced;
2646
+ // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
2647
+ // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i
2648
+ {
2649
+ const cpHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
2650
+ const cpIdx = cpHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
2651
+ if (cpIdx !== -1) {
2652
+ const cpH = cpHs[cpIdx];
2653
+ const cpBodyLines = body.split('\n');
2654
+ const cpHL = cpBodyLines[cpH.line - 1];
2655
+ const cpBodyStart = cpH.offset + cpHL.length + 1;
2656
+ let cpBodyEnd = body.length;
2657
+ for (let j = cpIdx + 1; j < cpHs.length; j++) {
2658
+ if (STOP_H2_PLUS(cpHs[j].level)) {
2659
+ cpBodyEnd = cpHs[j].offset - 1;
2660
+ break;
2661
+ }
2662
+ }
2663
+ let posBody = body.slice(cpBodyStart, cpBodyEnd);
2664
+ // Update Phase line to show COMPLETE
2665
+ const newPhase = `Phase: ${currentPhase} — COMPLETE`;
2666
+ if (/^Phase:/m.test(posBody)) {
2667
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2668
+ }
2669
+ else {
2670
+ // Pipe-table format in Current Position (#1255)
2671
+ // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
2672
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
2673
+ if (replaced !== null)
2674
+ posBody = replaced;
2675
+ }
2676
+ // Update Status line if present
2677
+ const newStatus = `Status: Phase ${currentPhase} complete`;
2678
+ if (/^Status:/m.test(posBody)) {
2679
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
2680
+ }
2681
+ else {
2682
+ // Pipe-table format in Current Position (#1255)
2683
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
2684
+ if (replaced !== null)
2685
+ posBody = replaced;
2686
+ }
2687
+ // Update Last activity line if present
2688
+ const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
2689
+ if (/^Last activity:/im.test(posBody)) {
2690
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2691
+ }
2692
+ else {
2693
+ // Pipe-table format in Current Position (#1255)
2694
+ // Value must match the inline branch (date + narrative), not bare date.
2695
+ const activityValue = `${today} — Phase ${currentPhase} marked complete`;
2696
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2697
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2698
+ if (replaced !== null)
2699
+ posBody = replaced;
2700
+ }
2701
+ body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
2702
+ updated.push('Current Position');
2545
2703
  }
2546
- body = body.replace(positionPattern, () => `${header}${posBody}`);
2547
- updated.push('Current Position');
2548
2704
  }
2549
2705
  return reassemble(body);
2550
2706
  }, cwd);