@opengsd/gsd-core 1.20.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +265 -161
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +554 -823
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +99 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +20 -18
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +20 -28
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-id.cjs +26 -9
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +170 -45
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +103 -46
- package/gsd-core/bin/lib/state.cjs +448 -63
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +168 -13
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +4 -3
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +9 -6
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +168 -13
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -29,7 +29,7 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
29
29
|
const { loadConfig } = configLoaderMod;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
31
|
const phaseIdMod = require("./phase-id.cjs");
|
|
32
|
-
const { parsePhaseFromProse,
|
|
32
|
+
const { parsePhaseFromProse, phaseNumberTokenSource, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
|
|
33
33
|
// #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
|
|
34
34
|
bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -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
|
-
|
|
3917
|
-
const
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
// #
|
|
4466
|
-
//
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
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 {
|
|
@@ -6386,7 +6404,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6386
6404
|
if (completion.value.complete)
|
|
6387
6405
|
diskCompletedPhases++;
|
|
6388
6406
|
// Track the highest phase with incomplete plans (or any plans)
|
|
6389
|
-
const phaseMatch = dir.match(new RegExp(`^(${
|
|
6407
|
+
const phaseMatch = dir.match(new RegExp(`^(${phaseNumberTokenSource(syncConvention)})`, 'i'));
|
|
6390
6408
|
if (phaseMatch && plans > 0) {
|
|
6391
6409
|
if (summaries < plans) {
|
|
6392
6410
|
// Incomplete phase — this is likely the current one
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
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:
|
|
6923
|
+
cutoff_phase: dryCutoff,
|
|
6577
6924
|
keep_recent: keepRecent,
|
|
6578
|
-
sections:
|
|
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
|
-
},
|
|
6582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
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
|
-
|
|
6980
|
+
return finish({
|
|
6604
6981
|
pruned: totalPruned > 0,
|
|
6605
|
-
cutoff_phase:
|
|
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
|
-
},
|
|
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
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
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
|