@opengsd/gsd-core 1.5.0-rc.3 → 1.5.0-rc.4

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 (85) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-nyquist-auditor.md +1 -0
  13. package/agents/gsd-phase-researcher.md +1 -1
  14. package/agents/gsd-plan-checker.md +1 -1
  15. package/agents/gsd-planner.md +1 -1
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +1 -1
  18. package/agents/gsd-roadmapper.md +55 -2
  19. package/agents/gsd-security-auditor.md +1 -0
  20. package/agents/gsd-ui-auditor.md +1 -1
  21. package/agents/gsd-ui-checker.md +1 -1
  22. package/agents/gsd-ui-researcher.md +1 -1
  23. package/agents/gsd-verifier.md +13 -2
  24. package/bin/install.js +36 -57
  25. package/commands/gsd/progress.md +2 -1
  26. package/gemini-extension.json +1 -1
  27. package/gsd-core/bin/gsd-tools.cjs +167 -3
  28. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  29. package/gsd-core/bin/lib/capability-state.cjs +97 -3
  30. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  31. package/gsd-core/bin/lib/config.cjs +80 -24
  32. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  33. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  34. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  35. package/gsd-core/bin/lib/init.cjs +28 -6
  36. package/gsd-core/bin/lib/install-profiles.cjs +55 -0
  37. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  38. package/gsd-core/bin/lib/phase.cjs +28 -12
  39. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  40. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  41. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +246 -0
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -2
  44. package/gsd-core/bin/lib/state.cjs +240 -59
  45. package/gsd-core/bin/lib/verify.cjs +73 -4
  46. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  47. package/gsd-core/references/edge-probe.md +11 -0
  48. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  49. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  50. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  51. package/gsd-core/references/prohibition-probe.md +248 -0
  52. package/gsd-core/templates/spec.md +14 -0
  53. package/gsd-core/workflows/complete-milestone.md +1 -5
  54. package/gsd-core/workflows/execute-phase.md +4 -3
  55. package/gsd-core/workflows/execute-plan.md +21 -6
  56. package/gsd-core/workflows/help/modes/full.md +4 -0
  57. package/gsd-core/workflows/next.md +50 -2
  58. package/gsd-core/workflows/pause-work.md +7 -1
  59. package/gsd-core/workflows/plan-phase.md +2 -0
  60. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  61. package/gsd-core/workflows/pr-branch.md +4 -2
  62. package/gsd-core/workflows/quick.md +3 -2
  63. package/gsd-core/workflows/resume-project.md +17 -1
  64. package/gsd-core/workflows/settings.md +27 -1
  65. package/gsd-core/workflows/ship.md +1 -5
  66. package/gsd-core/workflows/spec-phase.md +75 -0
  67. package/gsd-core/workflows/verify-phase.md +14 -4
  68. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  69. package/hooks/dist/gsd-statusline.js +1 -1
  70. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  71. package/hooks/gsd-ensure-canonical-path.js +305 -0
  72. package/hooks/gsd-statusline.js +1 -1
  73. package/hooks/hooks.json +1 -0
  74. package/hooks/managed-hooks-registry.cjs +1 -0
  75. package/package.json +3 -3
  76. package/scripts/build-hooks.js +7 -0
  77. package/scripts/changeset/new.cjs +17 -3
  78. package/scripts/fix-slash-commands.cjs +15 -3
  79. package/scripts/gen-capability-registry.cjs +14 -1
  80. package/scripts/lint-allow-test-rule-refs.allowlist.json +2 -1
  81. package/scripts/lint-test-file-count.allowlist.json +6 -0
  82. package/scripts/mutation-matrix.cjs +108 -7
  83. package/scripts/pr-target-policy.cjs +63 -0
  84. package/scripts/research-profiles.cjs +5 -5
  85. package/scripts/run-tests.cjs +107 -6
@@ -1327,6 +1327,19 @@ function acquireStateLock(statePath, clock) {
1327
1327
  const staleThresholdMs = 10000;
1328
1328
  const maxWaitMs = 30000;
1329
1329
  const startedAt = clock.now();
1330
+ // Shared helper: check the time budget then back off with jitter before the
1331
+ // next retry. Both the EEXIST contention path and the recoverable-errno path
1332
+ // must go through this so neither can busy-spin (#1217).
1333
+ const checkBudgetAndSleep = (context) => {
1334
+ if (clock.now() - startedAt >= maxWaitMs) {
1335
+ const e = new Error('acquireStateLock: ' + lockPath + ' ' + context + ' for ' +
1336
+ (clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
1337
+ e.lockBudgetExceeded = true;
1338
+ throw e;
1339
+ }
1340
+ const jitter = Math.floor(Math.random() * 50);
1341
+ clock.sleep(retryDelay + jitter);
1342
+ };
1330
1343
  while (true) {
1331
1344
  try {
1332
1345
  const fd = node_fs_1.default.openSync(lockPath, node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | node_fs_1.default.constants.O_WRONLY);
@@ -1338,9 +1351,11 @@ function acquireStateLock(statePath, clock) {
1338
1351
  }
1339
1352
  catch (err) {
1340
1353
  // Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
1341
- // are recoverable — retry the acquisition loop rather than propagating.
1354
+ // are recoverable — retry with the same budget + backoff as the EEXIST path so
1355
+ // a permanently-failing errno cannot busy-spin at 100% CPU (#1217).
1342
1356
  // See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
1343
1357
  if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) {
1358
+ checkBudgetAndSleep(err.code + ' persisted');
1344
1359
  continue;
1345
1360
  }
1346
1361
  if (err.code !== 'EEXIST')
@@ -1351,22 +1366,39 @@ function acquireStateLock(statePath, clock) {
1351
1366
  try {
1352
1367
  const stat = node_fs_1.default.statSync(lockPath);
1353
1368
  if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
1369
+ let removed = false;
1354
1370
  try {
1355
1371
  node_fs_1.default.unlinkSync(lockPath);
1372
+ removed = true;
1373
+ }
1374
+ catch { /* swallow: bounded below */ }
1375
+ if (removed) {
1376
+ // Successful steal — retry immediately to grab the just-freed lock.
1377
+ // Must NOT call checkBudgetAndSleep here: a throw-after-delete would
1378
+ // corrupt the filesystem state, and the budget is already bounded on
1379
+ // the next iteration's EEXIST or open attempt (#1217 regression fix).
1380
+ continue;
1356
1381
  }
1357
- catch { /* already gone */ }
1382
+ // Persistent unlinkSync failure — apply budget + backoff so it cannot
1383
+ // busy-spin (#1217).
1384
+ checkBudgetAndSleep('stale lock removal failed');
1358
1385
  continue;
1359
1386
  }
1360
1387
  }
1361
- catch {
1362
- continue; /* released between EEXIST and stat */
1363
- }
1364
- if ((clock).now() - startedAt >= maxWaitMs) {
1365
- throw new Error('acquireStateLock: ' + lockPath + ' held by live process for ' +
1366
- ((clock).now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
1388
+ catch (err) {
1389
+ // Re-throw a budget-exceeded error from the unlinkSync failure path above
1390
+ // unchanged — its message already names the real cause ("stale lock removal
1391
+ // failed") and double-wrapping it would replace that with the misleading
1392
+ // "statSync failed after EEXIST" context string (#1217 diagnostic fix).
1393
+ if (err?.lockBudgetExceeded)
1394
+ throw err;
1395
+ // statSync failed — lock was likely released between our EEXIST and this
1396
+ // stat call. Apply budget + backoff so a persistent statSync failure
1397
+ // cannot busy-spin (#1217).
1398
+ checkBudgetAndSleep('statSync failed after EEXIST');
1399
+ continue;
1367
1400
  }
1368
- const jitter = Math.floor(Math.random() * 50);
1369
- (clock).sleep(retryDelay + jitter);
1401
+ checkBudgetAndSleep('held by live process');
1370
1402
  }
1371
1403
  }
1372
1404
  }
@@ -1441,6 +1473,26 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1441
1473
  // Snapshot the existing progress block BEFORE the transform so we can
1442
1474
  // restore it when resync is false.
1443
1475
  const preFm = resync ? null : extractFrontmatter(content);
1476
+ // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
1477
+ // we can detect whether THIS write changed them. syncStateFrontmatter
1478
+ // re-derives frontmatter status/stopped_at from the body on every write;
1479
+ // when the body's source field was NOT changed by the transform, the
1480
+ // existing frontmatter value (e.g. a hand-set 'completed') must win over
1481
+ // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
1482
+ // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
1483
+ // above (null when resync:true) — these are independent snapshots.
1484
+ // Strip frontmatter before calling stateExtractField so the YAML `status:`
1485
+ // key in the frontmatter block cannot shadow the body field we are tracking.
1486
+ const preBody = stripFrontmatter(content);
1487
+ const preFmSnapshot = extractFrontmatter(content);
1488
+ const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1489
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1490
+ // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
1491
+ // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1492
+ // Archive prose) must not interfere with the delta comparison.
1493
+ const preSessionMatch = matchSessionSection(preBody);
1494
+ const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1495
+ const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1444
1496
  const modified = transformFn(content);
1445
1497
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
1446
1498
  // the file. An unconditional write would bump `last_updated`, reset
@@ -1453,14 +1505,53 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1453
1505
  return;
1454
1506
  }
1455
1507
  let synced = syncStateFrontmatter(modified, cwd);
1456
- if (!resync && preFm && preFm['progress']) {
1508
+ // Compute postFm once and apply BOTH the progress-restore (when !resync)
1509
+ // AND the status/stopped_at preservation (#1230) before reconstructing.
1510
+ // This avoids double-wrapping the frontmatter block.
1511
+ const needsProgressRestore = !resync && preFm && preFm['progress'];
1512
+ // Post-transform body source fields used for the delta comparison (#1230).
1513
+ // 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.
1514
+ // Strip frontmatter so the YAML status key cannot shadow the body field.
1515
+ const postBody = stripFrontmatter(modified);
1516
+ const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
1517
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1518
+ // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1519
+ const postSessionMatch = matchSessionSection(postBody);
1520
+ const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1521
+ const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1522
+ let mutated = false;
1523
+ const postFm = extractFrontmatter(synced);
1524
+ if (needsProgressRestore) {
1457
1525
  // Re-apply the curated progress block that syncStateFrontmatter just
1458
1526
  // overwrote with disk-derived values. Only restore keys that were present
1459
1527
  // in the snapshot — this preserves any new non-progress frontmatter fields
1460
1528
  // (e.g., status, current_phase) that syncStateFrontmatter legitimately
1461
1529
  // derived from the updated body.
1462
- const postFm = extractFrontmatter(synced);
1463
1530
  postFm['progress'] = preFm['progress'];
1531
+ mutated = true;
1532
+ }
1533
+ // Bug #1230: preserve existing frontmatter status when this write did NOT
1534
+ // change the body's Status field. A write that doesn't touch Status must
1535
+ // not silently revert a hand-set frontmatter status (e.g. 'completed') to
1536
+ // whatever the stale body Status happens to derive (e.g. 'verifying').
1537
+ // Only apply when the existing frontmatter held a real, non-unknown status.
1538
+ if (postBodyStatus === preBodyStatus &&
1539
+ typeof preFmSnapshot['status'] === 'string' &&
1540
+ preFmSnapshot['status'].length > 0 &&
1541
+ preFmSnapshot['status'] !== 'unknown' &&
1542
+ postFm['status'] !== preFmSnapshot['status']) {
1543
+ postFm['status'] = preFmSnapshot['status'];
1544
+ mutated = true;
1545
+ }
1546
+ // Bug #1230: same delta heuristic for stopped_at.
1547
+ if (postBodyStoppedAt === preBodyStoppedAt &&
1548
+ typeof preFmSnapshot['stopped_at'] === 'string' &&
1549
+ preFmSnapshot['stopped_at'].length > 0 &&
1550
+ postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
1551
+ postFm['stopped_at'] = preFmSnapshot['stopped_at'];
1552
+ mutated = true;
1553
+ }
1554
+ if (mutated) {
1464
1555
  const yamlStr = reconstructFrontmatter(postFm);
1465
1556
  const body = stripFrontmatter(synced);
1466
1557
  synced = `---\n${yamlStr}\n---\n\n${body}`;
@@ -1529,74 +1620,89 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1529
1620
  const today = clock_cjs_1.realClock.today();
1530
1621
  const updated = [];
1531
1622
  readModifyWriteStateMd(statePath, (content) => {
1623
+ // Bug #1255: all body-field replacements must operate on the body only
1624
+ // (frontmatter stripped), not on the full content. When the full content is
1625
+ // passed to stateReplaceField the YAML `status: planning` key matches the
1626
+ // plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1627
+ // pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
1628
+ // re-derives 'planning' from the unchanged body — the status never advances.
1629
+ const existingFm = extractFrontmatter(content);
1630
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1631
+ let body = stripFrontmatter(content);
1632
+ // Helper to reassemble content for field-replacement checks; callers that
1633
+ // only need to test/replace body fields use `body` directly, and the final
1634
+ // return reassembles the frontmatter block with the updated body.
1635
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1532
1636
  // Idempotency guard (#3127): if the phase is already mid-flight, do NOT
1533
1637
  // overwrite execution-progress fields (Current Plan, plan body line,
1534
1638
  // Last Activity Description). Only update fields that are safe to
1535
1639
  // refresh on resume (Last Activity date, Status if inconsistent).
1536
1640
  // A phase is considered mid-flight when Status contains 'Executing Phase N'
1537
1641
  // for the current phase number.
1538
- const currentStatus = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
1642
+ // #1255: extract from body (not full content) so the YAML `status:` key
1643
+ // cannot shadow the body Status field.
1644
+ const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
1539
1645
  const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
1540
- // Update Status field
1646
+ // Update Status field (body only — #1255)
1541
1647
  const statusValue = `Executing Phase ${phaseNumber}`;
1542
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Status', statusValue);
1648
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
1543
1649
  if (result) {
1544
- content = result;
1650
+ body = result;
1545
1651
  updated.push('Status');
1546
1652
  }
1547
1653
  // Update Last Activity (safe to update on resume — tracks when execute-phase ran)
1548
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity', today);
1654
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
1549
1655
  if (result) {
1550
- content = result;
1656
+ body = result;
1551
1657
  updated.push('Last Activity');
1552
1658
  }
1553
1659
  if (!isAlreadyExecuting) {
1554
1660
  // First-time execution: set all progress fields
1555
1661
  // Update Last Activity Description
1556
1662
  const activityDesc = `Phase ${phaseNumber} execution started`;
1557
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', activityDesc);
1663
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
1558
1664
  if (result) {
1559
- content = result;
1665
+ body = result;
1560
1666
  updated.push('Last Activity Description');
1561
1667
  }
1562
1668
  // Update Current Phase
1563
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Phase', String(phaseNumber));
1669
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
1564
1670
  if (result) {
1565
- content = result;
1671
+ body = result;
1566
1672
  updated.push('Current Phase');
1567
1673
  }
1568
1674
  // Update Current Phase Name
1569
1675
  if (phaseName) {
1570
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Phase Name', phaseName);
1676
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
1571
1677
  if (result) {
1572
- content = result;
1678
+ body = result;
1573
1679
  updated.push('Current Phase Name');
1574
1680
  }
1575
1681
  }
1576
1682
  // Update Current Plan to 1 (starting from the first plan)
1577
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Plan', '1');
1683
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
1578
1684
  if (result) {
1579
- content = result;
1685
+ body = result;
1580
1686
  updated.push('Current Plan');
1581
1687
  }
1582
1688
  // Update Total Plans in Phase
1583
1689
  if (planCount) {
1584
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Total Plans in Phase', String(planCount));
1690
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1585
1691
  if (result) {
1586
- content = result;
1692
+ body = result;
1587
1693
  updated.push('Total Plans in Phase');
1588
1694
  }
1589
1695
  }
1590
1696
  // Update **Current focus:** body text line (#1104)
1591
1697
  const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1592
1698
  const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1593
- if (focusPattern.test(content)) {
1594
- content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1699
+ if (focusPattern.test(body)) {
1700
+ body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1595
1701
  updated.push('Current focus');
1596
1702
  }
1597
1703
  // Update ## Current Position section (#1104, #1365)
1598
1704
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1599
- const positionMatch = content.match(positionPattern);
1705
+ const positionMatch = body.match(positionPattern);
1600
1706
  if (positionMatch) {
1601
1707
  const header = positionMatch[1];
1602
1708
  let posBody = positionMatch[2];
@@ -1606,7 +1712,13 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1606
1712
  posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1607
1713
  }
1608
1714
  else {
1609
- posBody = newPhase + '\n' + posBody;
1715
+ // Pipe-table format in Current Position (#1257): update the | Phase | … |
1716
+ // cell rather than prepending a spurious inline `Phase:` line (which left
1717
+ // the table cell stale). Mirrors the Status/Last-activity table branches.
1718
+ const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1719
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
1720
+ if (replaced !== null)
1721
+ posBody = replaced;
1610
1722
  }
1611
1723
  // Update or insert Plan line
1612
1724
  const newPlan = `Plan: 1 of ${planCount || '?'}`;
@@ -1614,19 +1726,39 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1614
1726
  posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1615
1727
  }
1616
1728
  else {
1617
- posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
1729
+ // Pipe-table format in Current Position (#1257): update the | Plan | … |
1730
+ // cell rather than appending after a prepended inline line.
1731
+ const planValue = `1 of ${planCount || '?'}`;
1732
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
1733
+ if (replaced !== null)
1734
+ posBody = replaced;
1618
1735
  }
1619
1736
  // Update Status line if present
1620
1737
  const newStatus = `Status: Executing Phase ${phaseNumber}`;
1621
1738
  if (/^Status:/m.test(posBody)) {
1622
1739
  posBody = posBody.replace(/^Status:.*$/m, newStatus);
1623
1740
  }
1741
+ else {
1742
+ // Pipe-table format in Current Position (#1255)
1743
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
1744
+ if (replaced !== null)
1745
+ posBody = replaced;
1746
+ }
1624
1747
  // Update Last activity line if present
1625
1748
  const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
1626
1749
  if (/^Last activity:/im.test(posBody)) {
1627
1750
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1628
1751
  }
1629
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1752
+ else {
1753
+ // Pipe-table format in Current Position (#1255)
1754
+ // Value must match the inline branch (date + narrative), not bare date.
1755
+ const activityValue = `${today} -- Phase ${phaseNumber} execution started`;
1756
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
1757
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
1758
+ if (replaced !== null)
1759
+ posBody = replaced;
1760
+ }
1761
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1630
1762
  updated.push('Current Position');
1631
1763
  }
1632
1764
  }
@@ -1634,19 +1766,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1634
1766
  // Resume path: only update Last activity timestamp in Current Position
1635
1767
  // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
1636
1768
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1637
- const positionMatch = content.match(positionPattern);
1769
+ const positionMatch = body.match(positionPattern);
1638
1770
  if (positionMatch) {
1639
1771
  const header = positionMatch[1];
1640
1772
  let posBody = positionMatch[2];
1641
1773
  const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
1642
1774
  if (/^Last activity:/im.test(posBody)) {
1643
1775
  posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
1644
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1776
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1645
1777
  updated.push('Last activity (resume)');
1646
1778
  }
1779
+ else {
1780
+ // Pipe-table format in Current Position (#1255)
1781
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
1782
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
1783
+ if (replaced !== null) {
1784
+ posBody = replaced;
1785
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1786
+ updated.push('Last activity (resume)');
1787
+ }
1788
+ }
1647
1789
  }
1648
1790
  }
1649
- return content;
1791
+ return reassemble(body);
1650
1792
  }, cwd);
1651
1793
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1652
1794
  }
@@ -1746,43 +1888,54 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1746
1888
  // doing so tramples curated/known-good counters. Route through the body-only
1747
1889
  // write contract (resync:false), the same guard state.update uses. (#500 RC1)
1748
1890
  readModifyWriteStateMd(statePath, (content) => {
1891
+ // Bug #1257: all body-field replacements must operate on the body only
1892
+ // (frontmatter stripped), not on the full content. When the full content is
1893
+ // passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
1894
+ // the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1895
+ // pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
1896
+ // re-derives 'planning' from the unchanged body — the status never advances.
1897
+ // (Mirrors the begin/complete-phase fix from #1255/#1256.)
1898
+ const existingFm = extractFrontmatter(content);
1899
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1900
+ let body = stripFrontmatter(content);
1901
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1749
1902
  // Update Status — only when the existing value is a known template default
1750
1903
  // (Knuth invariant: preserve executor-authored values).
1751
- const newContent = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Ready to execute');
1752
- if (newContent !== content) {
1753
- content = newContent;
1904
+ const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
1905
+ if (newBody !== body) {
1906
+ body = newBody;
1754
1907
  updated.push('Status');
1755
1908
  }
1756
1909
  // Update Total Plans in Phase
1757
1910
  if (planCount !== null && planCount !== undefined) {
1758
- const result = (0, state_document_cjs_1.stateReplaceField)(content, 'Total Plans in Phase', String(planCount));
1911
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1759
1912
  if (result) {
1760
- content = result;
1913
+ body = result;
1761
1914
  updated.push('Total Plans in Phase');
1762
1915
  }
1763
1916
  }
1764
1917
  // Update Last Activity — only when the existing value is a known template default
1765
1918
  {
1766
- const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
1767
- if (after !== content) {
1768
- content = after;
1919
+ const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
1920
+ if (after !== body) {
1921
+ body = after;
1769
1922
  updated.push('Last Activity');
1770
1923
  }
1771
1924
  }
1772
1925
  // Update Last Activity Description
1773
1926
  {
1774
- const result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1927
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1775
1928
  if (result) {
1776
- content = result;
1929
+ body = result;
1777
1930
  updated.push('Last Activity Description');
1778
1931
  }
1779
1932
  }
1780
1933
  // Update Current Position section
1781
- content = updateCurrentPositionFields(content, {
1934
+ body = updateCurrentPositionFields(body, {
1782
1935
  status: 'Ready to execute',
1783
1936
  lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1784
1937
  });
1785
- return content;
1938
+ return reassemble(body);
1786
1939
  }, cwd, { resync: false });
1787
1940
  output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1788
1941
  }
@@ -2263,29 +2416,35 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2263
2416
  const updated = [];
2264
2417
  readModifyWriteStateMd(statePath, (content) => {
2265
2418
  const currentPhase = resolvedPhase;
2266
- // Update Status field
2419
+ // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2420
+ // cannot shadow the body Status field (pipe-table or inline).
2421
+ const existingFm = extractFrontmatter(content);
2422
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
2423
+ let body = stripFrontmatter(content);
2424
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
2425
+ // Update Status field (body only — #1255)
2267
2426
  const statusValue = `Phase ${currentPhase} complete`;
2268
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Status', statusValue);
2427
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
2269
2428
  if (result) {
2270
- content = result;
2429
+ body = result;
2271
2430
  updated.push('Status');
2272
2431
  }
2273
2432
  // Update Last Activity date
2274
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity', today);
2433
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
2275
2434
  if (result) {
2276
- content = result;
2435
+ body = result;
2277
2436
  updated.push('Last Activity');
2278
2437
  }
2279
2438
  // Update Last Activity Description
2280
2439
  const activityDesc = `Phase ${currentPhase} marked complete`;
2281
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', activityDesc);
2440
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
2282
2441
  if (result) {
2283
- content = result;
2442
+ body = result;
2284
2443
  updated.push('Last Activity Description');
2285
2444
  }
2286
2445
  // Update ## Current Position section
2287
2446
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2288
- const positionMatch = content.match(positionPattern);
2447
+ const positionMatch = body.match(positionPattern);
2289
2448
  if (positionMatch) {
2290
2449
  const header = positionMatch[1];
2291
2450
  let posBody = positionMatch[2];
@@ -2294,20 +2453,42 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2294
2453
  if (/^Phase:/m.test(posBody)) {
2295
2454
  posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2296
2455
  }
2456
+ else {
2457
+ // Pipe-table format in Current Position (#1255)
2458
+ // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
2459
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
2460
+ if (replaced !== null)
2461
+ posBody = replaced;
2462
+ }
2297
2463
  // Update Status line if present
2298
2464
  const newStatus = `Status: Phase ${currentPhase} complete`;
2299
2465
  if (/^Status:/m.test(posBody)) {
2300
2466
  posBody = posBody.replace(/^Status:.*$/m, newStatus);
2301
2467
  }
2468
+ else {
2469
+ // Pipe-table format in Current Position (#1255)
2470
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
2471
+ if (replaced !== null)
2472
+ posBody = replaced;
2473
+ }
2302
2474
  // Update Last activity line if present
2303
2475
  const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
2304
2476
  if (/^Last activity:/im.test(posBody)) {
2305
2477
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2306
2478
  }
2307
- content = content.replace(positionPattern, () => `${header}${posBody}`);
2479
+ else {
2480
+ // Pipe-table format in Current Position (#1255)
2481
+ // Value must match the inline branch (date + narrative), not bare date.
2482
+ const activityValue = `${today} -- Phase ${currentPhase} marked complete`;
2483
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2484
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2485
+ if (replaced !== null)
2486
+ posBody = replaced;
2487
+ }
2488
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
2308
2489
  updated.push('Current Position');
2309
2490
  }
2310
- return content;
2491
+ return reassemble(body);
2311
2492
  }, cwd);
2312
2493
  output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
2313
2494
  }
@@ -24,6 +24,8 @@ const frontmatterMod = require("./frontmatter.cjs");
24
24
  const stateMod = require("./state.cjs");
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-profiles.cjs is an export= CommonJS module
26
26
  const modelProfilesMod = require("./model-profiles.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
28
+ const planScanMod = require("./plan-scan.cjs");
27
29
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
28
30
  const package_identity_cjs_1 = require("./package-identity.cjs");
29
31
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
@@ -450,6 +452,39 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
450
452
  artifacts: results,
451
453
  }, raw, passed === results.length ? 'valid' : 'invalid');
452
454
  }
455
+ /**
456
+ * Returns a Set of file paths (relative to cwd) that are promised by plans in
457
+ * the same phase directory at a wave number >= minWave.
458
+ *
459
+ * Used by cmdVerifyKeyLinks to avoid hard-failing a missing `from:` file that
460
+ * is a planned future artifact (fix #1202).
461
+ */
462
+ function collectPromisedFilesAtOrAfterWave(phaseDir, minWave) {
463
+ const promised = new Set();
464
+ const { planFiles } = planScanMod.scanPhasePlans(phaseDir);
465
+ for (const planFile of planFiles) {
466
+ const planFullPath = node_path_1.default.join(phaseDir, planFile);
467
+ const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(planFullPath);
468
+ if (!planContent)
469
+ continue;
470
+ const fm = extractFrontmatter(planContent);
471
+ const waveRaw = fm['wave'];
472
+ const wave = typeof waveRaw === 'string' ? parseInt(waveRaw, 10) : (typeof waveRaw === 'number' ? waveRaw : NaN);
473
+ if (isNaN(wave) || wave < minWave)
474
+ continue;
475
+ const filesModified = fm['files_modified'];
476
+ if (!filesModified)
477
+ continue;
478
+ const files = Array.isArray(filesModified)
479
+ ? filesModified
480
+ : (typeof filesModified === 'string' ? [filesModified] : []);
481
+ for (const f of files) {
482
+ if (typeof f === 'string' && f.trim())
483
+ promised.add(f.trim());
484
+ }
485
+ }
486
+ return promised;
487
+ }
453
488
  function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
454
489
  if (!planFilePath) {
455
490
  error('plan file path required');
@@ -465,7 +500,25 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
465
500
  output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
466
501
  return;
467
502
  }
503
+ // Derive the current plan's wave number and phase directory for wave-aware
504
+ // missing-file handling (fix #1202).
505
+ const currentFm = extractFrontmatter(content);
506
+ const currentWaveRaw = currentFm['wave'];
507
+ const currentWave = typeof currentWaveRaw === 'string'
508
+ ? parseInt(currentWaveRaw, 10)
509
+ : (typeof currentWaveRaw === 'number' ? currentWaveRaw : 1);
510
+ const phaseDir = node_path_1.default.dirname(fullPath);
511
+ // Collect files promised by plans at wave >= currentWave (lazy: computed once
512
+ // the first time a missing source is encountered).
513
+ let promisedFiles = null;
514
+ function getPromisedFiles() {
515
+ if (promisedFiles === null) {
516
+ promisedFiles = collectPromisedFilesAtOrAfterWave(phaseDir, isNaN(currentWave) ? 1 : currentWave);
517
+ }
518
+ return promisedFiles;
519
+ }
468
520
  const results = [];
521
+ let pendingCount = 0;
469
522
  for (const link of keyLinks) {
470
523
  if (typeof link === 'string')
471
524
  continue;
@@ -476,9 +529,20 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
476
529
  verified: false,
477
530
  detail: '',
478
531
  };
479
- const sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(cwd, link['from'] || ''));
532
+ const fromPath = link['from'] || '';
533
+ const sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(cwd, fromPath));
480
534
  if (!sourceContent) {
481
- check['detail'] = 'Source file not found (from: must be a relative file path; describe components/endpoints in via:)';
535
+ // Check if the missing file is promised by a plan at the same or later wave.
536
+ const promised = getPromisedFiles();
537
+ const isPromised = fromPath.trim() !== '' && promised.has(fromPath.trim());
538
+ if (isPromised) {
539
+ check['pending'] = true;
540
+ check['detail'] = 'Source file not yet created — declared in files_modified of a same-or-later-wave plan';
541
+ pendingCount++;
542
+ }
543
+ else {
544
+ check['detail'] = 'Source file not found (from: must be a relative file path; describe components/endpoints in via:)';
545
+ }
482
546
  }
483
547
  else if (link['pattern']) {
484
548
  try {
@@ -514,12 +578,17 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
514
578
  results.push(check);
515
579
  }
516
580
  const verified = results.filter((r) => r['verified']).length;
581
+ // A pending link (from: file promised by a same-or-later-wave plan) is not a
582
+ // hard failure — it should not count against the all_verified gate (#1202).
583
+ const hardFailed = results.filter((r) => !r['verified'] && !r['pending']).length;
584
+ const allVerified = hardFailed === 0;
517
585
  output({
518
- all_verified: verified === results.length,
586
+ all_verified: allVerified,
519
587
  verified,
588
+ pending: pendingCount,
520
589
  total: results.length,
521
590
  links: results,
522
- }, raw, verified === results.length ? 'valid' : 'invalid');
591
+ }, raw, allVerified ? 'valid' : 'invalid');
523
592
  }
524
593
  function listMilestoneArchiveDirs(planBase) {
525
594
  const milestonesDir = node_path_1.default.join(planBase, 'milestones');