@opengsd/gsd-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -500,7 +500,10 @@ function cmdStatePatch(cwd, patches, raw) {
500
500
  const results = { updated, failed };
501
501
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
502
502
  }
503
- catch {
503
+ catch (err) {
504
+ if (err.code !== 'ENOENT') {
505
+ error(`state patch: ${err instanceof Error ? err.message : String(err)}`);
506
+ }
504
507
  error('STATE.md not found');
505
508
  }
506
509
  }
@@ -675,7 +678,10 @@ function cmdStateUpdate(cwd, field, value) {
675
678
  output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
676
679
  }
677
680
  }
678
- catch {
681
+ catch (err) {
682
+ if (err.code !== 'ENOENT') {
683
+ error(`state update: ${err instanceof Error ? err.message : String(err)}`);
684
+ }
679
685
  output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
680
686
  }
681
687
  }
@@ -3913,8 +3919,9 @@ function acquireStateLock(statePath, clock) {
3913
3919
  // in the decision→steal gap never has its replacement deleted (window b). Mirrors
3914
3920
  // capability-lock.cts:455-499.
3915
3921
  try {
3916
- const stat = node_fs_1.default.statSync(lockPath);
3917
- const ageMs = clock.now() - stat.mtimeMs;
3922
+ // Exact 64-bit (dev, ino): an NTFS file id does not fit a JS number (never serialized).
3923
+ const stat = node_fs_1.default.statSync(lockPath, { bigint: true });
3924
+ const ageMs = clock.now() - Number(stat.mtimeMs);
3918
3925
  const bodyStatus = _stateLockBodyStatus(lockPath);
3919
3926
  const bodyPid = bodyStatus.kind === 'pid' ? bodyStatus.pid : null;
3920
3927
  const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid);
@@ -3939,12 +3946,12 @@ function acquireStateLock(statePath, clock) {
3939
3946
  // the body pid → do NOT delete the replacement; re-evaluate from scratch.
3940
3947
  let confirmStat;
3941
3948
  try {
3942
- confirmStat = node_fs_1.default.statSync(lockPath);
3949
+ confirmStat = node_fs_1.default.statSync(lockPath, { bigint: true });
3943
3950
  }
3944
3951
  catch {
3945
3952
  continue; // lock vanished between decision and steal — retry the create.
3946
3953
  }
3947
- const sameInstance = typeof stat.dev === 'number' && typeof stat.ino === 'number' &&
3954
+ const sameInstance = typeof stat.dev === 'bigint' && typeof stat.ino === 'bigint' &&
3948
3955
  confirmStat.dev === stat.dev && confirmStat.ino === stat.ino &&
3949
3956
  _stateLockBodyPid(lockPath) === bodyPid;
3950
3957
  if (!sameInstance) {
@@ -4450,7 +4457,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
4450
4457
  const resync = !options || options.resync !== false;
4451
4458
  const lockPath = acquireStateLock(statePath, clock);
4452
4459
  try {
4453
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
4460
+ // Read through the non-blocking seam (O_NONBLOCK + fstat): a FIFO or device swapped in at
4461
+ // STATE.md fails with ENXIO instead of blocking while the lock is held.
4462
+ const content = (0, shell_command_projection_cjs_1.platformReadBytesSync)(statePath)?.toString('utf-8') ?? '';
4454
4463
  const modified = transformFn(content);
4455
4464
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
4456
4465
  // the file. An unconditional write would bump `last_updated`, reset
@@ -4462,24 +4471,33 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
4462
4471
  if (modified === content) {
4463
4472
  return false;
4464
4473
  }
4465
- // #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
4466
- // owned composition (`syncAndPreserveStateMd`), not assembled here — this
4467
- // call site and `cmdPhaseComplete`'s atomic-commit adapter both route
4468
- // through the same function so the composition cannot diverge between
4469
- // the two.
4470
- const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
4471
- resync,
4472
- authoritativeFm: options?.authoritativeFm,
4473
- deriveProgressKeys: options?.deriveProgressKeys === true,
4474
- divergedFields: options?.divergedFields,
4475
- explicitProgressField: options?.explicitProgressField === true,
4476
- // ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
4477
- // fill it — an unenumerated option here is silently dropped
4478
- // (Phase 1's commit message; #3871), which is exactly how a prior cut
4479
- // of this option would have gone missing.
4480
- preWriteState: options?.preWriteState,
4481
- });
4482
- (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
4474
+ // #5831: everything after the transform may be rolled back by the caller
4475
+ // (onWriteFailure), still under the lock.
4476
+ try {
4477
+ // #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
4478
+ // owned composition (`syncAndPreserveStateMd`), not assembled here — this
4479
+ // call site and `cmdPhaseComplete`'s atomic-commit adapter both route
4480
+ // through the same function so the composition cannot diverge between
4481
+ // the two.
4482
+ const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
4483
+ resync,
4484
+ authoritativeFm: options?.authoritativeFm,
4485
+ deriveProgressKeys: options?.deriveProgressKeys === true,
4486
+ divergedFields: options?.divergedFields,
4487
+ explicitProgressField: options?.explicitProgressField === true,
4488
+ // ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
4489
+ // fill it — an unenumerated option here is silently dropped
4490
+ // (Phase 1's commit message; #3871), which is exactly how a prior cut
4491
+ // of this option would have gone missing.
4492
+ preWriteState: options?.preWriteState,
4493
+ });
4494
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced, { atomic: options?.atomicWrite === true });
4495
+ }
4496
+ catch (err) {
4497
+ if (options?.onWriteFailure)
4498
+ options.onWriteFailure(err);
4499
+ throw err;
4500
+ }
4483
4501
  return true;
4484
4502
  }
4485
4503
  finally {
@@ -6516,6 +6534,309 @@ function cmdStateSync(cwd, options, raw) {
6516
6534
  }
6517
6535
  output({ synced: true, changes, dry_run: false }, raw, undefined);
6518
6536
  }
6537
+ const STATE_ARCHIVE_HEADER = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
6538
+ /**
6539
+ * Pure (#5831): the archive's most recent `## Pruned <date> (phases 1-<N>, ...)`
6540
+ * block, or null when the archive has no `## Pruned ` heading. `cutoff` is N, or
6541
+ * null when the heading has no parsable `(phases 1-<N>, ` part. `sections` are
6542
+ * the block's entries: the text after the last line starting `## Pruned ` up to
6543
+ * the next `## ` heading; a `### <name>` line starts a section and every other
6544
+ * non-blank line is an entry of the current section (trailing `\r` stripped).
6545
+ * Lines before the first `###` are ignored.
6546
+ */
6547
+ function parseLastArchiveBlock(archiveText) {
6548
+ const all = archiveText.split(/\r?\n/);
6549
+ let start = -1;
6550
+ for (let i = all.length - 1; i >= 0; i--) {
6551
+ if (all[i].startsWith('## Pruned ')) {
6552
+ start = i;
6553
+ break;
6554
+ }
6555
+ }
6556
+ if (start === -1)
6557
+ return null;
6558
+ const heading = /\(phases 1-(\d+), /.exec(all[start]);
6559
+ const parsed = heading === null ? NaN : Number(heading[1]);
6560
+ const sections = [];
6561
+ let current = null;
6562
+ for (let i = start + 1; i < all.length; i++) {
6563
+ const line = all[i].replace(/\r$/, '');
6564
+ if (line.startsWith('## '))
6565
+ break;
6566
+ if (line.startsWith('### ')) {
6567
+ current = { section: line.slice(4).trimEnd(), lines: [] };
6568
+ sections.push(current);
6569
+ }
6570
+ else if (current !== null && line.trim().length > 0) {
6571
+ current.lines.push(line);
6572
+ }
6573
+ }
6574
+ return { cutoff: Number.isSafeInteger(parsed) ? parsed : null, sections };
6575
+ }
6576
+ function countLines(lines) {
6577
+ const counts = new Map();
6578
+ for (const l of lines) {
6579
+ const k = l.replace(/\r$/, '');
6580
+ counts.set(k, (counts.get(k) ?? 0) + 1);
6581
+ }
6582
+ return counts;
6583
+ }
6584
+ /**
6585
+ * Pure (#5831): does the archive's last block (`block`, from
6586
+ * `parseLastArchiveBlock`) already hold part of this prune? It does when the
6587
+ * block has at least one entry and, for every section it names, its lines are a
6588
+ * sub-multiset of the same-named pruned section's lines (trailing `\r`
6589
+ * ignored). `remainder` is then the pruned sections minus the block's lines
6590
+ * (one occurrence removed per block occurrence, original strings and order
6591
+ * kept, empty sections dropped, `count` recomputed); otherwise it is `sections`.
6592
+ */
6593
+ function subtractPendingBlock(block, sections) {
6594
+ if (block === null || !block.some(b => b.lines.length > 0))
6595
+ return { pending: false, remainder: sections };
6596
+ const prunedBySection = new Map();
6597
+ for (const s of sections) {
6598
+ const into = prunedBySection.get(s.section) ?? new Map();
6599
+ for (const [k, n] of countLines(s.lines))
6600
+ into.set(k, (into.get(k) ?? 0) + n);
6601
+ prunedBySection.set(s.section, into);
6602
+ }
6603
+ const consumedBySection = new Map();
6604
+ for (const b of block) {
6605
+ const prunedCounts = prunedBySection.get(b.section);
6606
+ if (prunedCounts === undefined) {
6607
+ if (b.lines.length === 0)
6608
+ continue;
6609
+ return { pending: false, remainder: sections };
6610
+ }
6611
+ const blockCounts = countLines(b.lines);
6612
+ const consumed = consumedBySection.get(b.section) ?? new Map();
6613
+ for (const [k, n] of blockCounts) {
6614
+ if ((prunedCounts.get(k) ?? 0) - (consumed.get(k) ?? 0) < n)
6615
+ return { pending: false, remainder: sections };
6616
+ }
6617
+ for (const [k, n] of blockCounts)
6618
+ consumed.set(k, (consumed.get(k) ?? 0) + n);
6619
+ consumedBySection.set(b.section, consumed);
6620
+ }
6621
+ const remainder = [];
6622
+ for (const s of sections) {
6623
+ const consumed = consumedBySection.get(s.section);
6624
+ const kept = [];
6625
+ for (const l of s.lines) {
6626
+ const k = l.replace(/\r$/, '');
6627
+ const left = consumed?.get(k) ?? 0;
6628
+ if (left > 0)
6629
+ consumed.set(k, left - 1);
6630
+ else
6631
+ kept.push(l);
6632
+ }
6633
+ if (kept.length > 0)
6634
+ remainder.push({ section: s.section, count: kept.length, lines: kept });
6635
+ }
6636
+ return { pending: true, remainder };
6637
+ }
6638
+ /** Pure: plan the archive append for one prune run. `existing` is null when the archive is absent. */
6639
+ function planStateArchiveAppend(existing, sections, meta) {
6640
+ const base = existing ?? STATE_ARCHIVE_HEADER;
6641
+ return {
6642
+ base,
6643
+ boundary: base.length > 0 && !base.endsWith('\n') ? '\n\n' : '',
6644
+ block: {
6645
+ date: meta.date,
6646
+ cutoff: meta.cutoff,
6647
+ keepRecent: meta.keepRecent,
6648
+ sections: sections.map(s => ({ section: s.section, lines: [...s.lines] })),
6649
+ },
6650
+ };
6651
+ }
6652
+ /**
6653
+ * Pure: render a plan to the archive text, in the `## Pruned ...` block format
6654
+ * the archive has always had.
6655
+ */
6656
+ function renderStateArchiveAppend(plan) {
6657
+ const { block } = plan;
6658
+ let out = plan.base + plan.boundary +
6659
+ `## Pruned ${block.date} (phases 1-${block.cutoff}, kept recent ${block.keepRecent})\n\n`;
6660
+ for (const section of block.sections) {
6661
+ out += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
6662
+ }
6663
+ return out;
6664
+ }
6665
+ function errMessage(e) {
6666
+ return e instanceof Error ? e.message : String(e);
6667
+ }
6668
+ /** One error carrying both the failure being handled and the one that hit while handling it. */
6669
+ function combineFailure(primary, secondary, message) {
6670
+ return new AggregateError([primary, secondary], message);
6671
+ }
6672
+ /**
6673
+ * Decide which prune this invocation runs (#5831). The archive's last block
6674
+ * names a cutoff N in its heading. When N is larger than this run's cutoff and
6675
+ * does not exceed the current phase, and the prune at N EQUALS the block exactly
6676
+ * (per section, with multiplicity, `\r` ignored: the prune at N minus the block
6677
+ * is empty), the block is an interrupted run's, and this invocation completes it
6678
+ * by pruning at N. In every other case — no block, an unparsable or smaller N, an
6679
+ * N past the current phase, or a prune at N that holds entries the block does not
6680
+ * (added since the interruption) — this run's own cutoff stays in force.
6681
+ */
6682
+ function chooseCutoffRun(block, first, cutoff, currentPhase, runPrune) {
6683
+ if (block !== null && block.cutoff !== null && block.cutoff > cutoff && block.cutoff <= currentPhase) {
6684
+ const wider = runPrune(block.cutoff);
6685
+ const { pending, remainder } = subtractPendingBlock(block.sections, wider.archivedSections);
6686
+ if (pending && remainder.length === 0)
6687
+ return { run: wider, cutoff: block.cutoff };
6688
+ }
6689
+ return { run: first, cutoff };
6690
+ }
6691
+ /**
6692
+ * The archive's current bytes for a prune that may append to it (#5831). A
6693
+ * symlinked or special archive is refused before it is read or snapshotted:
6694
+ * reading through a link would snapshot another file's bytes, and the rollback
6695
+ * would then replace the link itself. O_NOFOLLOW closes the window between the
6696
+ * lstat and the open: a link planted in between fails with ELOOP. Null when the
6697
+ * archive is absent.
6698
+ */
6699
+ function readArchiveSnapshot(archivePath) {
6700
+ try {
6701
+ const archiveStat = node_fs_1.default.lstatSync(archivePath);
6702
+ if (!archiveStat.isFile()) {
6703
+ throw new Error(`state prune: ${archivePath} is ${archiveStat.isSymbolicLink() ? 'a symlink' : 'not a regular file'}; ` +
6704
+ 'refusing to archive into it. STATE.md was not changed.');
6705
+ }
6706
+ }
6707
+ catch (err) {
6708
+ if (err.code !== 'ENOENT')
6709
+ throw err;
6710
+ }
6711
+ return (0, shell_command_projection_cjs_1.platformReadBytesSync)(archivePath, { noFollow: true });
6712
+ }
6713
+ /**
6714
+ * Append one `## Pruned <date> ...` block to STATE-ARCHIVE.md and return the
6715
+ * prune it archived, the cutoff it ran at, and a handle that puts the archive
6716
+ * back exactly as it was (#5831).
6717
+ *
6718
+ * Callers must hold the STATE.md lock (cmdStatePrune calls this from inside
6719
+ * its `readModifyWriteStateMd` transform). The write is atomic — temp file +
6720
+ * rename with no direct-write fallback (`platformWriteSync` `atomic`) — and
6721
+ * throws on failure, so the caller's STATE.md rewrite never happens without
6722
+ * it and a failed write leaves the archive's previous bytes in place. Should
6723
+ * the archive still differ from its snapshot after a failed write, it is
6724
+ * restored before the error propagates.
6725
+ *
6726
+ * Retry detection is by content, not by a hidden marker: when the archive's
6727
+ * most recent block is contained, per section and with multiplicity, in what
6728
+ * this run prunes (`subtractPendingBlock`), an earlier run published that block
6729
+ * but never rewrote STATE.md (killed between the two writes, or a failed
6730
+ * STATE.md write whose archive restore also failed). Only the entries not
6731
+ * already in that block are appended, and nothing is written (`rollback` is
6732
+ * null) when none are left, so the retry just rewrites STATE.md. A last block
6733
+ * that is not contained in this prune is a different run, and the whole prune
6734
+ * is appended. Identical text already in the last block is treated as
6735
+ * archived: nothing is lost, and it is not archived a second time.
6736
+ *
6737
+ * The interrupted run is completed, not duplicated, when this run's cutoff is
6738
+ * smaller: if the block's heading names a larger cutoff N (no greater than the
6739
+ * current phase) and the prune at N equals the block exactly, this invocation
6740
+ * prunes at N instead (see `chooseCutoffRun`; the block's entries leave
6741
+ * STATE.md and nothing is archived twice). Otherwise this run's own cutoff stays
6742
+ * in force and the whole prune is appended (duplicated, never lost), so entries
6743
+ * added since the interruption are never pruned early.
6744
+ */
6745
+ function publishPrunedToArchive(statePath, archivePath, first, runPrune, cutoff, currentPhase, keepRecent) {
6746
+ const stateBytes = (0, shell_command_projection_cjs_1.platformReadBytesSync)(statePath);
6747
+ if (stateBytes === null)
6748
+ throw new Error(`state prune: ${statePath} disappeared while its lock was held`);
6749
+ // STATE.md's permission bits, so a restore from the snapshot puts them back too. Only a regular
6750
+ // file has bits worth keeping: a symlink's own mode (0o777) is never applied to the restored file.
6751
+ const stateStat = node_fs_1.default.lstatSync(statePath);
6752
+ const stateMode = stateStat.isFile() ? stateStat.mode & 0o777 : undefined;
6753
+ const archiveBytes = readArchiveSnapshot(archivePath);
6754
+ const existing = archiveBytes === null ? null : archiveBytes.toString('utf-8');
6755
+ const lastBlock = existing === null ? null : parseLastArchiveBlock(existing);
6756
+ const chosen = chooseCutoffRun(lastBlock, first, cutoff, currentPhase, runPrune);
6757
+ const run = chosen.run;
6758
+ const effectiveCutoff = chosen.cutoff;
6759
+ const { pending, remainder } = subtractPendingBlock(lastBlock === null ? null : lastBlock.sections, run.archivedSections);
6760
+ if (pending && remainder.length === 0)
6761
+ return { run, cutoff: effectiveCutoff, rollback: null };
6762
+ const plan = planStateArchiveAppend(existing, remainder, { date: clock_cjs_1.realClock.localToday(), cutoff: effectiveCutoff, keepRecent });
6763
+ // The file this run created, by the identity the write reports once it is published: a
6764
+ // rollback to "absent" removes only that file, never one another process put at the path.
6765
+ let created;
6766
+ try {
6767
+ (0, shell_command_projection_cjs_1.platformWriteSync)(archivePath, renderStateArchiveAppend(plan), {
6768
+ atomic: true,
6769
+ onCreated: (id) => { created = id; },
6770
+ });
6771
+ }
6772
+ catch (writeErr) {
6773
+ restoreArchiveAfterFailedWrite(archivePath, archiveBytes, writeErr);
6774
+ throw writeErr;
6775
+ }
6776
+ const rollback = {
6777
+ restore(stateErr) {
6778
+ // Roll back only once STATE.md provably holds the entries again (its
6779
+ // bytes are exactly the pre-write snapshot). If the failed write changed
6780
+ // or removed it, put the snapshot back first; if that fails too, the
6781
+ // archive copy may be the only one left — keep it.
6782
+ let stateNow;
6783
+ try {
6784
+ stateNow = (0, shell_command_projection_cjs_1.platformReadBytesSync)(statePath);
6785
+ }
6786
+ catch (readErr) {
6787
+ throw combineFailure(stateErr, readErr, `state prune: writing STATE.md failed (${errMessage(stateErr)}) and STATE.md could not be re-read to verify ` +
6788
+ `its condition (${errMessage(readErr)}). STATE-ARCHIVE.md was left as written and holds the pruned entries; ` +
6789
+ 'check STATE.md before pruning again.');
6790
+ }
6791
+ if (stateNow === null || !stateNow.equals(stateBytes)) {
6792
+ try {
6793
+ (0, shell_command_projection_cjs_1.platformRestoreFileSync)(statePath, stateBytes, { mode: stateMode });
6794
+ }
6795
+ catch (stateRestoreErr) {
6796
+ throw combineFailure(stateErr, stateRestoreErr, `state prune: writing STATE.md failed (${errMessage(stateErr)}) and left it changed, and putting its ` +
6797
+ `previous content back also failed (${errMessage(stateRestoreErr)}). STATE-ARCHIVE.md was left as written ` +
6798
+ 'and holds the pruned entries; check STATE.md before pruning again.');
6799
+ }
6800
+ }
6801
+ try {
6802
+ if (archiveBytes !== null)
6803
+ (0, shell_command_projection_cjs_1.platformRestoreFileSync)(archivePath, archiveBytes);
6804
+ else if (created !== undefined)
6805
+ (0, shell_command_projection_cjs_1.platformRestoreFileSync)(archivePath, null, { unlinkOnlyIf: created });
6806
+ }
6807
+ catch (restoreErr) {
6808
+ throw combineFailure(stateErr, restoreErr, `state prune: writing STATE.md failed (${errMessage(stateErr)}) and restoring STATE-ARCHIVE.md also failed ` +
6809
+ `(${errMessage(restoreErr)}). The pruned entries are in both files for now; pruning again with the same ` +
6810
+ 'options only removes them from STATE.md (the archived block is recognised by its content).');
6811
+ }
6812
+ },
6813
+ };
6814
+ return { run, cutoff: effectiveCutoff, rollback };
6815
+ }
6816
+ /**
6817
+ * #5831: after the archive write itself threw, put the archive back to its
6818
+ * snapshot if it no longer matches it. The atomic write leaves the destination
6819
+ * untouched on failure, so this normally finds nothing to do; it guards the
6820
+ * earlier entries against any write path that damages the file and then
6821
+ * throws. A failed check or restore is reported alongside the write error.
6822
+ */
6823
+ function restoreArchiveAfterFailedWrite(archivePath, archiveBytes, writeErr) {
6824
+ try {
6825
+ const now = (0, shell_command_projection_cjs_1.platformReadBytesSync)(archivePath);
6826
+ if (archiveBytes === null && now !== null) {
6827
+ // No archive existed and one does now, though the write failed: it is not this run's, so it is never deleted.
6828
+ throw Object.assign(new Error(`ESTALE: ${archivePath} appeared during the failed write and was not created by this run — left in place`), { code: 'ESTALE' });
6829
+ }
6830
+ const unchanged = now === null ? archiveBytes === null : archiveBytes !== null && now.equals(archiveBytes);
6831
+ if (!unchanged)
6832
+ (0, shell_command_projection_cjs_1.platformRestoreFileSync)(archivePath, archiveBytes);
6833
+ }
6834
+ catch (restoreErr) {
6835
+ throw combineFailure(writeErr, restoreErr, `state prune: writing STATE-ARCHIVE.md failed (${errMessage(writeErr)}) and putting it back to its previous ` +
6836
+ `content also failed (${errMessage(restoreErr)}). STATE.md was not changed and still holds every entry; ` +
6837
+ 'check STATE-ARCHIVE.md for earlier archived entries.');
6838
+ }
6839
+ }
6519
6840
  /**
6520
6841
  * Prune old entries from STATE.md sections that grow unboundedly (#1970).
6521
6842
  * Moves decisions, recently-completed summaries, and resolved blockers
@@ -6524,15 +6845,18 @@ function cmdStateSync(cwd, options, raw) {
6524
6845
  * Options:
6525
6846
  * keepRecent: number of recent phases to retain (default: 3)
6526
6847
  * dryRun: if true, return what would be pruned without modifying STATE.md
6848
+ *
6849
+ * Returns the result object it emits (also when `silent`), so callers can tell
6850
+ * whether anything was actually pruned.
6527
6851
  */
6528
6852
  function cmdStatePrune(cwd, options, raw) {
6529
6853
  const silent = !!options.silent;
6530
6854
  const emit = silent ? () => { } : (result, r, v) => output(result, r, v);
6855
+ const finish = (result, v) => {
6856
+ emit({ ...result }, raw, v);
6857
+ return result;
6858
+ };
6531
6859
  const statePath = planningPaths(cwd).state;
6532
- if (!node_fs_1.default.existsSync(statePath)) {
6533
- emit({ error: 'STATE.md not found' }, raw);
6534
- return;
6535
- }
6536
6860
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
6537
6861
  const dryRun = !!options.dryRun;
6538
6862
  // Resolve the current phase via `resolveCurrentPhaseId` — the shared owner of
@@ -6541,7 +6865,14 @@ function cmdStatePrune(cwd, options, raw) {
6541
6865
  // engages on a template-conformant STATE.md instead of bailing "Only 0
6542
6866
  // phases" (#1760). #3231/#3481 routed the phase-labeled write commands
6543
6867
  // through the same helper rather than leaving a second copy of the ladder here.
6544
- const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
6868
+ // Read through the non-blocking seam: a FIFO or device at STATE.md fails with ENXIO and a
6869
+ // directory with EISDIR instead of blocking this process.
6870
+ const stateBytesBefore = (0, shell_command_projection_cjs_1.platformReadBytesSync)(statePath);
6871
+ if (stateBytesBefore === null) {
6872
+ emit({ error: 'STATE.md not found' }, raw);
6873
+ return { pruned: false, error: 'STATE.md not found' };
6874
+ }
6875
+ const rawState = stateBytesBefore.toString('utf-8');
6545
6876
  const fm = extractFrontmatter(rawState, statePath);
6546
6877
  const body = stripFrontmatter(rawState);
6547
6878
  // Prune needs an integer cutoff, so it parses the resolved id itself; a
@@ -6549,8 +6880,7 @@ function cmdStatePrune(cwd, options, raw) {
6549
6880
  const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd))), 10) || 0;
6550
6881
  const cutoff = currentPhase - keepRecent;
6551
6882
  if (cutoff <= 0) {
6552
- emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
6553
- return;
6883
+ return finish({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, 'false');
6554
6884
  }
6555
6885
  const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
6556
6886
  const archived = [];
@@ -6558,8 +6888,8 @@ function cmdStatePrune(cwd, options, raw) {
6558
6888
  // src/state-transition.cts (byte-identical tokenizeHeadings section splicing).
6559
6889
  // This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
6560
6890
  // fallback above), dry-run, and STATE-ARCHIVE.md writes.
6561
- const runPruneCore = (content) => {
6562
- const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock });
6891
+ const runPruneCore = (content, atCutoff = cutoff) => {
6892
+ const result = transitionCore(content, { kind: 'prune', cutoff: atCutoff }, { clock: clock_cjs_1.realClock });
6563
6893
  return {
6564
6894
  newContent: result.content,
6565
6895
  archivedSections: (result.data?.archivedSections) ?? [],
@@ -6567,47 +6897,94 @@ function cmdStatePrune(cwd, options, raw) {
6567
6897
  };
6568
6898
  if (dryRun) {
6569
6899
  // Dry-run: compute what would be pruned without writing anything
6570
- const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
6571
- const result = runPruneCore(content);
6572
- const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
6573
- emit({
6900
+ const dryBytes = (0, shell_command_projection_cjs_1.platformReadBytesSync)(statePath);
6901
+ if (dryBytes === null) {
6902
+ emit({ error: 'STATE.md not found' }, raw);
6903
+ return { pruned: false, error: 'STATE.md not found' };
6904
+ }
6905
+ const dryContent = dryBytes.toString('utf-8');
6906
+ let result = runPruneCore(dryContent);
6907
+ let dryCutoff = cutoff;
6908
+ if (result.archivedSections.length > 0) {
6909
+ // The same decision the real run makes, with the archive read-only: an interrupted run's
6910
+ // block is completed at its cutoff. Like the real run, the preview lists every entry the
6911
+ // effective prune removes from STATE.md, those already in that block included.
6912
+ const archiveBytes = readArchiveSnapshot(archivePath);
6913
+ const block = archiveBytes === null ? null : parseLastArchiveBlock(archiveBytes.toString('utf-8'));
6914
+ const chosen = chooseCutoffRun(block, result, cutoff, currentPhase, (atCutoff) => runPruneCore(dryContent, atCutoff));
6915
+ result = chosen.run;
6916
+ dryCutoff = chosen.cutoff;
6917
+ }
6918
+ const wouldArchive = result.archivedSections;
6919
+ const totalPruned = wouldArchive.reduce((sum, s) => sum + s.count, 0);
6920
+ return finish({
6574
6921
  pruned: false,
6575
6922
  dry_run: true,
6576
- cutoff_phase: cutoff,
6923
+ cutoff_phase: dryCutoff,
6577
6924
  keep_recent: keepRecent,
6578
- sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
6925
+ sections: wouldArchive.map(s => ({ section: s.section, entries_would_archive: s.count })),
6579
6926
  total_would_archive: totalPruned,
6580
6927
  note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
6581
- }, raw, totalPruned > 0 ? 'true' : 'false');
6582
- return;
6583
- }
6928
+ }, totalPruned > 0 ? 'true' : 'false');
6929
+ }
6930
+ // #5831: archive first, then STATE.md, with rollback. Everything runs
6931
+ // inside readModifyWriteStateMd, i.e. under the STATE.md lock, which also
6932
+ // serializes archive writers (this is the only one):
6933
+ // 1. compute the prune and snapshot the archive's exact bytes (or absence);
6934
+ // 2. publish the archive atomically (old content + one `## Pruned ...`
6935
+ // block), appending only the entries the archive's latest block does
6936
+ // not already hold, or skipping it when that block holds them all
6937
+ // (content match, see publishPrunedToArchive);
6938
+ // 3. write STATE.md. If that throws, `onWriteFailure` restores the archive
6939
+ // to its snapshot (or deletes it if it was absent) and the error is
6940
+ // rethrown, so both files are as they were before the prune.
6941
+ // Both writes are atomic (temp file + rename, no truncating fallback). A
6942
+ // failed archive write (step 2) leaves the archive's previous bytes and
6943
+ // throws out of the transform before STATE.md is touched; a failed STATE.md
6944
+ // write leaves it as it was (or is put back from the snapshot) before the
6945
+ // archive is restored. When a restore itself fails, the archive is kept, so
6946
+ // the pruned entries are in at least one file (only a STATE.md write that
6947
+ // damaged STATE.md and whose restore also failed can cost unpruned content,
6948
+ // and that error says so). A process killed between steps 2
6949
+ // and 3 (or a failed archive restore) leaves the entries in both files;
6950
+ // re-running the prune then finds the archive's latest block contained in
6951
+ // what it prunes and appends only what that block lacks (nothing, for the
6952
+ // same options), so each entry is archived once even when the cutoff grew or
6953
+ // other state commands rewrote STATE.md in between. A re-run at a SMALLER
6954
+ // cutoff completes the interrupted run: when the block's heading names a
6955
+ // larger cutoff N, N is no greater than the current phase, and the prune at N
6956
+ // equals the archived block exactly, this invocation prunes at N (reported as
6957
+ // `cutoff_phase`). Otherwise (entries added, edited or removed since the
6958
+ // interruption, or an N past the current phase) this run's own cutoff stays in
6959
+ // force and the whole prune is appended (duplicated, never lost).
6960
+ let rollback = null;
6961
+ let effectiveCutoff = cutoff;
6584
6962
  readModifyWriteStateMd(statePath, (content) => {
6585
- const result = runPruneCore(content);
6963
+ let result = runPruneCore(content);
6964
+ if (result.archivedSections.length > 0) {
6965
+ const published = publishPrunedToArchive(statePath, archivePath, result, (atCutoff) => runPruneCore(content, atCutoff), cutoff, currentPhase, keepRecent);
6966
+ result = published.run;
6967
+ effectiveCutoff = published.cutoff;
6968
+ rollback = published.rollback;
6969
+ }
6586
6970
  archived.push(...result.archivedSections);
6587
6971
  return result.newContent;
6588
- }, cwd);
6589
- // Write archived entries to STATE-ARCHIVE.md
6590
- if (archived.length > 0) {
6591
- const timestamp = clock_cjs_1.realClock.localToday();
6592
- let archiveContent = (0, shell_command_projection_cjs_1.platformReadSync)(archivePath);
6593
- if (archiveContent === null) {
6594
- archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
6595
- }
6596
- archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
6597
- for (const section of archived) {
6598
- archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
6599
- }
6600
- (0, shell_command_projection_cjs_1.platformWriteSync)(archivePath, archiveContent);
6601
- }
6972
+ }, cwd, {
6973
+ atomicWrite: true,
6974
+ onWriteFailure: (stateErr) => {
6975
+ if (rollback !== null)
6976
+ rollback.restore(stateErr);
6977
+ },
6978
+ });
6602
6979
  const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
6603
- emit({
6980
+ return finish({
6604
6981
  pruned: totalPruned > 0,
6605
- cutoff_phase: cutoff,
6982
+ cutoff_phase: effectiveCutoff,
6606
6983
  keep_recent: keepRecent,
6607
6984
  sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
6608
6985
  total_archived: totalPruned,
6609
6986
  archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
6610
- }, raw, totalPruned > 0 ? 'true' : 'false');
6987
+ }, totalPruned > 0 ? 'true' : 'false');
6611
6988
  }
6612
6989
  /**
6613
6990
  * Rebuild STATE.md body structure from canonical sources (ADR-1817).
@@ -7073,6 +7450,14 @@ module.exports = {
7073
7450
  cmdStateValidate,
7074
7451
  cmdStateSync,
7075
7452
  cmdStatePrune,
7453
+ // #5831: the archive append's structured plan and its renderer — the IR
7454
+ // prune's archive write is built from, exposed so tests assert on it.
7455
+ planStateArchiveAppend,
7456
+ renderStateArchiveAppend,
7457
+ // #5831: retry detection by content — the archive's last block and its
7458
+ // subtraction from a prune's sections.
7459
+ parseLastArchiveBlock,
7460
+ subtractPendingBlock,
7076
7461
  cmdStateRebuild,
7077
7462
  cmdStateMilestoneSwitch,
7078
7463
  cmdSignalWaiting,
@@ -426,13 +426,10 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
426
426
  tempDirsToClean.push(rewritten);
427
427
  }
428
428
  }
429
- // #2911: honor kind.home as a FALLBACK-preferred override (e.g. Codex
430
- // skills -> $HOME/.agents), never a blanket replacement — kinds without
431
- // a `home` must keep resolving against layout.configDir. This must stay
432
- // in lockstep with _copyStaged's root selection in src/install-engine.cts;
433
- // the parity test in tests/runtime-artifact-layout-surface.test.cjs
434
- // enforces that the two writers never diverge again.
435
- const dest = assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath);
429
+ // Share the install-root owner with _copyStaged and the install/uninstall
430
+ // plans. The positive-path writer parity test includes applySurface.
431
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(layout.configDir, kind);
432
+ const dest = assertDestWithinConfigHome(installRoot, kind.destSubpath);
436
433
  stagedKinds.push({ kind, staged, dest });
437
434
  }
438
435
  // Do not mutate installed artifacts until every kind has staged