@opengsd/gsd-core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) 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 +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -39,6 +39,7 @@ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } =
39
39
  const state_document_cjs_1 = require("./state-document.cjs");
40
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
41
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
42
+ const validate_cjs_1 = require("./validate.cjs");
42
43
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
43
44
  'Progress',
44
45
  'Total Plans in Phase',
@@ -359,6 +360,7 @@ function cmdStateAdvancePlan(cwd, raw) {
359
360
  const deps = {
360
361
  clock: clock_cjs_1.realClock,
361
362
  progressProvider: () => null,
363
+ sourcePath: statePath,
362
364
  };
363
365
  let resultData;
364
366
  readModifyWriteStateMd(statePath, (content) => {
@@ -1150,6 +1152,38 @@ function matchSessionSection(body) {
1150
1152
  ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
1151
1153
  return section ? section.body : null;
1152
1154
  }
1155
+ /**
1156
+ * #2567: prevent a stale archive "Last activity:" line from overwriting a
1157
+ * newer frontmatter value. `stateExtractField` matches the first body
1158
+ * occurrence, which may be a historical line in an archive section. Unlike
1159
+ * Stopped At / Paused At (which canonically live in `## Session`), Last
1160
+ * Activity has no single canonical section — it appears in the preamble,
1161
+ * `## Configuration`, and `## Current Position` across STATE.md layouts, so a
1162
+ * section scope cannot reliably exclude archive copies. Guard the
1163
+ * information-losing direction instead: when the body-derived date is OLDER
1164
+ * than the existing frontmatter date, keep the existing value and its
1165
+ * description. Applied at both the write seam (syncStateFrontmatter) and the
1166
+ * read seam (cmdStateJson) so they agree. Date fields only — non-date values
1167
+ * pass through unchanged.
1168
+ */
1169
+ function preferNewerLastActivity(existingFm, derivedFm) {
1170
+ if (!existingFm)
1171
+ return;
1172
+ const exRaw = existingFm['last_activity'];
1173
+ const derRaw = derivedFm['last_activity'];
1174
+ if (typeof exRaw !== 'string' || typeof derRaw !== 'string')
1175
+ return;
1176
+ const exDate = exRaw.slice(0, 10);
1177
+ const derDate = derRaw.slice(0, 10);
1178
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
1179
+ return;
1180
+ if (derDate < exDate) {
1181
+ derivedFm['last_activity'] = exRaw;
1182
+ if (existingFm['last_activity_desc'] !== undefined) {
1183
+ derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1184
+ }
1185
+ }
1186
+ }
1153
1187
  function parseProsePhaseField(value) {
1154
1188
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
1155
1189
  // module holds no independent prose phase-id regex. Drives #2111 — the
@@ -1180,7 +1214,9 @@ function cmdStateSnapshot(cwd, raw) {
1180
1214
  // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a
1181
1215
  // body table cell containing **Status:** Y cannot shadow the authoritative
1182
1216
  // frontmatter value. Mirrors the fix in sdk/src/query/state.ts.
1183
- const fm = extractFrontmatter(content);
1217
+ // Pass statePath so a truncated STATE.md is named in the #1882 diagnostic rather than
1218
+ // reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
1219
+ const fm = extractFrontmatter(content, statePath);
1184
1220
  const body = stripFrontmatter(content);
1185
1221
  // Helper: return frontmatter scalar value when present and non-empty.
1186
1222
  // Accepts strings, numbers, and booleans — coercing non-string primitives to
@@ -1363,8 +1399,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
1363
1399
  const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1364
1400
  const lastActivity = proseLastActivity.date ?? rawLastActivity;
1365
1401
  const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
1366
- // Bug #2444: scope Stopped At extraction to the ## Session section so that
1367
- // historical "Stopped at:" prose elsewhere in the body (e.g. in a
1402
+ // Bug #2444 / #2567: scope Stopped At AND Paused At extraction to the
1403
+ // ## Session section so historical prose elsewhere in the body (e.g. in a
1368
1404
  // Session Continuity Archive section) never overwrites the current value.
1369
1405
  // Fall back to full-body search only when no ## Session section exists.
1370
1406
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
@@ -1372,7 +1408,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
1372
1408
  const sessionSectionMatch = matchSessionSection(bodyContent);
1373
1409
  const sessionBodyScope = sessionSectionMatch ?? bodyContent;
1374
1410
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
1375
- const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
1411
+ // #2567: Paused At is a session field — scope it to ## Session too so a
1412
+ // stale "Paused At:" line in an archive section cannot overwrite the value.
1413
+ const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
1376
1414
  let milestone = null;
1377
1415
  let milestoneName = null;
1378
1416
  if (cwd) {
@@ -1498,10 +1536,20 @@ function buildStateFrontmatter(bodyContent, cwd) {
1498
1536
  const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`, 'mi');
1499
1537
  milestoneBounded = versionedHeading.test(roadmapRaw);
1500
1538
  }
1539
+ // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
1540
+ // at all — only Phase headings) from a MILESTONED-but-unbounded one
1541
+ // (milestone/version headings exist but the asserted one isn't among them).
1542
+ // On a flat roadmap the whole-doc count is correct (no sibling milestones to
1543
+ // conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
1544
+ // so fall back to phaseDirs.length.
1545
+ const hasMilestoneSectioning = roadmapRaw !== null
1546
+ && /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
1547
+ const safeToUseRoadmapCount = milestoneBounded
1548
+ || (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
1501
1549
  return {
1502
- totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
1503
- ? phaseDirs.length
1504
- : Math.max(phaseDirs.length, roadmapPhaseCount),
1550
+ totalPhases: safeToUseRoadmapCount
1551
+ ? Math.max(phaseDirs.length, roadmapPhaseCount)
1552
+ : phaseDirs.length,
1505
1553
  milestoneBounded,
1506
1554
  completedPhases: diskCompletedPhases,
1507
1555
  totalPlans: diskTotalPlans,
@@ -1578,10 +1626,12 @@ function buildStateFrontmatter(bodyContent, cwd) {
1578
1626
  fm['progress'] = progress;
1579
1627
  return fm;
1580
1628
  }
1581
- function syncStateFrontmatter(content, cwd) {
1629
+ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1582
1630
  // Read existing frontmatter BEFORE stripping — it may contain values
1583
1631
  // that the body no longer has (e.g., Status field removed by an agent).
1584
- const existingFm = extractFrontmatter(content);
1632
+ // `cwd` already identifies the workspace this content came from, so the STATE.md path is
1633
+ // derivable here without widening the signature (#1882).
1634
+ const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
1585
1635
  const body = stripFrontmatter(content);
1586
1636
  const derivedFm = buildStateFrontmatter(body, cwd);
1587
1637
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
@@ -1665,6 +1715,23 @@ function syncStateFrontmatter(content, cwd) {
1665
1715
  derivedFm[key] = existingFm[key];
1666
1716
  }
1667
1717
  }
1718
+ // #2567: guard the information-losing direction — a stale archive
1719
+ // "Last activity:" line must not overwrite a newer frontmatter value.
1720
+ preferNewerLastActivity(existingFm, derivedFm);
1721
+ // #2736: intent-first override, applied last. A transition adapter that
1722
+ // already holds the exact value (completePhase's next-phase display name,
1723
+ // beginPhase's phase name) passes it here, so the body-prose re-derivation
1724
+ // above — which is lossy by construction for names containing a
1725
+ // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
1726
+ // the final word on a field the transition just resolved. The prose parser
1727
+ // remains the fallback for genuinely unknown prose only.
1728
+ if (authoritativeFm) {
1729
+ for (const [key, value] of Object.entries(authoritativeFm)) {
1730
+ if (typeof value === 'string' && value.trim().length > 0) {
1731
+ derivedFm[key] = value;
1732
+ }
1733
+ }
1734
+ }
1668
1735
  const yamlStr = reconstructFrontmatter(derivedFm);
1669
1736
  return `---\n${yamlStr}\n---\n\n${body}`;
1670
1737
  }
@@ -1960,7 +2027,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1960
2027
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1961
2028
  // Snapshot the existing progress block BEFORE the transform so we can
1962
2029
  // restore it when resync is false.
1963
- const preFm = resync ? null : extractFrontmatter(content);
2030
+ const preFm = resync ? null : extractFrontmatter(content, statePath);
1964
2031
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
1965
2032
  // we can detect whether THIS write changed them. syncStateFrontmatter
1966
2033
  // re-derives frontmatter status/stopped_at from the body on every write;
@@ -1972,7 +2039,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1972
2039
  // Strip frontmatter before calling stateExtractField so the YAML `status:`
1973
2040
  // key in the frontmatter block cannot shadow the body field we are tracking.
1974
2041
  const preBody = stripFrontmatter(content);
1975
- const preFmSnapshot = extractFrontmatter(content);
2042
+ const preFmSnapshot = extractFrontmatter(content, statePath);
1976
2043
  const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1977
2044
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1978
2045
  // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
@@ -1999,7 +2066,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1999
2066
  if (modified === content) {
2000
2067
  return;
2001
2068
  }
2002
- let synced = syncStateFrontmatter(modified, cwd);
2069
+ let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm);
2003
2070
  // Post-transform body source fields used for the delta comparison (#1230).
2004
2071
  // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
2005
2072
  // Strip frontmatter so the YAML status key cannot shadow the body field we are tracking.
@@ -2020,7 +2087,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2020
2087
  // one policy source, not three drifting encodings. Behavior-identical to
2021
2088
  // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
2022
2089
  // already claimed shipped.
2023
- const postFm = extractFrontmatter(synced);
2090
+ const postFm = extractFrontmatter(synced, statePath);
2024
2091
  const preservation = applyStatePreservation({
2025
2092
  preFm, postFm, preFmSnapshot, resync,
2026
2093
  deriveProgressKeys: options?.deriveProgressKeys === true,
@@ -2028,7 +2095,21 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2028
2095
  preBodyStoppedAt, postBodyStoppedAt,
2029
2096
  preBodyPhaseSource, postBodyPhaseSource,
2030
2097
  });
2031
- if (preservation.mutated) {
2098
+ // #2736: re-assert the intent-first values AFTER preservation. On STATE.md
2099
+ // layouts with no body `Phase:` line, both phase-source snapshots are null
2100
+ // (equal), so the #1695 restore fires and would put the stale pre-transition
2101
+ // name back over the authoritative one. Intent beats both the prose
2102
+ // re-derivation and the curated restore — the transition just resolved it.
2103
+ let authoritativeReasserted = false;
2104
+ if (options?.authoritativeFm) {
2105
+ for (const [key, value] of Object.entries(options.authoritativeFm)) {
2106
+ if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
2107
+ preservation.postFm[key] = value;
2108
+ authoritativeReasserted = true;
2109
+ }
2110
+ }
2111
+ }
2112
+ if (preservation.mutated || authoritativeReasserted) {
2032
2113
  const yamlStr = reconstructFrontmatter(preservation.postFm);
2033
2114
  const body = stripFrontmatter(synced);
2034
2115
  synced = `---\n${yamlStr}\n---\n\n${body}`;
@@ -2046,7 +2127,7 @@ function cmdStateJson(cwd, raw) {
2046
2127
  return;
2047
2128
  }
2048
2129
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2049
- const existingFm = extractFrontmatter(content);
2130
+ const existingFm = extractFrontmatter(content, statePath);
2050
2131
  const body = stripFrontmatter(content);
2051
2132
  // Always rebuild from body + disk so progress counters reflect current state.
2052
2133
  // Returning cached frontmatter directly causes stale percent/completed_plans
@@ -2080,6 +2161,10 @@ function cmdStateJson(cwd, raw) {
2080
2161
  if (existingFm && (0, state_document_cjs_1.shouldPreserveExistingProgress)(existingFm['progress'], built['progress'])) {
2081
2162
  built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2082
2163
  }
2164
+ // #2567: guard the information-losing direction — a stale archive
2165
+ // "Last activity:" line must not surface as the current value. Mirrors the
2166
+ // syncStateFrontmatter guard so the read path agrees with the write path.
2167
+ preferNewerLastActivity(existingFm, built);
2083
2168
  output(built, raw, JSON.stringify(built, null, 2));
2084
2169
  }
2085
2170
  /**
@@ -2109,13 +2194,30 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2109
2194
  const deps = {
2110
2195
  clock: clock_cjs_1.realClock,
2111
2196
  progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
2197
+ sourcePath: statePath,
2198
+ };
2199
+ // #2736: the transition holds the exact display name; without this the
2200
+ // post-transform sync re-derives current_phase_name from the freshly
2201
+ // written `Phase: N (Name) — EXECUTING` line, which truncates any name
2202
+ // that itself contains a parenthetical. The #1695 delta-gate preservation
2203
+ // still runs after the sync; the override is re-asserted after it inside
2204
+ // readModifyWriteStateMd for layouts with no body `Phase:` line.
2205
+ const rmwOptions = {
2206
+ authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
2112
2207
  };
2113
2208
  let updated = [];
2114
2209
  readModifyWriteStateMd(statePath, (content) => {
2115
2210
  const result = transitionCore(content, intent, deps);
2116
2211
  updated = result.updated;
2212
+ // #3127 resume: the core preserved the mid-flight Current Phase Name, so
2213
+ // the intent-first override must not fire — it would drift frontmatter
2214
+ // away from the preserved body value. Dropping it here is safe because
2215
+ // readModifyWriteStateMd consults options only after this callback returns.
2216
+ if (result.data?.['resumed']) {
2217
+ delete rmwOptions.authoritativeFm;
2218
+ }
2117
2219
  return result.content;
2118
- }, cwd);
2220
+ }, cwd, rmwOptions);
2119
2221
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
2120
2222
  }
2121
2223
  /**
@@ -2361,6 +2463,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2361
2463
  const deps = {
2362
2464
  clock: clock_cjs_1.realClock,
2363
2465
  progressProvider: () => null,
2466
+ sourcePath: statePath,
2364
2467
  };
2365
2468
  let updated = [];
2366
2469
  readModifyWriteStateMd(statePath, (content) => {
@@ -2368,7 +2471,10 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2368
2471
  updated = result.updated;
2369
2472
  return result.content;
2370
2473
  }, cwd, { resync: false, deriveProgressKeys: true });
2371
- output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2474
+ const result = updated.length === 0
2475
+ ? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
2476
+ : { updated, phase: phaseNumber, plan_count: planCount };
2477
+ output(result, raw, updated.length > 0 ? 'true' : 'false');
2372
2478
  }
2373
2479
  /**
2374
2480
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -2390,7 +2496,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2390
2496
  // milestoneSwitch rebuilds frontmatter directly and must not run the
2391
2497
  // steady-state syncStateFrontmatter post-sync.
2392
2498
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
2393
- const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null };
2499
+ const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null, sourcePath: statePath };
2394
2500
  const lockPath = acquireStateLock(statePath);
2395
2501
  try {
2396
2502
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
@@ -2413,6 +2519,14 @@ function cmdStateValidate(cwd, raw) {
2413
2519
  return;
2414
2520
  }
2415
2521
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2522
+ // #2701: fail loud on NUL/binary corruption before drift checks. A corrupt
2523
+ // STATE.md otherwise validates as clean and is silently skipped by recursive
2524
+ // searchers downstream, reading as "absent" rather than "corrupt."
2525
+ const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
2526
+ if (encErr) {
2527
+ output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
2528
+ return;
2529
+ }
2416
2530
  const warnings = [];
2417
2531
  const drift = {};
2418
2532
  const status = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
@@ -2590,7 +2704,7 @@ function cmdStateSync(cwd, options, raw) {
2590
2704
  // set — leave Progress untouched (percent=null) rather than silently writing
2591
2705
  // fallback-derived wrong values. Projects without a milestone version (the common
2592
2706
  // sync-test shape) are unaffected: the gate only fires when a version is asserted.
2593
- const fmVersion = extractFrontmatter(content).milestone;
2707
+ const fmVersion = extractFrontmatter(content, statePath).milestone;
2594
2708
  const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
2595
2709
  let milestoneBounded = true;
2596
2710
  if (versionStr !== null && syncRoadmapRaw !== null) {
@@ -2648,7 +2762,7 @@ function cmdStatePrune(cwd, options, raw) {
2648
2762
  // the explicit `Current Phase` field are unambiguous, so they stay document-wide;
2649
2763
  // the shared extractor is not narrowed for any other caller.
2650
2764
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2651
- const fm = extractFrontmatter(rawState);
2765
+ const fm = extractFrontmatter(rawState, statePath);
2652
2766
  const body = stripFrontmatter(rawState);
2653
2767
  // Mirror buildStateFrontmatter's fmScalar: only string/number/boolean
2654
2768
  // frontmatter scalars are usable (an object/array `current_phase` is ignored,
@@ -2789,6 +2903,12 @@ function cmdStateRebuild(cwd, options, raw) {
2789
2903
  progressProvider: () => null,
2790
2904
  clock: clock_cjs_1.realClock,
2791
2905
  phaseInventoryProvider,
2906
+ // Without this, `state rebuild --dry-run` reported a truncated STATE.md anonymously: the
2907
+ // write path is named only because readModifyWriteStateMd parses with the path first, and
2908
+ // the dry-run branch reads the file directly and never does. Dry-run is the read-only mode
2909
+ // an operator reaches for first when they suspect corruption, so it is the one that most
2910
+ // needs to name the file (#1882).
2911
+ sourcePath: statePath,
2792
2912
  };
2793
2913
  const runRebuild = (content) => transitionCore(content, { kind: 'rebuild' }, deps);
2794
2914
  const emitVerboseLog = (log) => {
@@ -2887,7 +3007,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2887
3007
  const currentPhase = resolvedPhase;
2888
3008
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2889
3009
  // cannot shadow the body Status field (pipe-table or inline).
2890
- const existingFm = extractFrontmatter(content);
3010
+ const existingFm = extractFrontmatter(content, statePath);
2891
3011
  const hasFrontmatter = Object.keys(existingFm).length > 0;
2892
3012
  let body = stripFrontmatter(content);
2893
3013
  const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
@@ -194,9 +194,10 @@ function evaluateUatPassed(phaseFullDir, opts) {
194
194
  // ── Process UAT files ──────────────────────────────────────────────────────
195
195
  for (const file of uatFileNames) {
196
196
  uatFiles.push(file);
197
+ const uatFilePath = node_path_1.default.join(phaseFullDir, file);
197
198
  let raw = '';
198
199
  try {
199
- raw = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
200
+ raw = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
200
201
  }
201
202
  catch {
202
203
  blockers.push(`${file}: could not read file`);
@@ -210,7 +211,7 @@ function evaluateUatPassed(phaseFullDir, opts) {
210
211
  if (unterminatedFence || unterminatedComment) {
211
212
  blockers.push(`${file}: malformed markdown (unterminated fence or comment)`);
212
213
  }
213
- const fm = extractFrontmatter(raw);
214
+ const fm = extractFrontmatter(raw, uatFilePath);
214
215
  // File-level frontmatter status check
215
216
  if (fm['status'] && BLOCKING_UAT_FM_STATUSES.has(fm['status'])) {
216
217
  blockers.push(`${file}: frontmatter status=${fm['status']}`);
@@ -240,15 +241,16 @@ function evaluateUatPassed(phaseFullDir, opts) {
240
241
  let hasPassingVerification = false;
241
242
  for (const file of verFileNames) {
242
243
  verificationFiles.push(file);
244
+ const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
243
245
  let raw = '';
244
246
  try {
245
- raw = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
247
+ raw = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
246
248
  }
247
249
  catch {
248
250
  blockers.push(`${file}: could not read verification file`);
249
251
  continue;
250
252
  }
251
- const vfm = extractFrontmatter(raw);
253
+ const vfm = extractFrontmatter(raw, verificationFilePath);
252
254
  const vStatus = vfm['status'];
253
255
  if (vStatus && BLOCKING_VERIFICATION_FM_STATUSES.has(vStatus)) {
254
256
  blockers.push(`${file}: verification status=${vStatus}`);
@@ -63,7 +63,8 @@ function cmdAuditUat(cwd, raw) {
63
63
  const files = node_fs_1.default.readdirSync(phaseDir);
64
64
  // Process UAT files
65
65
  for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
66
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, file), 'utf-8');
66
+ const uatFilePath = node_path_1.default.join(phaseDir, file);
67
+ const content = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
67
68
  const items = parseUatItems(content);
68
69
  if (items.length > 0) {
69
70
  results.push({
@@ -72,17 +73,18 @@ function cmdAuditUat(cwd, raw) {
72
73
  file,
73
74
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
74
75
  type: 'uat',
75
- status: (extractFrontmatter(content).status || 'unknown'),
76
+ status: (extractFrontmatter(content, uatFilePath).status || 'unknown'),
76
77
  items,
77
78
  });
78
79
  }
79
80
  }
80
81
  // Process VERIFICATION files
81
82
  for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
82
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, file), 'utf-8');
83
- const status = extractFrontmatter(content).status || 'unknown';
83
+ const verificationFilePath = node_path_1.default.join(phaseDir, file);
84
+ const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
85
+ const status = extractFrontmatter(content, verificationFilePath).status || 'unknown';
84
86
  if (status === 'human_needed' || status === 'gaps_found') {
85
- const items = parseVerificationItems(content, status);
87
+ const items = parseVerificationItems(content, status, verificationFilePath);
86
88
  if (items.length > 0) {
87
89
  results.push({
88
90
  phase: phaseNum,
@@ -624,7 +626,7 @@ function rawGapEntryText(entryLines) {
624
626
  .trim();
625
627
  }
626
628
  // ─── parseVerificationItems ───────────────────────────────────────────────────
627
- function parseVerificationItems(content, status) {
629
+ function parseVerificationItems(content, status, sourcePath) {
628
630
  const items = [];
629
631
  if (status === 'human_needed') {
630
632
  // #2286: the frontmatter's structured `human_verification:` YAML array
@@ -633,7 +635,7 @@ function parseVerificationItems(content, status) {
633
635
  // whose frontmatter declares the array doesn't require any particular
634
636
  // `## Human Verification` body shape at all. An absent or empty array
635
637
  // (length 0) falls back to the body scan unchanged.
636
- const frontmatter = extractFrontmatter(content);
638
+ const frontmatter = extractFrontmatter(content, sourcePath);
637
639
  const humanVerification = frontmatter.human_verification;
638
640
  if (Array.isArray(humanVerification) && humanVerification.length > 0) {
639
641
  humanVerification.forEach((entry, idx) => {
@@ -66,8 +66,8 @@ function classifyElement(text) {
66
66
  */
67
67
  exports.UI_TAXONOMY = [
68
68
  { id: 'empty', name: 'Empty / no data', elements: ['form', 'list-collection', 'media'], consideration: 'What is shown when there is no data — zero items, an unfilled form, or absent media?' },
69
- { id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
70
- { id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
69
+ { id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
70
+ { id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
71
71
  { id: 'populated', name: 'Populated / happy path', elements: ['list-collection', 'media'], consideration: 'What does the normal populated (happy-path) state look like at a typical volume of content?' },
72
72
  { id: 'partial', name: 'Partial / incomplete', elements: ['form', 'list-collection'], consideration: 'What is shown for partial or incomplete data — some fields or rows present, others missing?' },
73
73
  { id: 'overflow', name: 'Overflow / truncation', elements: ['list-collection', 'nav', 'static-content'], consideration: 'What happens when content exceeds its container — scroll, clip, wrap, or truncate?' },