@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

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 (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -0,0 +1,1603 @@
1
+ "use strict";
2
+ /**
3
+ * STATE.md Transition Module — ADR-1769.
4
+ *
5
+ * Phase 1 substrate: field-classification table, section constants, the pure
6
+ * `transitionCore` dispatch, and the `beginPhase` intent (migrating
7
+ * `cmdStateBeginPhase` in state.cts onto this seam).
8
+ *
9
+ * Sibling/super-module of the STATE.md Document Module (state-document.cjs):
10
+ * consumes its `stateExtractField` / `stateReplaceField` primitives. Body
11
+ * section headings live as constants here (single writer after migration).
12
+ *
13
+ * Pure core + injected I/O (ADR-1769 §3): the exported `transitionCore` is a
14
+ * pure function `(content, intent, deps) → result`; adapters that own locks,
15
+ * file I/O, and the disk-scan wrap it.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
19
+ exports.getFieldClassification = getFieldClassification;
20
+ exports.applyStatePreservation = applyStatePreservation;
21
+ exports.transitionCore = transitionCore;
22
+ exports.sliceCurrentPositionSection = sliceCurrentPositionSection;
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const frontmatter = require("./frontmatter.cjs");
25
+ const state_document_cjs_1 = require("./state-document.cjs");
26
+ const state_document_cjs_2 = require("./state-document.cjs");
27
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
+ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const phaseIdMod = require("./phase-id.cjs");
31
+ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
32
+ const { escapeRegex } = phaseIdMod;
33
+ // Stop predicate for section-body slicing: a level-2+ heading ends the section.
34
+ const STOP_H2_PLUS = (lv) => lv >= 2;
35
+ /**
36
+ * Single source of truth for "which fields win when frontmatter and body
37
+ * disagree". Transitions declare which body fields they touch; the core
38
+ * consults the table to apply the preservation policy uniformly.
39
+ *
40
+ * Adding a new STATE.md field = one row here, not 9 transition edits.
41
+ *
42
+ * Field set verified against `buildStateFrontmatter` (state.cts:1474) — every
43
+ * frontmatter key emitted there has a row here.
44
+ *
45
+ * Frozen null-prototype object: prevents prototype-pollution lookups
46
+ * (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
47
+ * function). Use `getFieldClassification()` for lookups.
48
+ */
49
+ exports.FIELD_CLASSIFICATION = Object.freeze(Object.assign(Object.create(null), {
50
+ // Schema
51
+ gsd_state_version: { source: 'free', preservation: 'derive' },
52
+ // Milestone (external — from ROADMAP.md)
53
+ milestone: { source: 'external', preservation: 'preserve-if-placeholder' },
54
+ milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' },
55
+ // Phase / plan position (body-derived)
56
+ current_phase: { source: 'body', preservation: 'preserve-when-unchanged' },
57
+ current_phase_name: { source: 'curated', preservation: 'preserve-always' }, // #1743, #1695
58
+ current_plan: { source: 'body', preservation: 'preserve-when-unchanged' },
59
+ // Status / lifecycle (body-derived; #1230 delta heuristic applies)
60
+ status: { source: 'body', preservation: 'preserve-when-unchanged' },
61
+ stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' },
62
+ paused_at: { source: 'body', preservation: 'preserve-when-unchanged' },
63
+ // Activity log
64
+ last_updated: { source: 'free', preservation: 'derive' }, // realClock.nowIso()
65
+ last_activity: { source: 'body', preservation: 'derive' }, // always refresh on transition
66
+ last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' },
67
+ // Progress block (disk-derived, except the curated progress ratchet)
68
+ progress: { source: 'curated', preservation: 'preserve-always' }, // #3242, #1446
69
+ 'progress.total_phases': { source: 'disk', preservation: 'derive' },
70
+ 'progress.completed_phases': { source: 'disk', preservation: 'derive' },
71
+ 'progress.total_plans': { source: 'disk', preservation: 'derive' },
72
+ 'progress.completed_plans': { source: 'disk', preservation: 'derive' },
73
+ 'progress.percent': { source: 'disk', preservation: 'derive' },
74
+ }));
75
+ /**
76
+ * Own-property classification lookup. Returns `null` for unknown fields
77
+ * (including inherited prototype methods like `toString`/`valueOf`).
78
+ */
79
+ function getFieldClassification(field) {
80
+ if (!Object.prototype.hasOwnProperty.call(exports.FIELD_CLASSIFICATION, field))
81
+ return null;
82
+ return exports.FIELD_CLASSIFICATION[field];
83
+ }
84
+ /**
85
+ * Pure, table-driven post-sync preservation. Mutates `postFm` in place to
86
+ * mirror the pre-consolidation inline block (which also mutated in place) and
87
+ * returns whether any field was restored.
88
+ */
89
+ function applyStatePreservation(input) {
90
+ const { preFm, postFm, preFmSnapshot, resync } = input;
91
+ let mutated = false;
92
+ // Curated progress ratchet (#3242/#1446; closes the #1264 class by routing
93
+ // the policy through the table). Restored only when the table says preserve-
94
+ // always AND this transition is not re-deriving from disk (!resync). sync and
95
+ // the lifecycle transitions pass resync=true and recompute; patch/update and
96
+ // body-only writes pass resync=false and keep the curated counters.
97
+ const progressCls = getFieldClassification('progress');
98
+ if (progressCls !== null &&
99
+ progressCls.preservation === 'preserve-always' &&
100
+ !resync &&
101
+ preFm &&
102
+ preFm['progress']) {
103
+ postFm['progress'] = preFm['progress'];
104
+ mutated = true;
105
+ }
106
+ // status — #1230 body-delta heuristic. Table: preserve-when-unchanged.
107
+ const statusCls = getFieldClassification('status');
108
+ if (statusCls !== null &&
109
+ statusCls.preservation === 'preserve-when-unchanged' &&
110
+ input.postBodyStatus === input.preBodyStatus &&
111
+ typeof preFmSnapshot['status'] === 'string' &&
112
+ preFmSnapshot['status'].length > 0 &&
113
+ preFmSnapshot['status'] !== 'unknown' &&
114
+ postFm['status'] !== preFmSnapshot['status']) {
115
+ postFm['status'] = preFmSnapshot['status'];
116
+ mutated = true;
117
+ }
118
+ // stopped_at — same #1230 body-delta heuristic. Table: preserve-when-unchanged.
119
+ const stoppedCls = getFieldClassification('stopped_at');
120
+ if (stoppedCls !== null &&
121
+ stoppedCls.preservation === 'preserve-when-unchanged' &&
122
+ input.postBodyStoppedAt === input.preBodyStoppedAt &&
123
+ typeof preFmSnapshot['stopped_at'] === 'string' &&
124
+ preFmSnapshot['stopped_at'].length > 0 &&
125
+ postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
126
+ postFm['stopped_at'] = preFmSnapshot['stopped_at'];
127
+ mutated = true;
128
+ }
129
+ // current_phase_name — curated (#1743/#1695). Table: preserve-always.
130
+ const phaseNameCls = getFieldClassification('current_phase_name');
131
+ if (phaseNameCls !== null &&
132
+ phaseNameCls.preservation === 'preserve-always' &&
133
+ input.postBodyPhaseSource === input.preBodyPhaseSource &&
134
+ typeof preFmSnapshot['current_phase_name'] === 'string' &&
135
+ preFmSnapshot['current_phase_name'].length > 0 &&
136
+ postFm['current_phase_name'] !== preFmSnapshot['current_phase_name']) {
137
+ postFm['current_phase_name'] = preFmSnapshot['current_phase_name'];
138
+ mutated = true;
139
+ }
140
+ return { postFm, mutated };
141
+ }
142
+ // ----------------------------------------------------------------------------
143
+ // Body section constants (ADR-1769 §6 — single writer after migration)
144
+ // ----------------------------------------------------------------------------
145
+ /**
146
+ * Top-level STATE.md section headings (H2). Aligned byte-for-byte with the
147
+ * canonical template at `gsd-core/templates/state.md`. Sub-headings (H3) like
148
+ * `### Decisions` / `### Pending Todos` / `### Blockers/Concerns` live under
149
+ * `## Accumulated Context` and are not mutated by any Phase 1–7 transition;
150
+ * they will be added here if a future transition needs them.
151
+ *
152
+ * Verified against `gsd-core/templates/state.md` (codex Phase 1 review).
153
+ */
154
+ exports.STATE_MD_SECTIONS = {
155
+ projectReference: '## Project Reference',
156
+ currentPosition: '## Current Position',
157
+ performanceMetrics: '## Performance Metrics',
158
+ accumulatedContext: '## Accumulated Context',
159
+ deferredItems: '## Deferred Items',
160
+ sessionContinuity: '## Session Continuity',
161
+ };
162
+ // ----------------------------------------------------------------------------
163
+ // transitionCore — pure dispatch (ADR-1769 §3)
164
+ // ----------------------------------------------------------------------------
165
+ /**
166
+ * Pure transition core. `(content, intent, deps) → result`.
167
+ *
168
+ * Discriminated-union dispatch via plain `switch` (ADR-1769 §2.7 Kernighan's
169
+ * Law: debuggability over conciseness; the substrate sets the pattern).
170
+ *
171
+ * Phases 2–7 add cases for the remaining 9 intent kinds. A missing case is
172
+ * a compile-time error (the function would not return on that path).
173
+ */
174
+ function transitionCore(content, intent, deps) {
175
+ switch (intent.kind) {
176
+ case 'beginPhase':
177
+ return beginPhaseCore(content, intent, deps);
178
+ case 'advancePlan':
179
+ return advancePlanCore(content, deps);
180
+ case 'completePhase':
181
+ return completePhaseCore(content, intent, deps);
182
+ case 'plannedPhase':
183
+ return plannedPhaseCore(content, intent, deps);
184
+ case 'milestoneSwitch':
185
+ return milestoneSwitchCore(content, intent, deps);
186
+ case 'milestoneComplete':
187
+ return milestoneCompleteCore(content, intent, deps);
188
+ case 'patch':
189
+ return patchCore(content, intent);
190
+ case 'update':
191
+ return updateCore(content, intent);
192
+ case 'prune':
193
+ return pruneCore(content, intent);
194
+ case 'sync':
195
+ return syncCore(content, intent, deps);
196
+ case 'rebuild':
197
+ return rebuildCore(content, intent, deps);
198
+ }
199
+ }
200
+ // ----------------------------------------------------------------------------
201
+ // beginPhase — intent implementation (Phase 1)
202
+ // ----------------------------------------------------------------------------
203
+ /**
204
+ * Apply a `beginPhase` transition to STATE.md content.
205
+ *
206
+ * Phase 1 scope (this file): the Status field update only. Subsequent
207
+ * behaviors land via RED-GREEN cycles per the ADR-1769 migration plan:
208
+ * - Current Phase, Current Phase Name, Current Plan, Total Plans
209
+ * - Current Position section mutation
210
+ * - Idempotency guard (#3127)
211
+ * - Resume vs first-time branching
212
+ * - #1255 / #1257 format-detection parity
213
+ *
214
+ * Adapters that acquire the STATE.md lock and call this core live in
215
+ * state.cts and consume the existing `readModifyWriteStateMd` post-sync
216
+ * machinery (preserves the #1230 delta heuristic without re-implementing it).
217
+ */
218
+ function beginPhaseCore(content, intent, deps) {
219
+ const updated = [];
220
+ // #1255: body-field replacements operate on body only (frontmatter stripped),
221
+ // not on the full content. The YAML `status:` key matches `^Status:\s*`
222
+ // before the body pipe-table row if full content is passed.
223
+ const existingFm = extractFrontmatter(content);
224
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
225
+ let body = stripFrontmatter(content);
226
+ const reassemble = (b) => hasFrontmatter
227
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
228
+ : b;
229
+ const today = deps.clock.today();
230
+ // Consult the field-classification table for the frontmatter keys this
231
+ // transition touches (codex Phase 1 review: "table not consulted by
232
+ // transitionCore"). The table tracks FRONTMATTER keys (lowercase: `status`,
233
+ // `current_phase`, `last_activity`); body field names like `Status` /
234
+ // `Current Phase` are aliases and aren't enforced here — they're driven by
235
+ // the first-time/resume branching below, which encodes the same rules.
236
+ // Phase 2+ will dispatch preservation based on this lookup.
237
+ for (const fmKey of ['status', 'current_phase', 'current_plan', 'last_activity']) {
238
+ const cls = getFieldClassification(fmKey);
239
+ if (cls === null) {
240
+ throw new Error(`transitionCore beginPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
241
+ `add a row per ADR-1769 §4 before touching it.`);
242
+ }
243
+ }
244
+ // Helper: try to replace a body field; push to `updated` on success.
245
+ // Body field names (Title Case: 'Status', 'Current Phase') are not in the
246
+ // table — they're body-side aliases of classified frontmatter keys.
247
+ const tryField = (name, value) => {
248
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(body, name, value);
249
+ if (replaced !== null) {
250
+ body = replaced;
251
+ updated.push(name);
252
+ }
253
+ };
254
+ // #3127 idempotency guard: if Status already contains "Executing Phase N" for
255
+ // the current phase number, this is a resume (e.g. --wave N continue). Skip
256
+ // the first-time-only fields so mid-flight state (Current Plan, Total Plans,
257
+ // Current Phase Name, Last Activity Description) is preserved.
258
+ // Extract from body (not full content) so the YAML `status:` key cannot
259
+ // shadow the body Status field (#1255).
260
+ const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
261
+ const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
262
+ // Status update (applies on both first-time and resume — Status is always refreshed).
263
+ tryField('Status', `Executing Phase ${intent.phaseNumber}`);
264
+ // Last Activity date — safe to refresh on resume (tracks when execute-phase ran).
265
+ tryField('Last Activity', today);
266
+ if (!isAlreadyExecuting) {
267
+ // First-time execution: set all progress fields.
268
+ tryField('Last Activity Description', `Phase ${intent.phaseNumber} execution started`);
269
+ tryField('Current Phase', String(intent.phaseNumber));
270
+ if (intent.phaseName) {
271
+ tryField('Current Phase Name', intent.phaseName);
272
+ }
273
+ tryField('Current Plan', '1');
274
+ if (intent.planCount) {
275
+ tryField('Total Plans in Phase', String(intent.planCount));
276
+ }
277
+ // **Current focus:** body text line (#1104).
278
+ const focusLabel = intent.phaseName
279
+ ? `Phase ${intent.phaseNumber} — ${intent.phaseName}`
280
+ : `Phase ${intent.phaseNumber}`;
281
+ const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
282
+ if (focusPattern.test(body)) {
283
+ body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
284
+ updated.push('Current focus');
285
+ }
286
+ // ## Current Position section mutation (#1104, #1365).
287
+ // ADR-1372 T6: tokenizeHeadings + offset splicing (replaceSection adoption
288
+ // deferred to a later phase). Mirrors state.cts:2261-2324 byte-for-behaviour.
289
+ body = mutateCurrentPositionFirstTime(body, intent, today, updated);
290
+ }
291
+ else {
292
+ // Resume path: only update Last activity timestamp in Current Position
293
+ // (do not touch Plan:, Phase:, Status:, stopped_at, progress.percent).
294
+ body = mutateCurrentPositionResume(body, intent, today, updated);
295
+ }
296
+ return { content: reassemble(body), updated };
297
+ }
298
+ /**
299
+ * Find the `## Current Position` section, return its `{start, end}` byte
300
+ * offsets in `body` (end is exclusive — first byte of the next section or
301
+ * body.length). Returns `null` when the section is absent.
302
+ *
303
+ * ADR-1372 T6: tokenizeHeadings-based locator (fence-aware).
304
+ */
305
+ function locateCurrentPosition(body) {
306
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
307
+ const idx = hs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
308
+ if (idx === -1)
309
+ return null;
310
+ const h = hs[idx];
311
+ const lines = body.split('\n');
312
+ const hl = lines[h.line - 1];
313
+ const start = h.offset + hl.length + 1;
314
+ let end = body.length;
315
+ for (let j = idx + 1; j < hs.length; j++) {
316
+ if (STOP_H2_PLUS(hs[j].level)) {
317
+ end = hs[j].offset - 1;
318
+ break;
319
+ }
320
+ }
321
+ return { start, end };
322
+ }
323
+ /**
324
+ * Return the body text of the `## Current Position` section, or `null` when it
325
+ * is absent. Reuses the fence-aware `locateCurrentPosition` locator (ADR-1372).
326
+ *
327
+ * Exposed so callers that must read a position field (e.g. `cmdStatePrune`,
328
+ * #1776) can scope extraction to the canonical section instead of the whole
329
+ * document — where `stateExtractField`'s pipe-table fallback could otherwise
330
+ * latch onto an unrelated `| Phase | N |` row elsewhere in STATE.md. This
331
+ * scopes the *caller*; the shared extractor is left broad for every other use.
332
+ */
333
+ function sliceCurrentPositionSection(body) {
334
+ const span = locateCurrentPosition(body);
335
+ return span === null ? null : body.slice(span.start, span.end);
336
+ }
337
+ /**
338
+ * First-time ## Current Position mutation: update Phase / Plan / Status /
339
+ * Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour
340
+ * (inline regex first, pipe-table fallback via stateReplaceField — #1257).
341
+ */
342
+ function mutateCurrentPositionFirstTime(body, intent, today, updated) {
343
+ const span = locateCurrentPosition(body);
344
+ if (span === null)
345
+ return body;
346
+ let sectionBody = body.slice(span.start, span.end);
347
+ // Phase line — inline first, then pipe-table fallback (#1257).
348
+ const phaseLabel = `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — EXECUTING`;
349
+ if (/^Phase:/m.test(sectionBody)) {
350
+ sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${phaseLabel}`);
351
+ }
352
+ else {
353
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Phase', phaseLabel);
354
+ if (replaced !== null)
355
+ sectionBody = replaced;
356
+ }
357
+ // Plan line.
358
+ const planValue = `1 of ${intent.planCount || '?'}`;
359
+ if (/^Plan:/m.test(sectionBody)) {
360
+ sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`);
361
+ }
362
+ else {
363
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', planValue);
364
+ if (replaced !== null)
365
+ sectionBody = replaced;
366
+ }
367
+ // Status line.
368
+ const statusValue = `Executing Phase ${intent.phaseNumber}`;
369
+ if (/^Status:/m.test(sectionBody)) {
370
+ sectionBody = sectionBody.replace(/^Status:.*$/m, `Status: ${statusValue}`);
371
+ }
372
+ else {
373
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Status', statusValue);
374
+ if (replaced !== null)
375
+ sectionBody = replaced;
376
+ }
377
+ // Last activity line. The inline value carries date + narrative.
378
+ const activityValue = `${today} — Phase ${intent.phaseNumber} execution started`;
379
+ if (/^Last activity:/im.test(sectionBody)) {
380
+ sectionBody = sectionBody.replace(/^Last activity:.*$/im, `Last activity: ${activityValue}`);
381
+ }
382
+ else {
383
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', activityValue) ??
384
+ (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', activityValue);
385
+ if (replaced !== null)
386
+ sectionBody = replaced;
387
+ }
388
+ updated.push('Current Position');
389
+ return body.slice(0, span.start) + sectionBody + body.slice(span.end);
390
+ }
391
+ /**
392
+ * Resume ## Current Position mutation: only update Last activity line
393
+ * (preserves Plan/Phase/Status — #3127). Mirrors state.cts:2329-2363
394
+ * byte-for-behaviour.
395
+ */
396
+ function mutateCurrentPositionResume(body, intent, today, updated) {
397
+ const span = locateCurrentPosition(body);
398
+ if (span === null)
399
+ return body;
400
+ let sectionBody = body.slice(span.start, span.end);
401
+ const resumeActivity = `Last activity: ${today} — Phase ${intent.phaseNumber} execution resumed (wave continue)`;
402
+ if (/^Last activity:/im.test(sectionBody)) {
403
+ sectionBody = sectionBody.replace(/^Last activity:.*$/im, resumeActivity);
404
+ updated.push('Last activity (resume)');
405
+ }
406
+ else {
407
+ // Pipe-table format fallback (#1255).
408
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', resumeActivity) ??
409
+ (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', resumeActivity);
410
+ if (replaced !== null) {
411
+ sectionBody = replaced;
412
+ updated.push('Last activity (resume)');
413
+ }
414
+ }
415
+ return body.slice(0, span.start) + sectionBody + body.slice(span.end);
416
+ }
417
+ /**
418
+ * Strip ALL frontmatter blocks from the start of `content`.
419
+ *
420
+ * TODO (ADR-1769 follow-up): move to `frontmatter.cjs` or `state-document.cjs`
421
+ * so it's a shared primitive. Inlined here in Phase 1 to avoid touching
422
+ * `state.cjs` (which is the migration target itself) and to keep the Phase 1
423
+ * diff contained. Body is byte-identical to `state.cts:1653 stripFrontmatter`
424
+ * (same CRLF + stacked-block handling).
425
+ */
426
+ function stripFrontmatter(content) {
427
+ let result = content;
428
+ while (true) {
429
+ const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
430
+ if (stripped === result)
431
+ break;
432
+ result = stripped;
433
+ }
434
+ return result;
435
+ }
436
+ /**
437
+ * Update fields within the ## Current Position section for advancePlan.
438
+ * Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
439
+ * only replaces Status / Last Activity when the existing value is a known
440
+ * template default (Knuth invariant: preserve executor-authored values).
441
+ * Plan is always replaced (system-derived, never executor-authored).
442
+ *
443
+ * Cannot import `updateCurrentPositionFields` from state.cjs directly (circular
444
+ * dep: state.cjs → state-transition.cjs → state.cjs), so the mutation is
445
+ * inlined here using the same primitives.
446
+ */
447
+ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastActivityDefaults) {
448
+ const span = locateCurrentPosition(content);
449
+ if (span === null)
450
+ return content;
451
+ let sectionBody = content.slice(span.start, span.end);
452
+ let mutated = false;
453
+ if (fields.status) {
454
+ const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Status', statusDefaults, fields.status);
455
+ if (replaced !== null && replaced !== sectionBody) {
456
+ sectionBody = replaced;
457
+ mutated = true;
458
+ }
459
+ }
460
+ if (fields.lastActivity) {
461
+ const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last Activity', lastActivityDefaults, fields.lastActivity) ??
462
+ (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last activity', lastActivityDefaults, fields.lastActivity);
463
+ if (replaced !== null && replaced !== sectionBody) {
464
+ sectionBody = replaced;
465
+ mutated = true;
466
+ }
467
+ }
468
+ if (fields.plan) {
469
+ // Plan is always replaced — system-derived, not executor-authored.
470
+ if (/^Plan:/m.test(sectionBody)) {
471
+ sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
472
+ mutated = true;
473
+ }
474
+ else {
475
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', fields.plan);
476
+ if (replaced !== null) {
477
+ sectionBody = replaced;
478
+ mutated = true;
479
+ }
480
+ }
481
+ }
482
+ if (!mutated)
483
+ return content;
484
+ return content.slice(0, span.start) + sectionBody + content.slice(span.end);
485
+ }
486
+ // ----------------------------------------------------------------------------
487
+ // advancePlan — intent implementation (Phase 2)
488
+ // ----------------------------------------------------------------------------
489
+ /**
490
+ * Apply an `advancePlan` transition to STATE.md content.
491
+ *
492
+ * Parses Current Plan / Total Plans (legacy separate fields or compound
493
+ * "Plan: X of Y" format), increments the plan number, updates body fields
494
+ * and the ## Current Position section. When currentPlan >= totalPlans,
495
+ * takes the phase-complete branch (sets Status to "Phase complete — ready
496
+ * for verification") instead of advancing.
497
+ *
498
+ * Uses `stateReplaceFieldIfTemplate` (template-default-aware) to preserve
499
+ * executor-authored field values (Knuth invariant from cmdStateAdvancePlan).
500
+ *
501
+ * Returns `data.advanced` / `data.currentPlan` / `data.totalPlans` for the
502
+ * adapter to construct CLI output.
503
+ */
504
+ function advancePlanCore(content, deps) {
505
+ const today = deps.clock.today();
506
+ // #1255: body-field replacements operate on body only (frontmatter stripped),
507
+ // not on the full content. The YAML `status:` key matches `^Status:\s*`
508
+ // before the body field if full content is passed (codex Phase 2 review:
509
+ // HIGH blocking finding — same pattern beginPhaseCore already handles).
510
+ const existingFm = extractFrontmatter(content);
511
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
512
+ let body = stripFrontmatter(content);
513
+ const reassemble = (b) => hasFrontmatter
514
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
515
+ : b;
516
+ // Parse plan number — legacy first, then compound.
517
+ const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
518
+ const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
519
+ const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
520
+ let currentPlan;
521
+ let totalPlans;
522
+ let useCompoundFormat = false;
523
+ if (legacyPlan && legacyTotal) {
524
+ currentPlan = parseInt(legacyPlan, 10);
525
+ totalPlans = parseInt(legacyTotal, 10);
526
+ }
527
+ else if (planField) {
528
+ currentPlan = parseInt(planField, 10);
529
+ const ofMatch = planField.match(/of\s+(\d+)/);
530
+ totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
531
+ useCompoundFormat = true;
532
+ }
533
+ else {
534
+ currentPlan = NaN;
535
+ totalPlans = NaN;
536
+ }
537
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
538
+ return { content: reassemble(body), updated: [], data: { error: true } };
539
+ }
540
+ const updated = [];
541
+ const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
542
+ const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
543
+ if (currentPlan >= totalPlans) {
544
+ // Phase-complete branch.
545
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Phase complete — ready for verification') || body;
546
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
547
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
548
+ body = mutateCurrentPositionForAdvance(body, {
549
+ status: 'Phase complete — ready for verification',
550
+ lastActivity: today,
551
+ }, statusDefaults, lastActivityDefaults);
552
+ updated.push('Status', 'Last Activity', 'Current Position');
553
+ return {
554
+ content: reassemble(body),
555
+ updated,
556
+ data: { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' },
557
+ };
558
+ }
559
+ // Normal advance branch.
560
+ const newPlan = currentPlan + 1;
561
+ let planDisplayValue;
562
+ if (useCompoundFormat) {
563
+ planDisplayValue = planField.replace(/^\d+/, String(newPlan));
564
+ body = (0, state_document_cjs_1.stateReplaceField)(body, 'Plan', planDisplayValue) || body;
565
+ }
566
+ else {
567
+ planDisplayValue = `${newPlan} of ${totalPlans}`;
568
+ body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', String(newPlan)) || body;
569
+ }
570
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute') || body;
571
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
572
+ body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
573
+ body = mutateCurrentPositionForAdvance(body, {
574
+ status: 'Ready to execute',
575
+ lastActivity: today,
576
+ plan: planDisplayValue,
577
+ }, statusDefaults, lastActivityDefaults);
578
+ updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position');
579
+ return {
580
+ content: reassemble(body),
581
+ updated,
582
+ data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans },
583
+ };
584
+ }
585
+ // ----------------------------------------------------------------------------
586
+ // completePhase — intent implementation (Phase 3)
587
+ // ----------------------------------------------------------------------------
588
+ /**
589
+ * Apply a `completePhase` transition to STATE.md content.
590
+ *
591
+ * Migrates the inline STATE.md transform that lived inside `cmdPhaseComplete`
592
+ * (phase.cts) onto the substrate. Owns the field-classification-governed body
593
+ * mutations: Current Phase (preserving the `of total` shape and phase name),
594
+ * Current Phase Name, Status (`Milestone complete` on the last phase, else
595
+ * `Ready to plan`), Current Plan (`Not started`), Last Activity + Description,
596
+ * and the Completed/Total Phases + Progress percent block (re-derived from the
597
+ * roadmap via the injected `roadmapProvider`).
598
+ *
599
+ * The adapter (`cmdPhaseComplete`) retains two concerns that are NOT pure field
600
+ * updates: `updatePerformanceMetricsSection` (a section table upsert) and
601
+ * `syncStateFrontmatter` (the disk-scan post-sync). It also retains the
602
+ * multi-file atomic transaction (`writePlanningFileSet`) that writes ROADMAP,
603
+ * REQUIREMENTS, and STATE together — `readModifyWriteStateMd` is not used here
604
+ * because STATE.md is committed atomically with the other two files.
605
+ *
606
+ * Behavior is byte-for-byte with the pre-migration `phase.cts:1671-1772` block
607
+ * (verified by characterization tests in tests/state-transition.test.cjs).
608
+ */
609
+ function completePhaseCore(content, intent, deps) {
610
+ const updated = [];
611
+ const today = deps.clock.today();
612
+ // Consult the field-classification table for the frontmatter keys this
613
+ // transition touches (same guard beginPhaseCore applies). A missing row is a
614
+ // substrate defect — fail loudly rather than silently re-encoding policy.
615
+ for (const fmKey of [
616
+ 'current_phase',
617
+ 'current_phase_name',
618
+ 'status',
619
+ 'current_plan',
620
+ 'last_activity',
621
+ 'last_activity_desc',
622
+ 'progress',
623
+ ]) {
624
+ const cls = getFieldClassification(fmKey);
625
+ if (cls === null) {
626
+ throw new Error(`transitionCore completePhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
627
+ `add a row per ADR-1769 §4 before touching it.`);
628
+ }
629
+ }
630
+ // #1255: body-field replacements operate on body only (frontmatter stripped),
631
+ // so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
632
+ const existingFm = extractFrontmatter(content);
633
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
634
+ let body = stripFrontmatter(content);
635
+ const reassemble = (b) => hasFrontmatter
636
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
637
+ : b;
638
+ // Current Phase — preserve the existing `of <total>` shape and the phase name
639
+ // in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
640
+ const phaseValue = intent.nextPhaseNum || intent.phaseNum;
641
+ const nextPhaseDisplayName = intent.nextPhaseName;
642
+ const existingPhaseField = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
643
+ let newPhaseValue = String(phaseValue);
644
+ if (existingPhaseField) {
645
+ const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
646
+ const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
647
+ if (totalMatch) {
648
+ const total = totalMatch[1];
649
+ const nameStr = nextPhaseDisplayName
650
+ ? ` (${nextPhaseDisplayName})`
651
+ : nameMatch
652
+ ? ` (${nameMatch[1]})`
653
+ : '';
654
+ newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
655
+ }
656
+ else if (nextPhaseDisplayName) {
657
+ newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
658
+ }
659
+ }
660
+ const phaseAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Current Phase', 'Phase', newPhaseValue);
661
+ if (phaseAfter !== body) {
662
+ body = phaseAfter;
663
+ updated.push('Current Phase');
664
+ }
665
+ // Current Phase Name — only written when a next-phase display name is known
666
+ // (#1743/#1695: classified curated/preserve-always, so an absent name does
667
+ // NOT clear an existing curated value).
668
+ if (nextPhaseDisplayName) {
669
+ const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', nextPhaseDisplayName);
670
+ if (after) {
671
+ body = after;
672
+ updated.push('Current Phase Name');
673
+ }
674
+ }
675
+ // Status — `Milestone complete` on the final phase, otherwise `Ready to plan`.
676
+ const statusValue = intent.isLastPhase ? 'Milestone complete' : 'Ready to plan';
677
+ const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, statusValue);
678
+ if (statusAfter !== body) {
679
+ body = statusAfter;
680
+ updated.push('Status');
681
+ }
682
+ // Current Plan — reset for the next phase.
683
+ const planAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Current Plan', 'Plan', 'Not started');
684
+ if (planAfter !== body) {
685
+ body = planAfter;
686
+ updated.push('Current Plan');
687
+ }
688
+ // Last Activity — prefer the prose `Last activity:` line (date + narrative)
689
+ // when present, else the bold `Last Activity:` date field.
690
+ const lastActivityDescription = `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, transitioned to Phase ${intent.nextPhaseNum}` : ''}`;
691
+ if (/^Last activity:/m.test(body)) {
692
+ const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Last activity', `${today} — ${lastActivityDescription}`);
693
+ if (after) {
694
+ body = after;
695
+ updated.push('Last Activity');
696
+ }
697
+ }
698
+ else {
699
+ const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
700
+ if (after) {
701
+ body = after;
702
+ updated.push('Last Activity');
703
+ }
704
+ }
705
+ const ladAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', lastActivityDescription);
706
+ if (ladAfter) {
707
+ body = ladAfter;
708
+ updated.push('Last Activity Description');
709
+ }
710
+ // Progress block — re-derive completed/total phases from the roadmap when
711
+ // available (milestone-wide source of truth), then recompute the percent.
712
+ // Only runs when a Completed Phases field exists (the existing guard).
713
+ const completedRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Completed Phases');
714
+ if (completedRaw !== null) {
715
+ let newCompleted = parseInt(completedRaw, 10);
716
+ let derivedTotalPhases = null;
717
+ const roadmapContent = deps.roadmapProvider ? deps.roadmapProvider() : null;
718
+ if (roadmapContent) {
719
+ const derived = (0, phase_lifecycle_cjs_1.deriveProgressFromRoadmap)(roadmapContent);
720
+ if (derived.completedPhases !== null)
721
+ newCompleted = derived.completedPhases;
722
+ if (derived.totalPhases !== null)
723
+ derivedTotalPhases = derived.totalPhases;
724
+ }
725
+ const completedAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Completed Phases', String(newCompleted));
726
+ if (completedAfter) {
727
+ body = completedAfter;
728
+ updated.push('Completed Phases');
729
+ }
730
+ const totalRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases');
731
+ const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
732
+ if (totalPhases && totalPhases > 0) {
733
+ const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases);
734
+ const progAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Progress', `${newPercent}%`);
735
+ if (progAfter) {
736
+ body = progAfter;
737
+ updated.push('Progress');
738
+ }
739
+ // Inline `percent:` token (frontmatter / progress sub-block).
740
+ body = body.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
741
+ }
742
+ }
743
+ return { content: reassemble(body), updated };
744
+ }
745
+ // ----------------------------------------------------------------------------
746
+ // plannedPhase — intent implementation (Phase 4)
747
+ // ----------------------------------------------------------------------------
748
+ /**
749
+ * Apply a `plannedPhase` transition to STATE.md content.
750
+ *
751
+ * Migrates `cmdStatePlannedPhase` (state.cts) onto the substrate. Updates the
752
+ * per-phase body fields after plan-phase runs: Status (template-aware — only
753
+ * replaces handler-generated values, preserving executor-authored ones),
754
+ * Total Plans in Phase, Last Activity (template-aware), Last Activity
755
+ * Description, and the ## Current Position section. The adapter wraps this in
756
+ * `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
757
+ * frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
758
+ *
759
+ * Uses `mutateCurrentPositionForAdvance` (the inlined twin of state.cts's
760
+ * `updateCurrentPositionFields`) so the Knuth template-default invariant
761
+ * applies inside the Current Position section too.
762
+ */
763
+ function plannedPhaseCore(content, intent, deps) {
764
+ const updated = [];
765
+ const today = deps.clock.today();
766
+ for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
767
+ const cls = getFieldClassification(fmKey);
768
+ if (cls === null) {
769
+ throw new Error(`transitionCore plannedPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
770
+ `add a row per ADR-1769 §4 before touching it.`);
771
+ }
772
+ }
773
+ // #1255: body-field replacements operate on body only.
774
+ const existingFm = extractFrontmatter(content);
775
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
776
+ let body = stripFrontmatter(content);
777
+ const reassemble = (b) => hasFrontmatter
778
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
779
+ : b;
780
+ const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
781
+ const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
782
+ // Status — template-aware (preserve executor-authored values).
783
+ const statusAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
784
+ if (statusAfter !== null && statusAfter !== body) {
785
+ body = statusAfter;
786
+ updated.push('Status');
787
+ }
788
+ // Total Plans in Phase — system-derived; always replaced when a count is given.
789
+ if (intent.planCount !== null && intent.planCount !== undefined) {
790
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(intent.planCount));
791
+ if (result) {
792
+ body = result;
793
+ updated.push('Total Plans in Phase');
794
+ }
795
+ }
796
+ // Last Activity — template-aware.
797
+ const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
798
+ if (lastActivityAfter !== null && lastActivityAfter !== body) {
799
+ body = lastActivityAfter;
800
+ updated.push('Last Activity');
801
+ }
802
+ // Last Activity Description.
803
+ const ladResult = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${intent.phaseNumber} planning complete — ${intent.planCount || '?'} plans ready`);
804
+ if (ladResult) {
805
+ body = ladResult;
806
+ updated.push('Last Activity Description');
807
+ }
808
+ // ## Current Position section — Status + Last activity (template-aware).
809
+ const beforePos = body;
810
+ body = mutateCurrentPositionForAdvance(body, {
811
+ status: 'Ready to execute',
812
+ lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
813
+ }, statusDefaults, lastActivityDefaults);
814
+ if (body !== beforePos)
815
+ updated.push('Current Position');
816
+ return { content: reassemble(body), updated };
817
+ }
818
+ // ----------------------------------------------------------------------------
819
+ // milestoneSwitch — intent implementation (Phase 4)
820
+ // ----------------------------------------------------------------------------
821
+ /**
822
+ * Apply a `milestoneSwitch` transition to STATE.md content.
823
+ *
824
+ * Migrates `cmdStateMilestoneSwitch` (state.cts) onto the substrate. Resets
825
+ * STATE.md for a new milestone cycle: rewrites the frontmatter (milestone,
826
+ * milestone_name, status='planning', last_updated, last_activity, and the
827
+ * progress block zeroed) and rewrites the ## Current Position body to the
828
+ * "defining requirements" starting state. `gsd_state_version` is preserved.
829
+ * Body content OUTSIDE Current Position (e.g. Accumulated Context) is
830
+ * preserved.
831
+ *
832
+ * This is a destructive reset intent: it intentionally overwrites the curated
833
+ * `progress` / `current_phase_name` fields (classified preserve-always) because
834
+ * a new milestone starts from zero. That is the intent's contract, not a
835
+ * violation of the field-classification table — the table governs the steady-
836
+ * state RMW transitions; a milestone boundary is an explicit reset.
837
+ *
838
+ * The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
839
+ * `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
840
+ * directly and must not run the steady-state `syncStateFrontmatter` post-sync.
841
+ */
842
+ function milestoneSwitchCore(content, intent, deps) {
843
+ const today = deps.clock.today();
844
+ const updated = [
845
+ 'milestone',
846
+ 'milestone_name',
847
+ 'status',
848
+ 'last_updated',
849
+ 'last_activity',
850
+ 'progress',
851
+ 'Current Position',
852
+ ];
853
+ const existingFm = extractFrontmatter(content);
854
+ const body = stripFrontmatter(content);
855
+ const resolvedName = (intent.name && intent.name.trim()) || 'milestone';
856
+ // ## Current Position reset body (mirrors state.cts:2371-2375).
857
+ const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
858
+ `Plan: —\n` +
859
+ `Status: Defining requirements\n` +
860
+ `Last activity: ${today} — Milestone ${intent.version} started\n\n`;
861
+ let newBody;
862
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
863
+ const posIdx = hs.findIndex((h) => h.level === 2 && /^current\s+position$/i.test(h.text));
864
+ if (posIdx !== -1) {
865
+ const h = hs[posIdx];
866
+ const lines = body.split('\n');
867
+ const hl = lines[h.line - 1];
868
+ const bodyStart = h.offset + hl.length + 1;
869
+ let bodyEnd = body.length;
870
+ for (let j = posIdx + 1; j < hs.length; j++) {
871
+ if (STOP_H2_PLUS(hs[j].level)) {
872
+ bodyEnd = hs[j].offset - 1;
873
+ break;
874
+ }
875
+ }
876
+ newBody = body.slice(0, bodyStart) + resetPositionBody + body.slice(bodyEnd);
877
+ }
878
+ else {
879
+ const preface = body.trim().length > 0 ? body : '# Project State\n';
880
+ newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
881
+ }
882
+ // Rebuilt frontmatter — curated fields are intentionally reset (milestone
883
+ // boundary). gsd_state_version is preserved.
884
+ const fm = {
885
+ gsd_state_version: existingFm['gsd_state_version'] || '1.0',
886
+ milestone: intent.version,
887
+ milestone_name: resolvedName,
888
+ status: 'planning',
889
+ last_updated: deps.clock.nowIso(),
890
+ last_activity: today,
891
+ progress: {
892
+ total_phases: 0,
893
+ completed_phases: 0,
894
+ total_plans: 0,
895
+ completed_plans: 0,
896
+ percent: 0,
897
+ },
898
+ };
899
+ const yamlStr = reconstructFrontmatter(fm);
900
+ const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
901
+ return { content: assembled, updated };
902
+ }
903
+ // ----------------------------------------------------------------------------
904
+ // milestoneComplete — intent implementation (Phase 5)
905
+ // ----------------------------------------------------------------------------
906
+ /**
907
+ * Apply a `milestoneComplete` transition to STATE.md content.
908
+ *
909
+ * Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts)
910
+ * onto the substrate. Owns the closure write: Status (`<version> milestone
911
+ * complete`), Last Activity, Last Activity Description, a ## Current Position
912
+ * reset to the "Awaiting next milestone" state, and a ## Operator Next Steps
913
+ * reset pointing at the next-milestone command.
914
+ *
915
+ * The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that
916
+ * owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the
917
+ * runtime-specific next-milestone slash command, injecting it via
918
+ * `intent.nextMilestoneCommand` so the core stays pure.
919
+ *
920
+ * The two section resets use raw regex (with the pre-seam `allow-adhoc-markdown`
921
+ * waivers carried from milestone.cts) rather than tokenizeHeadings because the
922
+ * `## Operator Next Steps` section is non-canonical (not in STATE_MD_SECTIONS)
923
+ * and the existing behavior + its tests pin the exact regex semantics. A future
924
+ * collectSection migration (#1372) can swap both to section primitives.
925
+ *
926
+ * Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block.
927
+ */
928
+ function milestoneCompleteCore(content, intent, deps) {
929
+ const updated = [];
930
+ const today = deps.clock.today();
931
+ const version = intent.version;
932
+ for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
933
+ const cls = getFieldClassification(fmKey);
934
+ if (cls === null) {
935
+ throw new Error(`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
936
+ `add a row per ADR-1769 §4 before touching it.`);
937
+ }
938
+ }
939
+ // #1255: body-field replacements operate on body only.
940
+ const existingFm = extractFrontmatter(content);
941
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
942
+ let body = stripFrontmatter(content);
943
+ const reassemble = (b) => hasFrontmatter
944
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
945
+ : b;
946
+ // Status — `<version> milestone complete`.
947
+ const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
948
+ if (statusAfter !== body) {
949
+ body = statusAfter;
950
+ updated.push('Status');
951
+ }
952
+ // Last Activity.
953
+ const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity', 'Last activity', today);
954
+ if (lastActivityAfter !== body) {
955
+ body = lastActivityAfter;
956
+ updated.push('Last Activity');
957
+ }
958
+ // Last Activity Description.
959
+ const ladAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity Description', null, `${version} milestone completed and archived`);
960
+ if (ladAfter !== body) {
961
+ body = ladAfter;
962
+ updated.push('Last Activity Description');
963
+ }
964
+ // ## Current Position reset — stop resume/progress flows pointing at closed
965
+ // execution instructions.
966
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372
967
+ const closedPositionBody = `\nPhase: Milestone ${version} complete\n` +
968
+ `Plan: —\n` +
969
+ `Status: Awaiting next milestone\n` +
970
+ `Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
971
+ if (positionPattern.test(body)) {
972
+ body = body.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
973
+ }
974
+ else {
975
+ body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
976
+ }
977
+ updated.push('Current Position');
978
+ // ## Operator Next Steps — normalize stale tails that can persist after close.
979
+ const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372
980
+ if (operatorPattern.test(body)) {
981
+ body = body.replace(operatorPattern, `$1\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`);
982
+ }
983
+ else {
984
+ body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`;
985
+ }
986
+ updated.push('Operator Next Steps');
987
+ return { content: reassemble(body), updated };
988
+ }
989
+ // ----------------------------------------------------------------------------
990
+ // patch — intent implementation (Phase 6)
991
+ // ----------------------------------------------------------------------------
992
+ /**
993
+ * Apply a `patch` transition to STATE.md content.
994
+ *
995
+ * Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
996
+ * caller-supplied `{field: value}` pair via `stateReplaceField` over the full
997
+ * content (body + frontmatter — patch can target either), tracking which fields
998
+ * were updated vs. not found.
999
+ *
1000
+ * The curated-field preservation that fixes #1743/#1695 is NOT in this core —
1001
+ * it lives in `readModifyWriteStateMd`'s post-sync delta (table-driven via
1002
+ * `getFieldClassification('current_phase_name').preservation === 'preserve-always'`).
1003
+ * `patch` consulting the table "refuses to overwrite" curated fields implicitly:
1004
+ * when the patch does not change a curated field's body source line, the
1005
+ * existing frontmatter value wins over the sync re-derivation. The adapter
1006
+ * still owns field-name validation (security) and the resync-progress decision.
1007
+ *
1008
+ * `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
1009
+ */
1010
+ function patchCore(content, intent) {
1011
+ const updated = [];
1012
+ const failed = [];
1013
+ let result = content;
1014
+ for (const [field, value] of Object.entries(intent.patches)) {
1015
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(result, field, value);
1016
+ if (replaced !== null) {
1017
+ result = replaced;
1018
+ updated.push(field);
1019
+ }
1020
+ else {
1021
+ failed.push(field);
1022
+ }
1023
+ }
1024
+ return { content: result, updated, data: { updated, failed } };
1025
+ }
1026
+ // ----------------------------------------------------------------------------
1027
+ // update — intent implementation (Phase 7)
1028
+ // ----------------------------------------------------------------------------
1029
+ /**
1030
+ * Apply an `update` transition to STATE.md content.
1031
+ *
1032
+ * Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field
1033
+ * body-only update (the field is replaced in the body; frontmatter is preserved
1034
+ * as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync).
1035
+ * Mirrors the pre-migration body-strip/reassemble contract.
1036
+ */
1037
+ function updateCore(content, intent) {
1038
+ const existingFm = extractFrontmatter(content);
1039
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1040
+ const body = stripFrontmatter(content);
1041
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
1042
+ if (result === null) {
1043
+ return { content, updated: [], data: { updated: false } };
1044
+ }
1045
+ const reassembled = hasFrontmatter
1046
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
1047
+ : result;
1048
+ return { content: reassembled, updated: [intent.field], data: { updated: true } };
1049
+ }
1050
+ // Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
1051
+ // the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed /
1052
+ // Blockers / Performance Metrics live at H2 or H3).
1053
+ const STOP_H2_H3 = (lv) => lv === 2 || lv === 3;
1054
+ /**
1055
+ * Apply a `prune` transition to STATE.md content.
1056
+ *
1057
+ * Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the
1058
+ * substrate. Pure `content → {content, archivedSections}` given a cutoff phase:
1059
+ * archives Decisions / Recently Completed / resolved Blockers / Performance
1060
+ * Metrics table rows whose phase number is <= cutoff. ADR-1372 T6
1061
+ * tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration
1062
+ * `prunePass`.
1063
+ *
1064
+ * The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current
1065
+ * Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes.
1066
+ */
1067
+ function pruneCore(content, intent) {
1068
+ const cutoff = intent.cutoff;
1069
+ const sections = [];
1070
+ let c = content;
1071
+ // Helper: locate a heading matching pred, extract untrimmed body [bs, se),
1072
+ // apply transform, splice back. All prune sections stop at level 2 or 3.
1073
+ const pruneSectionSpan = (pred, transform, sectionName) => {
1074
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c);
1075
+ const i = hs.findIndex((h) => pred(h.level, h.text));
1076
+ if (i === -1)
1077
+ return;
1078
+ const h = hs[i];
1079
+ const ls = c.split('\n');
1080
+ const hl = ls[h.line - 1];
1081
+ const bs = h.offset + hl.length + 1;
1082
+ let se = c.length;
1083
+ for (let j = i + 1; j < hs.length; j++) {
1084
+ if (STOP_H2_H3(hs[j].level)) {
1085
+ se = hs[j].offset - 1;
1086
+ break;
1087
+ }
1088
+ }
1089
+ const body = c.slice(bs, se);
1090
+ const { keep, archive } = transform(body);
1091
+ if (archive.length > 0) {
1092
+ sections.push({ section: sectionName, count: archive.length, lines: archive });
1093
+ c = c.slice(0, bs) + keep.join('\n') + c.slice(se);
1094
+ }
1095
+ };
1096
+ pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => {
1097
+ const keep = [], archive = [];
1098
+ for (const line of body.split('\n')) {
1099
+ const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
1100
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1101
+ archive.push(line);
1102
+ }
1103
+ else {
1104
+ keep.push(line);
1105
+ }
1106
+ }
1107
+ return { keep, archive };
1108
+ }, 'Decisions');
1109
+ pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => {
1110
+ const keep = [], archive = [];
1111
+ for (const line of body.split('\n')) {
1112
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
1113
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1114
+ archive.push(line);
1115
+ }
1116
+ else {
1117
+ keep.push(line);
1118
+ }
1119
+ }
1120
+ return { keep, archive };
1121
+ }, 'Recently Completed');
1122
+ pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => {
1123
+ const keep = [], archive = [];
1124
+ for (const line of body.split('\n')) {
1125
+ const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
1126
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
1127
+ if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1128
+ archive.push(line);
1129
+ }
1130
+ else {
1131
+ keep.push(line);
1132
+ }
1133
+ }
1134
+ return { keep, archive };
1135
+ }, 'Blockers (resolved)');
1136
+ pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => {
1137
+ const keep = [], archive = [];
1138
+ for (const line of body.split('\n')) {
1139
+ const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
1140
+ if (tableRowMatch) {
1141
+ const rowPhase = parseInt(tableRowMatch[1], 10);
1142
+ if (rowPhase <= cutoff) {
1143
+ archive.push(line);
1144
+ }
1145
+ else {
1146
+ keep.push(line);
1147
+ }
1148
+ }
1149
+ else {
1150
+ keep.push(line);
1151
+ }
1152
+ }
1153
+ return { keep, archive };
1154
+ }, 'Performance Metrics');
1155
+ const totalPruned = sections.reduce((sum, s) => sum + s.count, 0);
1156
+ return {
1157
+ content: c,
1158
+ updated: totalPruned > 0 ? ['pruned'] : [],
1159
+ data: { archivedSections: sections, totalPruned },
1160
+ };
1161
+ }
1162
+ // ----------------------------------------------------------------------------
1163
+ // sync — intent implementation (Phase 7)
1164
+ // ----------------------------------------------------------------------------
1165
+ /**
1166
+ * Apply a `sync` transition to STATE.md content.
1167
+ *
1168
+ * Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate.
1169
+ * Updates Total Plans in Phase, the Progress bar, and Last Activity from
1170
+ * disk-derived numbers (injected via the intent). Returns the per-field change
1171
+ * log via `data.changes` so the adapter can build the CLI output.
1172
+ *
1173
+ * #1761: when the current milestone cannot be bounded to a versioned phase set,
1174
+ * the adapter passes `percent: null` and this core leaves Progress untouched
1175
+ * (rather than silently writing fallback-derived wrong values).
1176
+ */
1177
+ function syncCore(content, intent, deps) {
1178
+ const today = deps.clock.today();
1179
+ const changes = [];
1180
+ let modified = content;
1181
+ const updated = [];
1182
+ if (intent.totalPlansInPhase !== null) {
1183
+ const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
1184
+ if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) {
1185
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`);
1186
+ const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase));
1187
+ if (result) {
1188
+ modified = result;
1189
+ updated.push('Total Plans in Phase');
1190
+ }
1191
+ }
1192
+ }
1193
+ if (intent.percent !== null) {
1194
+ const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
1195
+ if (currentProgress) {
1196
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1197
+ if (currentPercent !== intent.percent) {
1198
+ const barWidth = 10;
1199
+ const filled = Math.round((intent.percent / 100) * barWidth);
1200
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1201
+ const progressStr = `[${bar}] ${intent.percent}%`;
1202
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1203
+ const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
1204
+ if (result) {
1205
+ modified = result;
1206
+ updated.push('Progress');
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ const lastActivityResult = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today);
1212
+ if (lastActivityResult) {
1213
+ const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity');
1214
+ if (oldActivity !== today) {
1215
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1216
+ updated.push('Last Activity');
1217
+ }
1218
+ modified = lastActivityResult;
1219
+ }
1220
+ return { content: modified, updated, data: { changes } };
1221
+ }
1222
+ // ----------------------------------------------------------------------------
1223
+ // rebuild — intent implementation (ADR-1817, capstone 11th transition)
1224
+ // ----------------------------------------------------------------------------
1225
+ //
1226
+ // Implements the body-structure derivability contract (ADR-1817 §2–§6):
1227
+ // - §2 re-derives derived sections (## Current Position prose, By Phase table
1228
+ // inside ## Performance Metrics), preserves curated sections verbatim
1229
+ // (## Accumulated Context, ## Deferred Items, ## Project Reference, ##
1230
+ // Session Continuity's prose fields) and unknown sections.
1231
+ // - §3 every mutation appends a structured entry to ## Rebuild Log
1232
+ // (ADR-1411 provenance principle — never drop silently).
1233
+ // - §4 idempotency: a no-mutation rebuild appends NO log entry, so two
1234
+ // successive runs on a clean file are byte-identical.
1235
+ // - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight,
1236
+ // auto-triggered; rebuild = body structure, heavier, manual).
1237
+ // - §6 orthogonal to auto_prune_state (rebuild reconciles with current
1238
+ // canonical sources; prune removes by retention policy).
1239
+ //
1240
+ // Section ordering is invariant: rebuild rewrites content IN PLACE; it does
1241
+ // not reorder, insert (other than ## Rebuild Log when absent), or remove
1242
+ // sections.
1243
+ const REBUILD_LOG_SECTION = '## Rebuild Log';
1244
+ const REBUILD_LOG_TRUNCATION_LIMIT = 512;
1245
+ /**
1246
+ * Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the
1247
+ * `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars
1248
+ * to prevent unbounded log growth when the drifted content is large.
1249
+ */
1250
+ function truncateForLog(s) {
1251
+ if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT)
1252
+ return s;
1253
+ return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...';
1254
+ }
1255
+ /**
1256
+ * Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3
1257
+ * and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated`
1258
+ * is false when no drift was found (idempotency contract, ADR-1817 §4).
1259
+ */
1260
+ function rebuildCore(content, _intent, deps) {
1261
+ const timestamp = deps.clock.nowIso();
1262
+ const log = [];
1263
+ let modified = content;
1264
+ // §2 Decision: re-derive derived sections, preserve others. Order is
1265
+ // oldest-section-first so log entries appear in body order.
1266
+ modified = reconcileCurrentPosition(modified, timestamp, log);
1267
+ modified = reconcileByPhaseTable(modified, deps, timestamp, log);
1268
+ modified = stripTemplatePlaceholders(modified, timestamp, log);
1269
+ modified = deduplicateSessionArchive(modified, timestamp, log);
1270
+ // §3 + §4: append the audit log ONLY when mutations occurred. The
1271
+ // log-appends-only-on-mutation rule is what makes idempotency byte-identical
1272
+ // (without it, the second invocation would always append a no-op entry).
1273
+ if (log.length > 0) {
1274
+ modified = appendRebuildLogSection(modified, log);
1275
+ }
1276
+ const updated = log.length > 0 ? ['rebuild'] : [];
1277
+ return {
1278
+ content: modified,
1279
+ updated,
1280
+ data: {
1281
+ mutated: log.length > 0,
1282
+ mutations: log.length,
1283
+ log,
1284
+ },
1285
+ };
1286
+ }
1287
+ /**
1288
+ * §2 — re-derive `## Current Position` prose fields from frontmatter.
1289
+ *
1290
+ * Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after
1291
+ * a milestone switch or prune (epic #1817). The body prose is re-derivable
1292
+ * because `buildStateFrontmatter` already derives the canonical values from
1293
+ * disk; rebuild pushes those back into the body prose.
1294
+ *
1295
+ * Implementation: pull each canonical value from frontmatter and replace the
1296
+ * body field via `stateReplaceField`. Skip silently when frontmatter lacks
1297
+ * the key (Leaky-Abstractions guard — don't synthesize values the canonical
1298
+ * source doesn't have).
1299
+ */
1300
+ function reconcileCurrentPosition(content, timestamp, log) {
1301
+ const fm = extractFrontmatter(content);
1302
+ if (!fm || typeof fm !== 'object')
1303
+ return content;
1304
+ let modified = content;
1305
+ // Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`.
1306
+ // The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned
1307
+ // by other transitions (beginPhase / completePhase) and reconstructed from
1308
+ // total-phase counts; rebuild reconciles only the `**Current Phase:**` body
1309
+ // field that frontmatter is the canonical source for.
1310
+ const fmPhase = fm.current_phase;
1311
+ if (typeof fmPhase === 'string' || typeof fmPhase === 'number') {
1312
+ const canonicalPhase = String(fmPhase);
1313
+ const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase');
1314
+ if (existing !== null && existing !== canonicalPhase) {
1315
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase', canonicalPhase);
1316
+ if (replaced !== null) {
1317
+ modified = replaced;
1318
+ log.push({
1319
+ timestamp,
1320
+ kind: 'current-position-reconciled',
1321
+ section: exports.STATE_MD_SECTIONS.currentPosition,
1322
+ before: truncateForLog(existing),
1323
+ after: truncateForLog(canonicalPhase),
1324
+ reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale",
1325
+ });
1326
+ }
1327
+ }
1328
+ }
1329
+ // Phase name prose.
1330
+ const fmPhaseName = fm.current_phase_name;
1331
+ if (typeof fmPhaseName === 'string' || typeof fmPhaseName === 'number') {
1332
+ const canonicalName = String(fmPhaseName);
1333
+ const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase Name');
1334
+ if (existing !== null && existing !== canonicalName) {
1335
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase Name', canonicalName);
1336
+ if (replaced !== null) {
1337
+ modified = replaced;
1338
+ log.push({
1339
+ timestamp,
1340
+ kind: 'current-position-reconciled',
1341
+ section: exports.STATE_MD_SECTIONS.currentPosition,
1342
+ before: truncateForLog(existing),
1343
+ after: truncateForLog(canonicalName),
1344
+ reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale",
1345
+ });
1346
+ }
1347
+ }
1348
+ }
1349
+ return modified;
1350
+ }
1351
+ /**
1352
+ * §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics`
1353
+ * from the injected `phaseInventoryProvider`. Drift class: orphaned rows for
1354
+ * phases from a prior milestone, or zero-padded phase IDs that were renamed
1355
+ * (epic #1817).
1356
+ *
1357
+ * Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
1358
+ * absent (no disk scan wired), this step is a no-op. The core stays pure and
1359
+ * testable without disk I/O.
1360
+ */
1361
+ function reconcileByPhaseTable(content, deps, timestamp, log) {
1362
+ if (!deps.phaseInventoryProvider)
1363
+ return content;
1364
+ const inventory = deps.phaseInventoryProvider();
1365
+ if (!inventory || inventory.length === 0)
1366
+ return content;
1367
+ // The canonical table shape (from gsd-core/templates/state.md):
1368
+ // | Phase | Plans | Total | Avg/Plan |
1369
+ // |-------|-------|-------|----------|
1370
+ // | - | - | - | - |
1371
+ // rebuild renders one row per inventory record (Phase N: P plans). The
1372
+ // Total/Avg columns are runtime-collected by other commands; rebuild does
1373
+ // NOT re-derive them and resets them to '-' so future plan-completion
1374
+ // repopulates. The canonical reconciliation target is the row SET.
1375
+ const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`);
1376
+ const canonicalTable = [
1377
+ '| Phase | Plans | Total | Avg/Plan |',
1378
+ '|-------|-------|-------|----------|',
1379
+ ...tableRows,
1380
+ ];
1381
+ // Line-based splice: find `**By Phase:**` line, then walk forward collecting
1382
+ // the table block (header + separator + body rows), replace the block with
1383
+ // the canonical table preceded by a single blank-line separator.
1384
+ const lines = content.split('\n');
1385
+ const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**');
1386
+ if (markerIdx === -1)
1387
+ return content; // unknown shape — preserve verbatim
1388
+ // Walk forward from markerIdx+1 to find the table block span. Skip leading
1389
+ // blank lines; once we see the first table row, consume subsequent table
1390
+ // rows; stop at the first non-table line after we've started.
1391
+ let blockStart = -1;
1392
+ let blockEnd = -1;
1393
+ for (let i = markerIdx + 1; i < lines.length; i++) {
1394
+ const trimmed = lines[i].trim();
1395
+ const isTable = trimmed.startsWith('|') && trimmed.endsWith('|');
1396
+ if (blockStart === -1) {
1397
+ if (isTable) {
1398
+ blockStart = i;
1399
+ blockEnd = i + 1;
1400
+ }
1401
+ else if (trimmed === '')
1402
+ continue;
1403
+ else
1404
+ break; // non-table, non-blank before any row — unknown shape
1405
+ }
1406
+ else {
1407
+ if (isTable)
1408
+ blockEnd = i + 1;
1409
+ else
1410
+ break;
1411
+ }
1412
+ }
1413
+ if (blockStart === -1)
1414
+ return content; // no table found
1415
+ // Replace lines[blockStart..blockEnd) with canonicalTable.
1416
+ const beforeBlock = lines.slice(0, markerIdx + 1);
1417
+ const afterBlock = lines.slice(blockEnd);
1418
+ // Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after)
1419
+ const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock];
1420
+ const candidate = newLines.join('\n');
1421
+ if (candidate === content)
1422
+ return content;
1423
+ log.push({
1424
+ timestamp,
1425
+ kind: 'by-phase-table-reconciled',
1426
+ section: exports.STATE_MD_SECTIONS.performanceMetrics,
1427
+ before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')),
1428
+ after: truncateForLog(canonicalTable.join('\n')),
1429
+ reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added',
1430
+ });
1431
+ return candidate;
1432
+ }
1433
+ /**
1434
+ * §2 + epic-#1817 drift class — template-placeholder field values left in
1435
+ * place when an AI agent wrote partial state. The canonical template uses
1436
+ * `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see
1437
+ * `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]`
1438
+ * line where the value still matches the placeholder shape.
1439
+ *
1440
+ * "Clears" means: leaves the field in place with the literal text `(pending)`,
1441
+ * signalling that rebuild recognized the placeholder but had no canonical
1442
+ * source to substitute. This is honest — better than silently leaving `[X]`
1443
+ * which looks like a value.
1444
+ */
1445
+ const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
1446
+ function stripTemplatePlaceholders(content, timestamp, log) {
1447
+ // Scan body `**Field:** value` lines; when value matches the placeholder
1448
+ // shape, replace with `(pending)`. We deliberately do NOT touch fields that
1449
+ // other transitions actively maintain (syncCore's three, beginPhase's set,
1450
+ // etc.) — only the template placeholder rows that nothing has touched.
1451
+ const lines = content.split('\n');
1452
+ const replacements = [];
1453
+ for (let i = 0; i < lines.length; i++) {
1454
+ const line = lines[i];
1455
+ const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/);
1456
+ if (!m)
1457
+ continue;
1458
+ const fieldName = m[1];
1459
+ const value = m[2];
1460
+ if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) {
1461
+ const cleared = `**${fieldName}:** (pending)`;
1462
+ replacements.push({ lineIdx: i, before: line, after: cleared, fieldName });
1463
+ }
1464
+ }
1465
+ if (replacements.length === 0)
1466
+ return content;
1467
+ for (const r of replacements) {
1468
+ lines[r.lineIdx] = r.after;
1469
+ log.push({
1470
+ timestamp,
1471
+ kind: 'placeholder-removed',
1472
+ section: exports.STATE_MD_SECTIONS.currentPosition,
1473
+ before: truncateForLog(r.before.trim()),
1474
+ after: truncateForLog(r.after),
1475
+ reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`,
1476
+ });
1477
+ }
1478
+ return lines.join('\n');
1479
+ }
1480
+ /**
1481
+ * §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive`
1482
+ * blocks from repeated `state record-session` calls on a corrupt file. The
1483
+ * canonical template has one `## Session Continuity` section; archived blocks
1484
+ * may accumulate as `### Session — <timestamp>` H3 sub-sections under it.
1485
+ * Rebuild keeps the most-recent N (default 3) and drops older duplicates,
1486
+ * logging each drop.
1487
+ *
1488
+ * Conservative scope: only acts when the section has more than 3 H3
1489
+ * `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim).
1490
+ */
1491
+ const DEFAULT_MAX_SESSION_ARCHIVES = 3;
1492
+ // `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X`
1493
+ // is just `Session — X`. Match the bare heading text.
1494
+ const SESSION_ARCHIVE_H3 = /^Session\s+—/;
1495
+ function deduplicateSessionArchive(content, timestamp, log) {
1496
+ const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1497
+ // Find `## Session Continuity` H2.
1498
+ const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity');
1499
+ if (sectionIdx === -1)
1500
+ return content;
1501
+ // Find the section span: from this H2's offset to the next H2 (or EOF).
1502
+ const sectionStart = hs[sectionIdx].offset;
1503
+ let sectionEnd = content.length;
1504
+ for (let i = sectionIdx + 1; i < hs.length; i++) {
1505
+ if (hs[i].level === 2) {
1506
+ sectionEnd = hs[i].offset;
1507
+ break;
1508
+ }
1509
+ }
1510
+ // Count `### Session — …` H3 sub-headings inside the section.
1511
+ const archiveHeadings = hs.filter((h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text));
1512
+ if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES)
1513
+ return content;
1514
+ // Keep the most-recent N by offset (last N in document order; if timestamps
1515
+ // in the H3 text are in chronological order — the template convention —
1516
+ // last-N == most-recent-N).
1517
+ const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES;
1518
+ const toDrop = archiveHeadings.slice(0, dropCount);
1519
+ // Compute the byte spans to drop: each archived H3 spans from its offset to
1520
+ // the next H3 (or to sectionEnd). Drop with one preceding blank line so we
1521
+ // don't leave a dangling separator.
1522
+ let mutated = content;
1523
+ // Process from the bottom up so offsets don't shift mid-edit.
1524
+ for (let i = toDrop.length - 1; i >= 0; i--) {
1525
+ const h = toDrop[i];
1526
+ let spanEnd = sectionEnd;
1527
+ // Find next H3 at-or-after h.offset (within the section).
1528
+ for (const candidate of hs) {
1529
+ if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) {
1530
+ spanEnd = candidate.offset;
1531
+ break;
1532
+ }
1533
+ }
1534
+ const dropStart = h.offset;
1535
+ const before = mutated.slice(0, dropStart);
1536
+ const after = mutated.slice(spanEnd);
1537
+ const droppedText = mutated.slice(dropStart, spanEnd);
1538
+ mutated = before + after;
1539
+ log.push({
1540
+ timestamp,
1541
+ kind: 'session-archive-deduplicated',
1542
+ section: exports.STATE_MD_SECTIONS.sessionContinuity,
1543
+ before: truncateForLog(droppedText),
1544
+ after: '',
1545
+ reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`,
1546
+ });
1547
+ }
1548
+ return mutated;
1549
+ }
1550
+ /**
1551
+ * §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3
1552
+ * the section is created if absent; existing entries are preserved verbatim
1553
+ * (append-only).
1554
+ *
1555
+ * Format (yaml-ish, human-readable, machine-parseable):
1556
+ *
1557
+ * ## Rebuild Log
1558
+ *
1559
+ * - timestamp: 2026-06-29T19:30:00Z
1560
+ * kind: placeholder-removed
1561
+ * section: ## Current Position
1562
+ * before: ...
1563
+ * after: ...
1564
+ * reason: ...
1565
+ */
1566
+ function appendRebuildLogSection(content, entries) {
1567
+ const lines = content.split('\n');
1568
+ // Render the new entry block.
1569
+ const rendered = [];
1570
+ for (const e of entries) {
1571
+ rendered.push(`- timestamp: ${e.timestamp}`);
1572
+ rendered.push(` kind: ${e.kind}`);
1573
+ rendered.push(` section: ${e.section}`);
1574
+ rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`);
1575
+ rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`);
1576
+ rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`);
1577
+ }
1578
+ // Locate an existing `## Rebuild Log` section.
1579
+ const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION);
1580
+ if (sectionHeaderIdx === -1) {
1581
+ // Create the section at end-of-file, separated by a blank line.
1582
+ const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== '';
1583
+ const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered];
1584
+ return [...lines, ...trailer].join('\n');
1585
+ }
1586
+ // Append to the existing section. Find the end of the existing log entries
1587
+ // (walk forward until the next H2 or EOF). Insert before that boundary.
1588
+ let insertAt = sectionHeaderIdx + 1;
1589
+ while (insertAt < lines.length) {
1590
+ const l = lines[insertAt];
1591
+ if (/^##\s/.test(l))
1592
+ break;
1593
+ insertAt++;
1594
+ }
1595
+ // Preserve a blank-line separator before the new entries if the prior line
1596
+ // is non-blank and non-header.
1597
+ const sep = [];
1598
+ if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) {
1599
+ sep.push('');
1600
+ }
1601
+ const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)];
1602
+ return next.join('\n');
1603
+ }