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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-verifier.md +1 -0
  3. package/bin/gsd-mcp-server.js +31 -0
  4. package/bin/install.js +293 -1145
  5. package/commands/gsd/review.md +6 -0
  6. package/gemini-extension.json +1 -1
  7. package/gsd-core/bin/gsd-tools.cjs +116 -1
  8. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  9. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  10. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  12. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  14. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  15. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  16. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  17. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  18. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  19. package/gsd-core/bin/lib/config.cjs +27 -0
  20. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  21. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  22. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  24. package/gsd-core/bin/lib/init.cjs +1 -1
  25. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  26. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  27. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  28. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  29. package/gsd-core/bin/lib/milestone.cjs +27 -30
  30. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  31. package/gsd-core/bin/lib/phase.cjs +41 -72
  32. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  33. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  34. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  35. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  36. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  37. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  38. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  40. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  41. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  42. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
  43. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  44. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  45. package/gsd-core/bin/lib/state-io.cjs +55 -0
  46. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  47. package/gsd-core/bin/lib/state.cjs +306 -681
  48. package/gsd-core/bin/lib/surface.cjs +4 -1
  49. package/gsd-core/bin/lib/workstream.cjs +4 -4
  50. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  51. package/gsd-core/references/honest-verifier.md +105 -0
  52. package/gsd-core/references/reviewer-instances.md +99 -0
  53. package/gsd-core/workflows/autonomous.md +9 -9
  54. package/gsd-core/workflows/manager.md +15 -15
  55. package/gsd-core/workflows/plan-phase.md +1 -1
  56. package/gsd-core/workflows/review.md +26 -0
  57. package/gsd-core/workflows/thread.md +4 -4
  58. package/gsd-core/workflows/verify-phase.md +11 -4
  59. package/hooks/dist/gsd-graphify-update.sh +7 -1
  60. package/hooks/gsd-graphify-update.sh +7 -1
  61. package/package.json +4 -4
  62. package/scripts/ci-test-scope.cjs +38 -9
  63. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  64. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  65. package/scripts/lint-test-file-count.allowlist.json +19 -5
  66. package/scripts/mutation-matrix.cjs +45 -3
  67. package/scripts/prompt-injection-scan.sh +8 -0
  68. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -33,6 +33,9 @@ const frontmatter = require("./frontmatter.cjs");
33
33
  const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const stateTransitionMod = require("./state-transition.cjs");
38
+ const { transitionCore, applyStatePreservation } = stateTransitionMod;
36
39
  const state_document_cjs_1 = require("./state-document.cjs");
37
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
38
41
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
@@ -253,21 +256,18 @@ function cmdStatePatch(cwd, patches, raw) {
253
256
  }
254
257
  const statePath = planningPaths(cwd).state;
255
258
  try {
256
- const results = { updated: [], failed: [] };
257
259
  const shouldResync = shouldResyncStateProgress(Object.keys(patches));
258
- // Use atomic read-modify-write to prevent lost updates from concurrent agents
260
+ // ADR-1769 Phase 6: dispatches to the STATE.md Transition Module. The
261
+ // per-patch stateReplaceField loop is the pure `patchCore` in
262
+ // src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
263
+ // #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
264
+ // delta (table-driven) that this phase adds. Field-name validation (security)
265
+ // and the resync-progress decision stay in this adapter.
266
+ let results = { updated: [], failed: [] };
259
267
  readModifyWriteStateMd(statePath, (content) => {
260
- for (const [field, value] of Object.entries(patches)) {
261
- const result = (0, state_document_cjs_1.stateReplaceField)(content, field, value);
262
- if (result) {
263
- content = result;
264
- results.updated.push(field);
265
- }
266
- else {
267
- results.failed.push(field);
268
- }
269
- }
270
- return content;
268
+ const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
269
+ results = result.data ?? results;
270
+ return result.content;
271
271
  }, cwd, { resync: shouldResync });
272
272
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
273
273
  }
@@ -290,20 +290,16 @@ function cmdStateUpdate(cwd, field, value) {
290
290
  try {
291
291
  let updated = false;
292
292
  const shouldResync = shouldResyncStateProgress([field]);
293
+ // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
294
+ // body-strip/reassemble single-field update is the pure `updateCore` in
295
+ // src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
296
+ // #1230/#1264/#1695 post-sync preservation, and the no-op write guard.
293
297
  // Preserve curated progress for body-only updates, but allow fields that
294
298
  // directly project into progress.* frontmatter to rebuild after mutation.
295
299
  readModifyWriteStateMd(statePath, (content) => {
296
- const body = stripFrontmatter(content);
297
- const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
298
- if (result) {
299
- updated = true;
300
- const existingFm = extractFrontmatter(content);
301
- if (Object.keys(existingFm).length > 0) {
302
- return `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`;
303
- }
304
- return result;
305
- }
306
- return content;
300
+ const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
301
+ updated = result.data?.updated === true;
302
+ return result.content;
307
303
  }, cwd, { resync: shouldResync });
308
304
  if (updated) {
309
305
  output({ updated: true }, false, undefined);
@@ -338,185 +334,37 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
338
334
  `This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
339
335
  return content;
340
336
  }
341
- /**
342
- * Update fields within the ## Current Position section of STATE.md.
343
- * This keeps the Current Position body in sync with the bold frontmatter fields.
344
- * Only updates fields that already exist in the section; does not add new lines.
345
- * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
346
- */
347
- function updateCurrentPositionFields(content, fields) {
348
- // ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the
349
- // untrimmed body span, apply field edits, then splice the modified body back in.
350
- // Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2.
351
- const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
352
- const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
353
- if (posIdx === -1)
354
- return content;
355
- const posHeading = headings[posIdx];
356
- const lines = content.split('\n');
357
- const posHeadingLine = lines[posHeading.line - 1];
358
- const posBodyStart = posHeading.offset + posHeadingLine.length + 1;
359
- let posBodyEnd = content.length;
360
- for (let j = posIdx + 1; j < headings.length; j++) {
361
- if (STOP_H2_PLUS(headings[j].level)) {
362
- posBodyEnd = headings[j].offset - 1;
363
- break;
364
- }
365
- }
366
- let posBody = content.slice(posBodyStart, posBodyEnd);
367
- const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
368
- const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
369
- if (fields.status) {
370
- if (/^Status:/m.test(posBody)) {
371
- // Inline format: Status: value — only replace when the existing value is a
372
- // known template default (Knuth invariant: preserve executor-authored values).
373
- const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
374
- const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
375
- const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
376
- const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
377
- const isDefault = !existingStatus || isInList || matchesPattern;
378
- if (isDefault) {
379
- posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
380
- }
381
- }
382
- else {
383
- // Table format: | Status | value | — apply the same preserve-authored guard
384
- // as the inline branch: only overwrite a known template default.
385
- // (Finding 2 code-review: the table branch was unconditional before this fix.)
386
- const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
387
- const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
388
- const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
389
- const isDefault = !existingStatus || isInList || matchesPattern;
390
- if (isDefault) {
391
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
392
- if (replaced !== null)
393
- posBody = replaced;
394
- }
395
- }
396
- }
397
- if (fields.lastActivity) {
398
- if (/^Last activity:/im.test(posBody)) {
399
- // Inline format — only replace when the existing value is a known template
400
- // default (a bare ISO date). Executor-authored narrative prose is preserved.
401
- const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
402
- const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
403
- // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
404
- // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
405
- // was authored by the executor and must be preserved.
406
- const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
407
- const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
408
- const isDefault = !existingActivity || isDateShape || inList;
409
- if (isDefault) {
410
- posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
411
- }
412
- }
413
- else {
414
- // Table format — apply the same preserve-authored guard as the inline branch:
415
- // only overwrite a bare ISO date or a known default; preserve narrative prose.
416
- // (Finding 2 code-review: the table branch was unconditional before this fix.)
417
- const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
418
- ?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
419
- const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
420
- const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
421
- const isDefault = !existingActivity || isDateShape || inList;
422
- if (isDefault) {
423
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
424
- ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
425
- if (replaced !== null)
426
- posBody = replaced;
427
- }
428
- }
429
- }
430
- if (fields.plan) {
431
- if (/^Plan:/m.test(posBody)) {
432
- posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
433
- }
434
- else {
435
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
436
- if (replaced !== null)
437
- posBody = replaced;
438
- }
439
- }
440
- // Splice the modified body back in place of the original untrimmed span.
441
- return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd);
442
- }
443
337
  function cmdStateAdvancePlan(cwd, raw) {
444
338
  const statePath = planningPaths(cwd).state;
445
339
  if (!node_fs_1.default.existsSync(statePath)) {
446
340
  output({ error: 'STATE.md not found' }, raw, undefined);
447
341
  return;
448
342
  }
449
- const today = clock_cjs_1.realClock.today();
450
- let result = null;
343
+ // ADR-1769 Phase 2: dispatches to the STATE.md Transition Module. The
344
+ // ~80-line RMW callback that used to live here (plan parsing, advance vs
345
+ // phase-complete branching, template-default-aware field replacement,
346
+ // Current Position section mutation) is now the pure `advancePlanCore`
347
+ // function in src/state-transition.cts.
348
+ const intent = { kind: 'advancePlan' };
349
+ const deps = {
350
+ clock: clock_cjs_1.realClock,
351
+ progressProvider: () => null,
352
+ };
353
+ let resultData;
451
354
  readModifyWriteStateMd(statePath, (content) => {
452
- // Try legacy separate fields first, then compound "Plan: X of Y" format
453
- const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
454
- const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
455
- const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
456
- let currentPlan, totalPlans;
457
- let useCompoundFormat = false;
458
- if (legacyPlan && legacyTotal) {
459
- currentPlan = parseInt(legacyPlan, 10);
460
- totalPlans = parseInt(legacyTotal, 10);
461
- }
462
- else if (planField) {
463
- // Compound format: "2 of 6 in current phase" or "2 of 6"
464
- currentPlan = parseInt(planField, 10);
465
- const ofMatch = planField.match(/of\s+(\d+)/);
466
- totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
467
- useCompoundFormat = true;
468
- }
469
- else {
470
- currentPlan = NaN;
471
- totalPlans = NaN;
472
- }
473
- if (isNaN(currentPlan) || isNaN(totalPlans)) {
474
- result = { error: true };
475
- return content;
476
- }
477
- const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
478
- const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
479
- if (currentPlan >= totalPlans) {
480
- // Phase-complete branch — only replace Status/Last Activity when the existing
481
- // value is a known template default (Knuth invariant: preserve executor-authored).
482
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
483
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
484
- // stateReplaceFieldWithFallback tries 'Last activity' alias too
485
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
486
- content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
487
- result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
488
- }
489
- else {
490
- const newPlan = currentPlan + 1;
491
- let planDisplayValue;
492
- if (useCompoundFormat) {
493
- // Preserve compound format: "X of Y in current phase" → replace X only
494
- planDisplayValue = planField.replace(/^\d+/, String(newPlan));
495
- content = (0, state_document_cjs_1.stateReplaceField)(content, 'Plan', planDisplayValue) || content;
496
- }
497
- else {
498
- planDisplayValue = `${newPlan} of ${totalPlans}`;
499
- content = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Plan', String(newPlan)) || content;
500
- }
501
- // Normal advance — only replace Status/Last Activity when the existing value is
502
- // a known template default (Knuth invariant: preserve executor-authored).
503
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Ready to execute');
504
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
505
- content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
506
- content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
507
- result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
508
- }
509
- return content;
355
+ const result = transitionCore(content, intent, deps);
356
+ resultData = result.data;
357
+ return result.content;
510
358
  }, cwd);
511
- if (!result || result['error']) {
359
+ if (!resultData || resultData['error']) {
512
360
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
513
361
  return;
514
362
  }
515
- if (result['advanced'] === false) {
516
- output(result, raw, 'false');
363
+ if (resultData['advanced'] === false) {
364
+ output(resultData, raw, 'false');
517
365
  }
518
366
  else {
519
- output(result, raw, 'true');
367
+ output(resultData, raw, 'true');
520
368
  }
521
369
  }
522
370
  function cmdStateRecordMetric(cwd, options, raw) {
@@ -1366,6 +1214,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
1366
1214
  let completedPhases = null;
1367
1215
  let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1368
1216
  let completedPlans = null;
1217
+ // #1761 read-path: set from cached.milestoneBounded inside the disk-scan
1218
+ // block; consumed at the percent computation to mirror the cmdStateSync guard.
1219
+ let milestoneUnbounded = false;
1369
1220
  if (cwd) {
1370
1221
  try {
1371
1222
  const phasesDir = planningPaths(cwd).phases;
@@ -1379,10 +1230,11 @@ function buildStateFrontmatter(bodyContent, cwd) {
1379
1230
  // exclusion (#1514). Computed before the disk scan so retired phases
1380
1231
  // can be dropped from the dir set too.
1381
1232
  let roadmapScope = null;
1233
+ let roadmapRaw = null;
1382
1234
  let retiredPhaseNums = new Set();
1383
1235
  try {
1384
1236
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1385
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1237
+ roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1386
1238
  if (roadmapRaw !== null) {
1387
1239
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1388
1240
  retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
@@ -1457,20 +1309,36 @@ function buildStateFrontmatter(bodyContent, cwd) {
1457
1309
  roadmapPhaseCount++;
1458
1310
  }
1459
1311
  }
1460
- cached = {
1461
- totalPhases: roadmapPhaseCount > 0
1462
- ? Math.max(phaseDirs.length, roadmapPhaseCount)
1463
- : phaseDirs.length,
1464
- completedPhases: diskCompletedPhases,
1465
- totalPlans: diskTotalPlans,
1466
- completedPlans: diskTotalSummaries,
1467
- };
1312
+ cached = (() => {
1313
+ // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
1314
+ // asserted milestone version can't be bounded to a versioned ROADMAP
1315
+ // heading, extractCurrentMilestone falls back to the whole document
1316
+ // and roadmapPhaseCount conflates sibling milestones. In that case
1317
+ // don't substitute the whole-doc count — fall back to the on-disk
1318
+ // phase-dir count only, and mark unbounded so percent is skipped
1319
+ // downstream (mirrors the sync write-path guard).
1320
+ let milestoneBounded = true;
1321
+ if (milestone && roadmapRaw !== null) {
1322
+ const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`, 'mi');
1323
+ milestoneBounded = versionedHeading.test(roadmapRaw);
1324
+ }
1325
+ return {
1326
+ totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
1327
+ ? phaseDirs.length
1328
+ : Math.max(phaseDirs.length, roadmapPhaseCount),
1329
+ milestoneBounded,
1330
+ completedPhases: diskCompletedPhases,
1331
+ totalPlans: diskTotalPlans,
1332
+ completedPlans: diskTotalSummaries,
1333
+ };
1334
+ })();
1468
1335
  _diskScanCache.set(cwd, cached);
1469
1336
  }
1470
1337
  totalPhases = cached.totalPhases;
1471
1338
  completedPhases = cached.completedPhases;
1472
1339
  totalPlans = cached.totalPlans;
1473
1340
  completedPlans = cached.completedPlans;
1341
+ milestoneUnbounded = cached.milestoneBounded === false;
1474
1342
  }
1475
1343
  }
1476
1344
  catch { /* intentionally empty */ }
@@ -1481,7 +1349,11 @@ function buildStateFrontmatter(bodyContent, cwd) {
1481
1349
  // instead of a false 100% from plan-only coverage (#3242 Bug B).
1482
1350
  // Falls back to the body Progress: field only when no plan files exist on disk.
1483
1351
  let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases);
1484
- if (progressPercent === null && progressRaw) {
1352
+ // #1761 read-path: when the milestone can't be bounded, percent would be
1353
+ // derived from a conflated/understated total — skip it (mirror cmdStateSync).
1354
+ if (milestoneUnbounded)
1355
+ progressPercent = null;
1356
+ if (progressPercent === null && progressRaw && !milestoneUnbounded) {
1485
1357
  const pctMatch = progressRaw.match(/(\d+)%/);
1486
1358
  if (pctMatch)
1487
1359
  progressPercent = parseInt(pctMatch[1], 10);
@@ -1779,7 +1651,7 @@ function acquireStateLock(statePath, clock) {
1779
1651
  const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++);
1780
1652
  let renamed = false;
1781
1653
  try {
1782
- node_fs_1.default.renameSync(lockPath, stolen);
1654
+ (0, shell_command_projection_cjs_1.retryRenameSync)(lockPath, stolen);
1783
1655
  renamed = true;
1784
1656
  }
1785
1657
  catch { /* another racer won */ }
@@ -1920,6 +1792,13 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1920
1792
  const preSessionMatch = matchSessionSection(preBody);
1921
1793
  const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1922
1794
  const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1795
+ // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
1796
+ // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
1797
+ // this write does NOT change that line, the curated frontmatter value must
1798
+ // win over syncStateFrontmatter's body re-derivation (which can harvest a
1799
+ // wrong parenthetical aside — #1695). Gated by the field-classification
1800
+ // table's preserve-always row so the rule lives in one place.
1801
+ const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
1923
1802
  const modified = transformFn(content);
1924
1803
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
1925
1804
  // the file. An unconditional write would bump `last_updated`, reset
@@ -1932,13 +1811,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1932
1811
  return;
1933
1812
  }
1934
1813
  let synced = syncStateFrontmatter(modified, cwd);
1935
- // Compute postFm once and apply BOTH the progress-restore (when !resync)
1936
- // AND the status/stopped_at preservation (#1230) before reconstructing.
1937
- // This avoids double-wrapping the frontmatter block.
1938
- const needsProgressRestore = !resync && preFm && preFm['progress'];
1939
1814
  // Post-transform body source fields used for the delta comparison (#1230).
1940
1815
  // 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.
1941
- // Strip frontmatter so the YAML status key cannot shadow the body field.
1816
+ // Strip frontmatter so the YAML status key cannot shadow the body field we are tracking.
1942
1817
  const postBody = stripFrontmatter(modified);
1943
1818
  const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
1944
1819
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
@@ -1946,40 +1821,25 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1946
1821
  const postSessionMatch = matchSessionSection(postBody);
1947
1822
  const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1948
1823
  const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1949
- let mutated = false;
1824
+ // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
1825
+ // current_phase_name delta comparison.
1826
+ const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
1827
+ // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
1828
+ // preservation block is now the pure, table-driven `applyStatePreservation`
1829
+ // in the STATE.md Transition Module. progress / status / stopped_at /
1830
+ // current_phase_name are all governed by their FIELD_CLASSIFICATION row —
1831
+ // one policy source, not three drifting encodings. Behavior-identical to
1832
+ // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
1833
+ // already claimed shipped.
1950
1834
  const postFm = extractFrontmatter(synced);
1951
- if (needsProgressRestore) {
1952
- // Re-apply the curated progress block that syncStateFrontmatter just
1953
- // overwrote with disk-derived values. Only restore keys that were present
1954
- // in the snapshot — this preserves any new non-progress frontmatter fields
1955
- // (e.g., status, current_phase) that syncStateFrontmatter legitimately
1956
- // derived from the updated body.
1957
- postFm['progress'] = preFm['progress'];
1958
- mutated = true;
1959
- }
1960
- // Bug #1230: preserve existing frontmatter status when this write did NOT
1961
- // change the body's Status field. A write that doesn't touch Status must
1962
- // not silently revert a hand-set frontmatter status (e.g. 'completed') to
1963
- // whatever the stale body Status happens to derive (e.g. 'verifying').
1964
- // Only apply when the existing frontmatter held a real, non-unknown status.
1965
- if (postBodyStatus === preBodyStatus &&
1966
- typeof preFmSnapshot['status'] === 'string' &&
1967
- preFmSnapshot['status'].length > 0 &&
1968
- preFmSnapshot['status'] !== 'unknown' &&
1969
- postFm['status'] !== preFmSnapshot['status']) {
1970
- postFm['status'] = preFmSnapshot['status'];
1971
- mutated = true;
1972
- }
1973
- // Bug #1230: same delta heuristic for stopped_at.
1974
- if (postBodyStoppedAt === preBodyStoppedAt &&
1975
- typeof preFmSnapshot['stopped_at'] === 'string' &&
1976
- preFmSnapshot['stopped_at'].length > 0 &&
1977
- postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
1978
- postFm['stopped_at'] = preFmSnapshot['stopped_at'];
1979
- mutated = true;
1980
- }
1981
- if (mutated) {
1982
- const yamlStr = reconstructFrontmatter(postFm);
1835
+ const preservation = applyStatePreservation({
1836
+ preFm, postFm, preFmSnapshot, resync,
1837
+ preBodyStatus, postBodyStatus,
1838
+ preBodyStoppedAt, postBodyStoppedAt,
1839
+ preBodyPhaseSource, postBodyPhaseSource,
1840
+ });
1841
+ if (preservation.mutated) {
1842
+ const yamlStr = reconstructFrontmatter(preservation.postFm);
1983
1843
  const body = stripFrontmatter(synced);
1984
1844
  synced = `---\n${yamlStr}\n---\n\n${body}`;
1985
1845
  }
@@ -2044,201 +1904,27 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2044
1904
  output({ error: 'STATE.md not found' }, raw, undefined);
2045
1905
  return;
2046
1906
  }
2047
- const today = clock_cjs_1.realClock.today();
2048
- const updated = [];
1907
+ // ADR-1769 Phase 1: dispatches to the STATE.md Transition Module. The 175-line
1908
+ // RMW callback that used to live here (format detection + preservation policy
1909
+ // + section mutation + idempotency guard + resume branching) is now the pure
1910
+ // `transitionCore` function in src/state-transition.cts, backed by the
1911
+ // field-classification table. readModifyWriteStateMd still owns the lock,
1912
+ // #1230 post-sync preservation, and the no-op write guard.
1913
+ const intent = {
1914
+ kind: 'beginPhase',
1915
+ phaseNumber,
1916
+ phaseName: phaseName ?? null,
1917
+ planCount: planCount ?? null,
1918
+ };
1919
+ const deps = {
1920
+ clock: clock_cjs_1.realClock,
1921
+ progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
1922
+ };
1923
+ let updated = [];
2049
1924
  readModifyWriteStateMd(statePath, (content) => {
2050
- // Bug #1255: all body-field replacements must operate on the body only
2051
- // (frontmatter stripped), not on the full content. When the full content is
2052
- // passed to stateReplaceField the YAML `status: planning` key matches the
2053
- // plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
2054
- // pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
2055
- // re-derives 'planning' from the unchanged body — the status never advances.
2056
- const existingFm = extractFrontmatter(content);
2057
- const hasFrontmatter = Object.keys(existingFm).length > 0;
2058
- let body = stripFrontmatter(content);
2059
- // Helper to reassemble content for field-replacement checks; callers that
2060
- // only need to test/replace body fields use `body` directly, and the final
2061
- // return reassembles the frontmatter block with the updated body.
2062
- const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
2063
- // Idempotency guard (#3127): if the phase is already mid-flight, do NOT
2064
- // overwrite execution-progress fields (Current Plan, plan body line,
2065
- // Last Activity Description). Only update fields that are safe to
2066
- // refresh on resume (Last Activity date, Status if inconsistent).
2067
- // A phase is considered mid-flight when Status contains 'Executing Phase N'
2068
- // for the current phase number.
2069
- // #1255: extract from body (not full content) so the YAML `status:` key
2070
- // cannot shadow the body Status field.
2071
- const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
2072
- const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
2073
- // Update Status field (body only — #1255)
2074
- const statusValue = `Executing Phase ${phaseNumber}`;
2075
- let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
2076
- if (result) {
2077
- body = result;
2078
- updated.push('Status');
2079
- }
2080
- // Update Last Activity (safe to update on resume — tracks when execute-phase ran)
2081
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
2082
- if (result) {
2083
- body = result;
2084
- updated.push('Last Activity');
2085
- }
2086
- if (!isAlreadyExecuting) {
2087
- // First-time execution: set all progress fields
2088
- // Update Last Activity Description
2089
- const activityDesc = `Phase ${phaseNumber} execution started`;
2090
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
2091
- if (result) {
2092
- body = result;
2093
- updated.push('Last Activity Description');
2094
- }
2095
- // Update Current Phase
2096
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
2097
- if (result) {
2098
- body = result;
2099
- updated.push('Current Phase');
2100
- }
2101
- // Update Current Phase Name
2102
- if (phaseName) {
2103
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
2104
- if (result) {
2105
- body = result;
2106
- updated.push('Current Phase Name');
2107
- }
2108
- }
2109
- // Update Current Plan to 1 (starting from the first plan)
2110
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
2111
- if (result) {
2112
- body = result;
2113
- updated.push('Current Plan');
2114
- }
2115
- // Update Total Plans in Phase
2116
- if (planCount) {
2117
- result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
2118
- if (result) {
2119
- body = result;
2120
- updated.push('Total Plans in Phase');
2121
- }
2122
- }
2123
- // Update **Current focus:** body text line (#1104)
2124
- const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
2125
- const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
2126
- if (focusPattern.test(body)) {
2127
- body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
2128
- updated.push('Current focus');
2129
- }
2130
- // Update ## Current Position section (#1104, #1365)
2131
- // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
2132
- // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
2133
- const posHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
2134
- const posIdx = posHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
2135
- if (posIdx !== -1) {
2136
- const posH = posHs[posIdx];
2137
- const bodyLines = body.split('\n');
2138
- const posHL = bodyLines[posH.line - 1];
2139
- const posBodyStart = posH.offset + posHL.length + 1;
2140
- let posBodyEnd = body.length;
2141
- for (let j = posIdx + 1; j < posHs.length; j++) {
2142
- if (STOP_H2_PLUS(posHs[j].level)) {
2143
- posBodyEnd = posHs[j].offset - 1;
2144
- break;
2145
- }
2146
- }
2147
- let posBody = body.slice(posBodyStart, posBodyEnd);
2148
- // Update or insert Phase line
2149
- const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
2150
- if (/^Phase:/m.test(posBody)) {
2151
- posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2152
- }
2153
- else {
2154
- // Pipe-table format in Current Position (#1257): update the | Phase | … |
2155
- // cell rather than prepending a spurious inline `Phase:` line (which left
2156
- // the table cell stale). Mirrors the Status/Last-activity table branches.
2157
- const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
2158
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
2159
- if (replaced !== null)
2160
- posBody = replaced;
2161
- }
2162
- // Update or insert Plan line
2163
- const newPlan = `Plan: 1 of ${planCount || '?'}`;
2164
- if (/^Plan:/m.test(posBody)) {
2165
- posBody = posBody.replace(/^Plan:.*$/m, newPlan);
2166
- }
2167
- else {
2168
- // Pipe-table format in Current Position (#1257): update the | Plan | … |
2169
- // cell rather than appending after a prepended inline line.
2170
- const planValue = `1 of ${planCount || '?'}`;
2171
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
2172
- if (replaced !== null)
2173
- posBody = replaced;
2174
- }
2175
- // Update Status line if present
2176
- const newStatus = `Status: Executing Phase ${phaseNumber}`;
2177
- if (/^Status:/m.test(posBody)) {
2178
- posBody = posBody.replace(/^Status:.*$/m, newStatus);
2179
- }
2180
- else {
2181
- // Pipe-table format in Current Position (#1255)
2182
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
2183
- if (replaced !== null)
2184
- posBody = replaced;
2185
- }
2186
- // Update Last activity line if present
2187
- const newActivity = `Last activity: ${today} — Phase ${phaseNumber} execution started`;
2188
- if (/^Last activity:/im.test(posBody)) {
2189
- posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2190
- }
2191
- else {
2192
- // Pipe-table format in Current Position (#1255)
2193
- // Value must match the inline branch (date + narrative), not bare date.
2194
- const activityValue = `${today} — Phase ${phaseNumber} execution started`;
2195
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2196
- ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2197
- if (replaced !== null)
2198
- posBody = replaced;
2199
- }
2200
- body = body.slice(0, posBodyStart) + posBody + body.slice(posBodyEnd);
2201
- updated.push('Current Position');
2202
- }
2203
- }
2204
- else {
2205
- // Resume path: only update Last activity timestamp in Current Position
2206
- // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
2207
- // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
2208
- const posHsR = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
2209
- const posIdxR = posHsR.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
2210
- if (posIdxR !== -1) {
2211
- const posHR = posHsR[posIdxR];
2212
- const bodyLinesR = body.split('\n');
2213
- const posHLR = bodyLinesR[posHR.line - 1];
2214
- const posBodyStartR = posHR.offset + posHLR.length + 1;
2215
- let posBodyEndR = body.length;
2216
- for (let j = posIdxR + 1; j < posHsR.length; j++) {
2217
- if (STOP_H2_PLUS(posHsR[j].level)) {
2218
- posBodyEndR = posHsR[j].offset - 1;
2219
- break;
2220
- }
2221
- }
2222
- let posBody = body.slice(posBodyStartR, posBodyEndR);
2223
- const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
2224
- if (/^Last activity:/im.test(posBody)) {
2225
- posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
2226
- body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
2227
- updated.push('Last activity (resume)');
2228
- }
2229
- else {
2230
- // Pipe-table format in Current Position (#1255)
2231
- const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
2232
- ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
2233
- if (replaced !== null) {
2234
- posBody = replaced;
2235
- body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
2236
- updated.push('Last activity (resume)');
2237
- }
2238
- }
2239
- }
2240
- }
2241
- return reassemble(body);
1925
+ const result = transitionCore(content, intent, deps);
1926
+ updated = result.updated;
1927
+ return result.content;
2242
1928
  }, cwd);
2243
1929
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
2244
1930
  }
@@ -2355,63 +2041,28 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2355
2041
  output({ error: 'STATE.md not found' }, raw, undefined);
2356
2042
  return;
2357
2043
  }
2358
- const today = clock_cjs_1.realClock.today();
2359
- const updated = [];
2360
- const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
2361
- const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
2362
- // plan-phase updates per-phase body fields only. It must NOT resync the
2363
- // milestone-wide progress.* frontmatter from a half-planned disk snapshot —
2364
- // doing so tramples curated/known-good counters. Route through the body-only
2365
- // write contract (resync:false), the same guard state.update uses. (#500 RC1)
2044
+ // ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The RMW
2045
+ // callback that lived here (body strip/reassemble, template-aware Status +
2046
+ // Last Activity, Total Plans in Phase, Last Activity Description, Current
2047
+ // Position section update) is the pure `plannedPhaseCore` in
2048
+ // src/state-transition.cts, backed by the field-classification table.
2049
+ // resync:false is preserved: plan-phase must NOT re-derive milestone-wide
2050
+ // progress.* from a half-planned disk snapshot (#500 RC1). readModifyWriteStateMd
2051
+ // still owns the lock, the #1230 preservation, and the no-op write guard.
2052
+ const intent = {
2053
+ kind: 'plannedPhase',
2054
+ phaseNumber,
2055
+ planCount: planCount ?? null,
2056
+ };
2057
+ const deps = {
2058
+ clock: clock_cjs_1.realClock,
2059
+ progressProvider: () => null,
2060
+ };
2061
+ let updated = [];
2366
2062
  readModifyWriteStateMd(statePath, (content) => {
2367
- // Bug #1257: all body-field replacements must operate on the body only
2368
- // (frontmatter stripped), not on the full content. When the full content is
2369
- // passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
2370
- // the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
2371
- // pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
2372
- // re-derives 'planning' from the unchanged body — the status never advances.
2373
- // (Mirrors the begin/complete-phase fix from #1255/#1256.)
2374
- const existingFm = extractFrontmatter(content);
2375
- const hasFrontmatter = Object.keys(existingFm).length > 0;
2376
- let body = stripFrontmatter(content);
2377
- const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
2378
- // Update Status — only when the existing value is a known template default
2379
- // (Knuth invariant: preserve executor-authored values).
2380
- const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
2381
- if (newBody !== body) {
2382
- body = newBody;
2383
- updated.push('Status');
2384
- }
2385
- // Update Total Plans in Phase
2386
- if (planCount !== null && planCount !== undefined) {
2387
- const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
2388
- if (result) {
2389
- body = result;
2390
- updated.push('Total Plans in Phase');
2391
- }
2392
- }
2393
- // Update Last Activity — only when the existing value is a known template default
2394
- {
2395
- const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
2396
- if (after !== body) {
2397
- body = after;
2398
- updated.push('Last Activity');
2399
- }
2400
- }
2401
- // Update Last Activity Description
2402
- {
2403
- const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
2404
- if (result) {
2405
- body = result;
2406
- updated.push('Last Activity Description');
2407
- }
2408
- }
2409
- // Update Current Position section
2410
- body = updateCurrentPositionFields(body, {
2411
- status: 'Ready to execute',
2412
- lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
2413
- });
2414
- return reassemble(body);
2063
+ const result = transitionCore(content, intent, deps);
2064
+ updated = result.updated;
2065
+ return result.content;
2415
2066
  }, cwd, { resync: false });
2416
2067
  output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2417
2068
  }
@@ -2428,57 +2079,19 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2428
2079
  }
2429
2080
  const resolvedName = (name && String(name).trim()) || 'milestone';
2430
2081
  const statePath = planningPaths(cwd).state;
2431
- const today = clock_cjs_1.realClock.today();
2082
+ // ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The reset
2083
+ // policy (frontmatter rebuild + Current Position body reset) is the pure
2084
+ // `milestoneSwitchCore` in src/state-transition.cts. acquireStateLock +
2085
+ // platformWriteSync are retained (NOT readModifyWriteStateMd) because
2086
+ // milestoneSwitch rebuilds frontmatter directly and must not run the
2087
+ // steady-state syncStateFrontmatter post-sync.
2088
+ const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
2089
+ const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null };
2432
2090
  const lockPath = acquireStateLock(statePath);
2433
2091
  try {
2434
2092
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
2435
- const existingFm = extractFrontmatter(content);
2436
- const body = stripFrontmatter(content);
2437
- // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
2438
- // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
2439
- const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
2440
- `Plan: —\n` +
2441
- `Status: Defining requirements\n` +
2442
- `Last activity: ${today} — Milestone ${version} started\n\n`;
2443
- let newBody;
2444
- const msPosHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
2445
- const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
2446
- if (msPosIdx !== -1) {
2447
- const msPosH = msPosHs[msPosIdx];
2448
- const msBodyLines = body.split('\n');
2449
- const msPosHL = msBodyLines[msPosH.line - 1];
2450
- const msPosBodyStart = msPosH.offset + msPosHL.length + 1;
2451
- let msPosBodyEnd = body.length;
2452
- for (let j = msPosIdx + 1; j < msPosHs.length; j++) {
2453
- if (STOP_H2_PLUS(msPosHs[j].level)) {
2454
- msPosBodyEnd = msPosHs[j].offset - 1;
2455
- break;
2456
- }
2457
- }
2458
- newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd);
2459
- }
2460
- else {
2461
- const preface = body.trim().length > 0 ? body : '# Project State\n';
2462
- newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
2463
- }
2464
- const fm = {
2465
- gsd_state_version: existingFm['gsd_state_version'] || '1.0',
2466
- milestone: version,
2467
- milestone_name: resolvedName,
2468
- status: 'planning',
2469
- last_updated: clock_cjs_1.realClock.nowIso(),
2470
- last_activity: today,
2471
- progress: {
2472
- total_phases: 0,
2473
- completed_phases: 0,
2474
- total_plans: 0,
2475
- completed_plans: 0,
2476
- percent: 0,
2477
- },
2478
- };
2479
- const yamlStr = reconstructFrontmatter(fm);
2480
- const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
2481
- (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, assembled);
2093
+ const result = transitionCore(content, intent, deps);
2094
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
2482
2095
  output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
2483
2096
  }
2484
2097
  finally {
@@ -2559,7 +2172,6 @@ function cmdStateSync(cwd, options, raw) {
2559
2172
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2560
2173
  const changes = [];
2561
2174
  let modified = content;
2562
- const today = clock_cjs_1.realClock.today();
2563
2175
  const phasesDir = planningPaths(cwd).phases;
2564
2176
  if (!node_fs_1.default.existsSync(phasesDir)) {
2565
2177
  output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
@@ -2570,10 +2182,12 @@ function cmdStateSync(cwd, options, raw) {
2570
2182
  // exactly as buildStateFrontmatter does — otherwise `state sync --verify`
2571
2183
  // would keep re-deriving the inflated denominator and report "no drift".
2572
2184
  let syncRoadmapScope = null;
2185
+ let syncRoadmapRaw = null;
2573
2186
  let syncRetiredPhaseNums = new Set();
2574
2187
  try {
2575
2188
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
2576
2189
  if (roadmapRaw !== null) {
2190
+ syncRoadmapRaw = roadmapRaw;
2577
2191
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2578
2192
  syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
2579
2193
  }
@@ -2653,46 +2267,32 @@ function cmdStateSync(cwd, options, raw) {
2653
2267
  }
2654
2268
  }
2655
2269
  catch { /* intentionally empty */ }
2656
- // Sync Total Plans in Phase
2657
- if (highestIncompletePhase) {
2658
- const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
2659
- if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
2660
- changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
2661
- const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
2662
- if (result)
2663
- modified = result;
2664
- }
2270
+ // ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last
2271
+ // Activity) are the pure `syncCore` in src/state-transition.cts.
2272
+ // #1761: when a milestone version is set in frontmatter but the ROADMAP has no
2273
+ // versioned heading for it, the milestone cannot be bounded to a versioned phase
2274
+ // set — leave Progress untouched (percent=null) rather than silently writing
2275
+ // fallback-derived wrong values. Projects without a milestone version (the common
2276
+ // sync-test shape) are unaffected: the gate only fires when a version is asserted.
2277
+ const fmVersion = extractFrontmatter(content).milestone;
2278
+ const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
2279
+ let milestoneBounded = true;
2280
+ if (versionStr !== null && syncRoadmapRaw !== null) {
2281
+ const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
2282
+ milestoneBounded = versionedHeading.test(syncRoadmapRaw);
2283
+ }
2284
+ let percent = null;
2285
+ if (!milestoneBounded) {
2286
+ changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
2665
2287
  }
2666
- // Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
2667
- // computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
2668
- // ROADMAP phases cap the reported percent rather than allowing a false 100%.
2669
- const percent = (() => {
2288
+ else {
2670
2289
  const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2671
- return p !== null ? p : 0;
2672
- })();
2673
- const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
2674
- if (currentProgress) {
2675
- const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
2676
- if (currentPercent !== percent) {
2677
- const barWidth = 10;
2678
- const filled = Math.round(percent / 100 * barWidth);
2679
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
2680
- const progressStr = `[${bar}] ${percent}%`;
2681
- changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
2682
- const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
2683
- if (result)
2684
- modified = result;
2685
- }
2686
- }
2687
- // Sync Last Activity
2688
- const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today);
2689
- if (result) {
2690
- const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity');
2691
- if (oldActivity !== today) {
2692
- changes.push(`Last Activity: ${oldActivity} -> ${today}`);
2693
- }
2694
- modified = result;
2290
+ percent = p !== null ? p : 0;
2695
2291
  }
2292
+ const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
2293
+ modified = syncResult.content;
2294
+ const coreChanges = syncResult.data?.changes ?? [];
2295
+ changes.push(...coreChanges);
2696
2296
  if (verify) {
2697
2297
  output({ synced: false, changes, dry_run: true }, raw, undefined);
2698
2298
  return;
@@ -2721,8 +2321,13 @@ function cmdStatePrune(cwd, options, raw) {
2721
2321
  }
2722
2322
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2723
2323
  const dryRun = !!options.dryRun;
2724
- const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(node_fs_1.default.readFileSync(statePath, 'utf-8'), 'Current Phase');
2725
- const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
2324
+ // #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not
2325
+ // `Current Phase:`. Read both (mirroring buildStateFrontmatter /
2326
+ // resolvePhaseIdForCompletePhase) so prune engages on template-conformant
2327
+ // STATE.md instead of bailing with "Only 0 phases — nothing to prune".
2328
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2329
+ const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(rawState, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(rawState, 'Phase');
2330
+ const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
2726
2331
  const cutoff = currentPhase - keepRecent;
2727
2332
  if (cutoff <= 0) {
2728
2333
  emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
@@ -2730,112 +2335,21 @@ function cmdStatePrune(cwd, options, raw) {
2730
2335
  }
2731
2336
  const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
2732
2337
  const archived = [];
2733
- // Shared pruning logic applied to both dry-run and real passes.
2734
- // Returns { newContent, archivedSections }.
2735
- // ADR-1372 T6: all four inline section-collect regexes replaced with
2736
- // tokenizeHeadings + untrimmed-span splicing for byte-identical writes.
2737
- function prunePass(content) {
2738
- const sections = [];
2739
- // Helper: locate a heading matching pred, extract untrimmed body [bs, se),
2740
- // apply transform, and splice back. Returns updated content.
2741
- // All prune-section patterns stop at level 2 or 3 (STOP_H2_H3).
2742
- function pruneSectionSpan(c, pred, transform, sectionName) {
2743
- const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c);
2744
- const i = hs.findIndex(h => pred(h.level, h.text));
2745
- if (i === -1)
2746
- return c;
2747
- const h = hs[i];
2748
- const ls = c.split('\n');
2749
- const hl = ls[h.line - 1];
2750
- const bs = h.offset + hl.length + 1;
2751
- let se = c.length;
2752
- for (let j = i + 1; j < hs.length; j++) {
2753
- if (STOP_H2_H3(hs[j].level)) {
2754
- se = hs[j].offset - 1;
2755
- break;
2756
- }
2757
- }
2758
- const body = c.slice(bs, se);
2759
- const { keep, archive } = transform(body);
2760
- if (archive.length > 0) {
2761
- sections.push({ section: sectionName, count: archive.length, lines: archive });
2762
- return c.slice(0, bs) + keep.join('\n') + c.slice(se);
2763
- }
2764
- return c;
2765
- }
2766
- // Prune Decisions section: entries like "- [Phase N]: ..."
2767
- content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => {
2768
- const keep = [], archive = [];
2769
- for (const line of body.split('\n')) {
2770
- const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
2771
- if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2772
- archive.push(line);
2773
- }
2774
- else {
2775
- keep.push(line);
2776
- }
2777
- }
2778
- return { keep, archive };
2779
- }, 'Decisions');
2780
- // Prune Recently Completed section: entries mentioning phase numbers
2781
- content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => {
2782
- const keep = [], archive = [];
2783
- for (const line of body.split('\n')) {
2784
- const phaseMatch = line.match(/Phase\s+(\d+)/i);
2785
- if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2786
- archive.push(line);
2787
- }
2788
- else {
2789
- keep.push(line);
2790
- }
2791
- }
2792
- return { keep, archive };
2793
- }, 'Recently Completed');
2794
- // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
2795
- // or "[RESOLVED]" prefix) with a phase reference older than cutoff
2796
- content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => {
2797
- const keep = [], archive = [];
2798
- for (const line of body.split('\n')) {
2799
- const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
2800
- const phaseMatch = line.match(/Phase\s+(\d+)/i);
2801
- if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2802
- archive.push(line);
2803
- }
2804
- else {
2805
- keep.push(line);
2806
- }
2807
- }
2808
- return { keep, archive };
2809
- }, 'Blockers (resolved)');
2810
- // Prune Performance Metrics table rows: keep only rows for phases > cutoff.
2811
- // Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
2812
- content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => {
2813
- const keep = [], archive = [];
2814
- for (const line of body.split('\n')) {
2815
- // Table data row: starts with | followed by a number (phase)
2816
- const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
2817
- if (tableRowMatch) {
2818
- const rowPhase = parseInt(tableRowMatch[1], 10);
2819
- if (rowPhase <= cutoff) {
2820
- archive.push(line);
2821
- }
2822
- else {
2823
- keep.push(line);
2824
- }
2825
- }
2826
- else {
2827
- // Header row, separator row, or prose — always keep
2828
- keep.push(line);
2829
- }
2830
- }
2831
- return { keep, archive };
2832
- }, 'Performance Metrics');
2833
- return { newContent: content, archivedSections: sections };
2834
- }
2338
+ // ADR-1769 Phase 7: the section-pruning is the pure `pruneCore` in
2339
+ // src/state-transition.cts (byte-identical tokenizeHeadings section splicing).
2340
+ // This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
2341
+ // fallback above), dry-run, and STATE-ARCHIVE.md writes.
2342
+ const runPruneCore = (content) => {
2343
+ const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
2344
+ return {
2345
+ newContent: result.content,
2346
+ archivedSections: (result.data?.archivedSections) ?? [],
2347
+ };
2348
+ };
2835
2349
  if (dryRun) {
2836
2350
  // Dry-run: compute what would be pruned without writing anything
2837
2351
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2838
- const result = prunePass(content);
2352
+ const result = runPruneCore(content);
2839
2353
  const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
2840
2354
  emit({
2841
2355
  pruned: false,
@@ -2849,7 +2363,7 @@ function cmdStatePrune(cwd, options, raw) {
2849
2363
  return;
2850
2364
  }
2851
2365
  readModifyWriteStateMd(statePath, (content) => {
2852
- const result = prunePass(content);
2366
+ const result = runPruneCore(content);
2853
2367
  archived.push(...result.archivedSections);
2854
2368
  return result.newContent;
2855
2369
  }, cwd);
@@ -2876,6 +2390,116 @@ function cmdStatePrune(cwd, options, raw) {
2876
2390
  archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
2877
2391
  }, raw, totalPruned > 0 ? 'true' : 'false');
2878
2392
  }
2393
+ /**
2394
+ * Rebuild STATE.md body structure from canonical sources (ADR-1817).
2395
+ *
2396
+ * Implements the `gsd state rebuild` subcommand (issue #1817 Phase 2, #1826).
2397
+ * Wires the pure `rebuildCore` transition (Phase 1, #1827) to the CLI:
2398
+ * - Locks via `readModifyWriteStateMd` (real path) or reads-only (dry-run).
2399
+ * - Wires `phaseInventoryProvider` to a real `.planning/phases/` disk scan.
2400
+ * - `--dry-run`: computes the rebuild, emits a structured diff, writes nothing.
2401
+ * - `--verbose`: emits the audit-log entries to stderr (in addition to the
2402
+ * `## Rebuild Log` section that `rebuildCore` already appends to STATE.md).
2403
+ *
2404
+ * Per ADR-1817 §5 this is the heavy/manual counterpart to the lightweight,
2405
+ * auto-triggered `state sync` (3 frontmatter fields). The two compose
2406
+ * non-overlappingly.
2407
+ */
2408
+ function cmdStateRebuild(cwd, options, raw) {
2409
+ const silent = !!options.silent;
2410
+ const emit = silent ? () => { } : (result, r, v) => output(result, r, v);
2411
+ const statePath = planningPaths(cwd).state;
2412
+ if (!node_fs_1.default.existsSync(statePath)) {
2413
+ emit({ error: 'STATE.md not found' }, raw);
2414
+ return;
2415
+ }
2416
+ const dryRun = !!options.dryRun;
2417
+ const verbose = !!options.verbose;
2418
+ // Wire phaseInventoryProvider to a real `.planning/phases/` disk scan. This
2419
+ // is the same canonical source `buildStateFrontmatter` consults; the Leaky-
2420
+ // Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
2421
+ // testable without this dep — here we provide it.
2422
+ const phaseInventoryProvider = () => {
2423
+ try {
2424
+ const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
2425
+ if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
2426
+ return null;
2427
+ const entries = node_fs_1.default.readdirSync(phasesDir);
2428
+ const records = [];
2429
+ for (const entry of entries) {
2430
+ const full = node_path_1.default.join(phasesDir, entry);
2431
+ let stat;
2432
+ try {
2433
+ stat = node_fs_1.default.statSync(full);
2434
+ }
2435
+ catch {
2436
+ continue;
2437
+ }
2438
+ if (!stat.isDirectory())
2439
+ continue;
2440
+ // Directory-name convention: `<NN>-<slug>` (e.g. `03-test-phase`).
2441
+ const m = entry.match(/^(\d+)-(.+)$/);
2442
+ if (!m)
2443
+ continue;
2444
+ const files = node_fs_1.default.readdirSync(full);
2445
+ const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
2446
+ const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
2447
+ records.push({ number: m[1], name: m[2], planCount, summaryCount });
2448
+ }
2449
+ return records;
2450
+ }
2451
+ catch {
2452
+ return null;
2453
+ }
2454
+ };
2455
+ const deps = {
2456
+ progressProvider: () => null,
2457
+ clock: clock_cjs_1.realClock,
2458
+ phaseInventoryProvider,
2459
+ };
2460
+ const runRebuild = (content) => transitionCore(content, { kind: 'rebuild' }, deps);
2461
+ const emitVerboseLog = (log) => {
2462
+ if (!verbose || !Array.isArray(log))
2463
+ return;
2464
+ for (const entry of log) {
2465
+ // Treat user-data as data-only (ADR-1577 untrusted-input-boundary).
2466
+ process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
2467
+ }
2468
+ };
2469
+ if (dryRun) {
2470
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2471
+ const result = runRebuild(content);
2472
+ const data = (result.data ?? {});
2473
+ emitVerboseLog(data.log);
2474
+ const mutated = data.mutated === true;
2475
+ emit({
2476
+ rebuilt: false,
2477
+ dry_run: true,
2478
+ mutations: Array.isArray(data.log) ? data.log.length : 0,
2479
+ mutated,
2480
+ note: mutated ? 'Run without --dry-run to apply changes' : 'Nothing to rebuild',
2481
+ }, raw, mutated ? 'true' : 'false');
2482
+ return;
2483
+ }
2484
+ // Real path: lock + RMW via the existing seam. The rebuild log is captured
2485
+ // so we can emit it to stderr under --verbose (the section is also written
2486
+ // to STATE.md by rebuildCore itself, per ADR-1817 §3).
2487
+ let capturedLog = [];
2488
+ let capturedMutated = false;
2489
+ readModifyWriteStateMd(statePath, (content) => {
2490
+ const result = runRebuild(content);
2491
+ const data = (result.data ?? {});
2492
+ capturedLog = Array.isArray(data.log) ? data.log : [];
2493
+ capturedMutated = data.mutated === true;
2494
+ return result.content;
2495
+ }, cwd);
2496
+ emitVerboseLog(capturedLog);
2497
+ emit({
2498
+ rebuilt: capturedMutated,
2499
+ mutations: capturedLog.length,
2500
+ note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : 'Nothing to rebuild',
2501
+ }, raw, capturedMutated ? 'true' : 'false');
2502
+ }
2879
2503
  /**
2880
2504
  * Mark the current phase as COMPLETE in STATE.md.
2881
2505
  * Updates Status, Last Activity, and the Current Position section to reflect
@@ -3044,6 +2668,7 @@ module.exports = {
3044
2668
  cmdStateValidate,
3045
2669
  cmdStateSync,
3046
2670
  cmdStatePrune,
2671
+ cmdStateRebuild,
3047
2672
  cmdStateMilestoneSwitch,
3048
2673
  cmdSignalWaiting,
3049
2674
  cmdSignalResume,