@agent-delivery-harness/cli 0.1.0 → 0.5.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 (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +10 -5
  4. package/src/boundary.ts +302 -9
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +419 -0
  10. package/src/commands/gate.ts +127 -18
  11. package/src/commands/maintain.ts +203 -0
  12. package/src/commands/managed.ts +478 -0
  13. package/src/commands/prepare.ts +123 -15
  14. package/src/commands/record.ts +100 -8
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +640 -0
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +173 -5
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +40 -4
  23. package/src/main.ts +38 -42
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +24 -0
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +396 -0
  35. package/src/run-server.ts +905 -0
  36. package/src/run-surface.ts +334 -0
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
@@ -0,0 +1,99 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { HARNESS_VERSION, digestCanonical, type Blocker, type RunEvent, type RunEventVersion } from "@agent-delivery-harness/kernel";
4
+ import { commandBlocker, type CommandContext } from "./boundary.ts";
5
+ import { oneLine, resolveRunSurface, type RunSurface } from "./run-surface.ts";
6
+
7
+ export function recoveryBlocker(code: string, summary: string, details?: string): Blocker {
8
+ return commandBlocker({ code, sourceId: "delivery-harness.cli.resume", summary,
9
+ ...(details === undefined ? {} : { details }),
10
+ remediations: [{ id: "reconcile-delivery-context", kind: "manual_action", summary: "Inspect the saved context and actual workspace/external state with host tools; rerun preparation and gates where evidence is not current. Never replay an uncertain action automatically." }],
11
+ });
12
+ }
13
+
14
+ /**
15
+ * What a store rejection may say to an operator: the store's own code, the
16
+ * JSON pointer at the offending member, and the store's own message. All three
17
+ * are written by the store rather than taken from the refused payload, so the
18
+ * diagnostic names the field without ever echoing its value — a contract a
19
+ * caller must keep, because the payload it refused is the one place a
20
+ * credential could have been.
21
+ */
22
+ export function rejectionDetails(rejections: readonly { readonly code: string; readonly pointer: string; readonly message: string }[]): string {
23
+ const first = rejections[0];
24
+ if (first === undefined) return "the store refused the append";
25
+ return `${oneLine(first.code, 64)} at ${oneLine(first.pointer, 128) || "/"}: ${oneLine(first.message, 200)}`;
26
+ }
27
+
28
+ /**
29
+ * The writer members an ordinary-context event carries, for the version the
30
+ * selected run was started at.
31
+ *
32
+ * WHY THE ID IS DERIVED FROM THE OBSERVATION. A v2 event id is a retry key:
33
+ * the store admits a repeat of an id only when the whole event matches, and
34
+ * refuses the id outright for different content. An id derived from the
35
+ * payload's canonical digest is therefore exactly the key the contract wants —
36
+ * the same save repeated is the same event and appends nothing new, while a
37
+ * changed observation is a different id and a new entry, so neither an
38
+ * interrupted retry nor a genuine second save can produce a refusal an
39
+ * operator has to reason about. It is also stable across processes and hosts,
40
+ * which a random id is not.
41
+ */
42
+ export function ordinaryEventWriter(version: RunEventVersion, kind: "context.saved", payload: unknown): {
43
+ readonly version: RunEventVersion;
44
+ readonly eventId?: string;
45
+ } {
46
+ if (version !== "run-event/2") return { version };
47
+ // `kind` is narrowed to the one kind this writer serves, so the prefix is a
48
+ // constant that keeps the id readable and inside the store's 128-character
49
+ // run-id charset. It is deliberately not a general scoping claim: dots are
50
+ // folded to hyphens, which is not injective over arbitrary kinds, so a
51
+ // second kind must not be added here without an injective encoding.
52
+ return { version, eventId: `${kind.replaceAll(".", "-")}-${digestCanonical(payload)}` };
53
+ }
54
+
55
+ export async function recoveryRun(rootDir: string, named?: string): Promise<
56
+ { ok: true; surface: RunSurface; runId: string; events: readonly RunEvent[]; version: RunEventVersion } | { ok: false; blocker: Blocker }
57
+ > {
58
+ const resolved = await resolveRunSurface(rootDir);
59
+ if (!resolved.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The repository run store cannot be read.") };
60
+ const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
61
+ const runId = named ?? (current.ok ? current.runId : undefined);
62
+ if (!runId) return { ok: false, blocker: recoveryBlocker("resume_context_missing", "No current delivery run was found; start a run or name its existing id with --run.") };
63
+ const read = await resolved.surface.store.read(runId);
64
+ if (!read.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The delivery journal is missing, corrupt, or inaccessible.", rejectionDetails(read.rejections)) };
65
+ // The writer version is selected when the run starts and never upgraded, so
66
+ // every writer into this journal reads it from the journal's first event.
67
+ return { ok: true, surface: resolved.surface, runId, events: read.events, version: read.events[0]?.version ?? "run-event/1" };
68
+ }
69
+
70
+ export async function installedRelease(rootDir: string) {
71
+ const document: unknown = JSON.parse(await readFile(path.join(rootDir, ".agent-skills/active.json"), "utf8"));
72
+ const release = (document as { release?: { releaseId?: unknown; profile?: unknown; archiveSha256?: unknown } } | null)?.release;
73
+ if (!release || typeof release.releaseId !== "string" || typeof release.profile !== "string" ||
74
+ typeof release.archiveSha256 !== "string" || !/^[0-9a-f]{64}$/.test(release.archiveSha256)) {
75
+ throw new Error("Installed workflow release identity is missing or malformed.");
76
+ }
77
+ return { runtimeVersion: HARNESS_VERSION, releaseId: release.releaseId, profile: release.profile, archiveSha256: release.archiveSha256 };
78
+ }
79
+
80
+ export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
81
+
82
+ /** Observed action outcomes are a host handoff, never permission to invoke an action. */
83
+ export function reconciliationActions(events: readonly RunEvent[]) {
84
+ const actions = new Map<string, { actionId: string; operation?: unknown; reference: unknown; outcome: string; inconsistent?: boolean }>();
85
+ for (const event of events) {
86
+ const p = event.payload;
87
+ if (event.kind === "action.intent") {
88
+ const actionId = p["actionId"] as string;
89
+ const prior = actions.get(actionId);
90
+ actions.set(actionId, { actionId, operation: p["operation"], reference: p["reference"], outcome: "unknown", ...(prior ? { inconsistent: true } : {}) });
91
+ } else if (event.kind === "action.observed") {
92
+ const actionId = p["actionId"] as string;
93
+ const prior = actions.get(actionId);
94
+ actions.set(actionId, { ...prior, actionId, reference: p["reference"], outcome: p["outcome"] as string,
95
+ ...(!prior || prior.inconsistent || (prior.outcome !== "unknown" && prior.outcome !== p["outcome"]) ? { inconsistent: true } : {}) });
96
+ }
97
+ }
98
+ return [...actions.values()];
99
+ }
@@ -0,0 +1,24 @@
1
+ /** Compatibility exposure; execution is shared by all product callers. */
2
+ export {
3
+ DELIVERY_PROVIDER_RAILS_VERSION,
4
+ type ProviderRailNegotiate,
5
+ type ProviderRailNegotiation,
6
+ type ProviderRailRequest,
7
+ type ProviderRailProgress,
8
+ type ProviderRailEvidence,
9
+ type ProviderRailBlocker,
10
+ type ProviderRailTerminal,
11
+ type ProviderRailCancel,
12
+ type ProviderRailEvent,
13
+ type ProviderRailMessage,
14
+ type ProviderRailConsumption,
15
+ type ConsumeProviderRailOptions,
16
+ consumeProviderRailMessages,
17
+ type ProviderRailSession,
18
+ type OpenProviderRailProcessInput,
19
+ openProviderRailProcess,
20
+ type ProviderRailAttemptInput,
21
+ type ProviderRailAttemptOptions,
22
+ type ProviderRailInvocationResult,
23
+ invokeProviderRail,
24
+ } from "@agent-delivery-harness/kernel";
@@ -0,0 +1,325 @@
1
+ /** Opt-in ownership and pruning for tracked per-digest delivery records. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import { chmod, lstat, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import {
9
+ ProcessLockRefused,
10
+ deriveDeliveryRecordPath,
11
+ resolveRecordStorage,
12
+ sha256Hex,
13
+ withProcessLock,
14
+ } from "@agent-delivery-harness/kernel";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const SCOPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
18
+ const DIGEST = /^[a-f0-9]{64}$/;
19
+ const LEDGER_VERSION = "delivery-record-retention/1";
20
+ const RETENTION_LEAF = "delivery-record-retention";
21
+
22
+ interface Receipt {
23
+ readonly relativePath: string;
24
+ readonly deliverableDigest: string;
25
+ readonly sha256: string;
26
+ }
27
+
28
+ interface Ledger {
29
+ readonly version: typeof LEDGER_VERSION;
30
+ readonly scope: string;
31
+ readonly recordBasePath: string;
32
+ readonly owned: readonly Receipt[];
33
+ readonly pendingPrune: readonly Receipt[];
34
+ }
35
+
36
+ export type RecordRetentionCode =
37
+ | "retention_scope_invalid"
38
+ | "retention_bound_invalid"
39
+ | "retention_ledger_unsafe"
40
+ | "retention_ledger_invalid"
41
+ | "retention_ownership_ambiguous"
42
+ | "retention_path_unowned"
43
+ | "retention_owned_record_changed"
44
+ | "retention_owned_record_unsafe"
45
+ | "retention_history_missing"
46
+ | "retention_write_failed"
47
+ | "retention_cleanup_failed"
48
+ | "retention_lock_unavailable";
49
+
50
+ export type RecordRetentionResult =
51
+ | { readonly ok: true; readonly pruned: readonly string[]; readonly ledgerPath: string }
52
+ | { readonly ok: false; readonly code: RecordRetentionCode; readonly detail: string };
53
+ type RecordRetentionFailure = Extract<RecordRetentionResult, { readonly ok: false }>;
54
+
55
+ export interface ApplyDeliveryRecordRetentionInput {
56
+ readonly rootDir: string;
57
+ readonly storageNamespace: string;
58
+ readonly scope: string;
59
+ readonly keepSuperseded: number;
60
+ readonly recordBasePath: string;
61
+ readonly current: {
62
+ readonly relativePath: string;
63
+ readonly deliverableDigest: string;
64
+ readonly bytes: string;
65
+ };
66
+ readonly writeCurrent: () => Promise<void>;
67
+ }
68
+
69
+ const failure = (code: RecordRetentionCode, detail: string): RecordRetentionFailure => ({ ok: false, code, detail });
70
+ const scopeDigest = (scope: string): string => createHash("sha256").update(scope, "utf8").digest("hex");
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ function receipt(value: unknown, recordBasePath: string): Receipt | undefined {
77
+ if (!isRecord(value) || Object.keys(value).sort().join(",") !== "deliverableDigest,relativePath,sha256") return undefined;
78
+ const relativePath = value["relativePath"];
79
+ const deliverableDigest = value["deliverableDigest"];
80
+ const sha256 = value["sha256"];
81
+ if (typeof relativePath !== "string" || typeof deliverableDigest !== "string" || !DIGEST.test(deliverableDigest) ||
82
+ typeof sha256 !== "string" || !DIGEST.test(sha256) || deriveDeliveryRecordPath(recordBasePath, deliverableDigest) !== relativePath) return undefined;
83
+ return { relativePath, deliverableDigest, sha256 };
84
+ }
85
+
86
+ function parseLedger(value: unknown, expectedName: string): Ledger | undefined {
87
+ if (!isRecord(value) || Object.keys(value).sort().join(",") !== "owned,pendingPrune,recordBasePath,scope,version" ||
88
+ value["version"] !== LEDGER_VERSION || typeof value["scope"] !== "string" || !SCOPE.test(value["scope"]) ||
89
+ typeof value["recordBasePath"] !== "string" || !Array.isArray(value["owned"]) || !Array.isArray(value["pendingPrune"]) ||
90
+ `${scopeDigest(value["scope"])}.json` !== expectedName) return undefined;
91
+ const owned = value["owned"].map((entry) => receipt(entry, value["recordBasePath"] as string));
92
+ const pendingPrune = value["pendingPrune"].map((entry) => receipt(entry, value["recordBasePath"] as string));
93
+ if (owned.some((entry) => entry === undefined) || pendingPrune.some((entry) => entry === undefined)) return undefined;
94
+ const paths = [...owned, ...pendingPrune].map((entry) => entry!.relativePath);
95
+ if (new Set(paths).size !== paths.length) return undefined;
96
+ return {
97
+ version: LEDGER_VERSION,
98
+ scope: value["scope"],
99
+ recordBasePath: value["recordBasePath"],
100
+ owned: owned as Receipt[],
101
+ pendingPrune: pendingPrune as Receipt[],
102
+ };
103
+ }
104
+
105
+ async function readLedgers(storageDir: string): Promise<RecordRetentionFailure | { readonly ok: true; readonly ledgers: Map<string, Ledger> }> {
106
+ const ledgers = new Map<string, Ledger>();
107
+ for (const name of await readdir(storageDir).catch((error: NodeJS.ErrnoException) => {
108
+ if (error.code === "ENOENT") return [] as string[];
109
+ throw error;
110
+ })) {
111
+ if (!name.endsWith(".json")) continue;
112
+ const ledgerPath = path.join(storageDir, name);
113
+ const stats = await lstat(ledgerPath).catch(() => undefined);
114
+ if (stats === undefined || !stats.isFile() || stats.isSymbolicLink() || (stats.mode & 0o077) !== 0) {
115
+ return failure("retention_ledger_unsafe", `${ledgerPath} is not an owner-only regular file`);
116
+ }
117
+ let value: unknown;
118
+ try {
119
+ value = JSON.parse(await readFile(ledgerPath, "utf8"));
120
+ } catch {
121
+ return failure("retention_ledger_invalid", `${ledgerPath} is not valid JSON`);
122
+ }
123
+ const parsed = parseLedger(value, name);
124
+ if (parsed === undefined) return failure("retention_ledger_invalid", `${ledgerPath} has an unsupported or inconsistent shape`);
125
+ ledgers.set(parsed.scope, parsed);
126
+ }
127
+ const owners = new Map<string, string>();
128
+ for (const ledger of ledgers.values()) {
129
+ for (const entry of [...ledger.owned, ...ledger.pendingPrune]) {
130
+ const prior = owners.get(entry.relativePath);
131
+ if (prior !== undefined && prior !== ledger.scope) {
132
+ return failure("retention_ownership_ambiguous", `${entry.relativePath} is claimed by ${prior} and ${ledger.scope}`);
133
+ }
134
+ owners.set(entry.relativePath, ledger.scope);
135
+ }
136
+ }
137
+ return { ok: true, ledgers };
138
+ }
139
+
140
+ async function writeLedger(ledgerPath: string, ledger: Ledger): Promise<void> {
141
+ await mkdir(path.dirname(ledgerPath), { recursive: true, mode: 0o700 });
142
+ await chmod(path.dirname(ledgerPath), 0o700);
143
+ const temporary = `${ledgerPath}.tmp-${randomUUID()}`;
144
+ const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
145
+ try {
146
+ await handle.writeFile(`${JSON.stringify(ledger)}\n`, "utf8");
147
+ await handle.sync();
148
+ await handle.chmod(0o600);
149
+ } finally {
150
+ await handle.close();
151
+ }
152
+ await rename(temporary, ledgerPath);
153
+ }
154
+
155
+ async function verifyWorkingRecord(rootDir: string, entry: Receipt): Promise<RecordRetentionResult | { readonly ok: true }> {
156
+ const absolute = path.join(rootDir, entry.relativePath);
157
+ const stats = await lstat(absolute).catch((error: NodeJS.ErrnoException) => error.code === "ENOENT" ? undefined : Promise.reject(error));
158
+ if (stats === undefined) return failure("retention_owned_record_changed", `${entry.relativePath} is missing`);
159
+ if (!stats.isFile() || stats.isSymbolicLink()) return failure("retention_owned_record_unsafe", `${entry.relativePath} is not a regular file`);
160
+ if (sha256Hex(await readFile(absolute)) !== entry.sha256) {
161
+ return failure("retention_owned_record_changed", `${entry.relativePath} no longer matches its ownership receipt`);
162
+ }
163
+ return { ok: true };
164
+ }
165
+
166
+ async function gitCarriesExactRecord(rootDir: string, entry: Receipt): Promise<boolean> {
167
+ try {
168
+ const { stdout: listing } = await execFileAsync("git", ["ls-tree", "HEAD", "--", entry.relativePath], { cwd: rootDir, encoding: "utf8" });
169
+ if (!listing.startsWith("100644 blob ") || !listing.endsWith(`\t${entry.relativePath}\n`)) return false;
170
+ const { stdout } = await execFileAsync("git", ["show", `HEAD:${entry.relativePath}`], { cwd: rootDir, encoding: "buffer", maxBuffer: 20 * 1024 * 1024 });
171
+ return sha256Hex(stdout) === entry.sha256;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ export async function applyDeliveryRecordRetention(input: ApplyDeliveryRecordRetentionInput): Promise<RecordRetentionResult> {
178
+ if (!SCOPE.test(input.scope)) return failure("retention_scope_invalid", "retention scope must be a plain 1-128 character key");
179
+ if (!Number.isSafeInteger(input.keepSuperseded) || input.keepSuperseded < 0 || input.keepSuperseded > 100) {
180
+ return failure("retention_bound_invalid", "keepSuperseded must be an integer from 0 through 100");
181
+ }
182
+ if (!DIGEST.test(input.current.deliverableDigest) ||
183
+ deriveDeliveryRecordPath(input.recordBasePath, input.current.deliverableDigest) !== input.current.relativePath) {
184
+ return failure("retention_ledger_invalid", "the current path is not the configured per-digest record path");
185
+ }
186
+
187
+ const storage = await resolveRecordStorage(input.rootDir, { storageNamespace: input.storageNamespace, leaf: RETENTION_LEAF });
188
+ const ledgerPath = path.join(storage.storageDir, `${scopeDigest(input.scope)}.json`);
189
+ // Ownership is global to this worktree store. Serializing only one scope
190
+ // would let two scopes reserve the same absent digest path concurrently.
191
+ const lockPath = path.join(storage.storageDir, ".ownership.lock");
192
+ await mkdir(storage.storageDir, { recursive: true, mode: 0o700 });
193
+
194
+ try {
195
+ return await withProcessLock(lockPath, 5000, async () => {
196
+ const loaded = await readLedgers(storage.storageDir);
197
+ if (!loaded.ok) return loaded;
198
+ const existing = loaded.ledgers.get(input.scope);
199
+ if (existing !== undefined && existing.recordBasePath !== input.recordBasePath) {
200
+ return failure("retention_ledger_invalid", `retention scope ${input.scope} was created for a different delivery record path`);
201
+ }
202
+ let ledger: Ledger = existing ?? {
203
+ version: LEDGER_VERSION,
204
+ scope: input.scope,
205
+ recordBasePath: input.recordBasePath,
206
+ owned: [],
207
+ pendingPrune: [],
208
+ };
209
+
210
+ const currentSha = sha256Hex(input.current.bytes);
211
+ const currentReceipt: Receipt = {
212
+ relativePath: input.current.relativePath,
213
+ deliverableDigest: input.current.deliverableDigest,
214
+ sha256: currentSha,
215
+ };
216
+
217
+ // A retried delivery may return to a path whose deletion was durably
218
+ // scheduled by an interrupted later attempt. Cancel that deletion before
219
+ // touching any pending path; the invocation's verified current record wins.
220
+ const returningCurrent = ledger.pendingPrune.find((entry) => entry.relativePath === currentReceipt.relativePath);
221
+ if (returningCurrent !== undefined) {
222
+ if (returningCurrent.sha256 !== currentReceipt.sha256 || returningCurrent.deliverableDigest !== currentReceipt.deliverableDigest) {
223
+ return failure("retention_owned_record_changed", `${currentReceipt.relativePath} conflicts with its pending ownership receipt`);
224
+ }
225
+ ledger = {
226
+ ...ledger,
227
+ owned: [...ledger.owned, returningCurrent],
228
+ pendingPrune: ledger.pendingPrune.filter((entry) => entry.relativePath !== currentReceipt.relativePath),
229
+ };
230
+ await writeLedger(ledgerPath, ledger);
231
+ }
232
+
233
+ // Complete an interrupted, already-proved pruning decision first.
234
+ for (const pending of ledger.pendingPrune) {
235
+ const absolute = path.join(input.rootDir, pending.relativePath);
236
+ const present = await lstat(absolute).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(error));
237
+ if (present) {
238
+ const verified = await verifyWorkingRecord(input.rootDir, pending);
239
+ if (!verified.ok) return verified;
240
+ if (!await gitCarriesExactRecord(input.rootDir, pending)) {
241
+ return failure("retention_history_missing", `${pending.relativePath} is not preserved byte-for-byte at HEAD`);
242
+ }
243
+ try {
244
+ await unlink(absolute);
245
+ } catch (error) {
246
+ return failure("retention_cleanup_failed", `could not remove ${pending.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
248
+ }
249
+ }
250
+ if (ledger.pendingPrune.length > 0) {
251
+ ledger = { ...ledger, pendingPrune: [] };
252
+ await writeLedger(ledgerPath, ledger);
253
+ }
254
+
255
+ const currentIndex = ledger.owned.findIndex((entry) => entry.relativePath === currentReceipt.relativePath);
256
+ if (currentIndex >= 0 && (ledger.owned[currentIndex]!.sha256 !== currentSha ||
257
+ ledger.owned[currentIndex]!.deliverableDigest !== currentReceipt.deliverableDigest)) {
258
+ return failure("retention_owned_record_changed", `${currentReceipt.relativePath} conflicts with its ownership receipt`);
259
+ }
260
+ for (const other of loaded.ledgers.values()) {
261
+ if (other.scope !== input.scope && [...other.owned, ...other.pendingPrune].some((entry) => entry.relativePath === currentReceipt.relativePath)) {
262
+ return failure("retention_ownership_ambiguous", `${currentReceipt.relativePath} belongs to retention scope ${other.scope}`);
263
+ }
264
+ }
265
+ const currentStats = await lstat(path.join(input.rootDir, currentReceipt.relativePath)).catch((error: NodeJS.ErrnoException) =>
266
+ error.code === "ENOENT" ? undefined : Promise.reject(error));
267
+ if (currentIndex < 0 && currentStats !== undefined) {
268
+ return failure("retention_path_unowned", `${currentReceipt.relativePath} existed before retention scope ${input.scope} owned it`);
269
+ }
270
+
271
+ // Validate every prior live receipt before deciding what this attempt may remove.
272
+ for (const entry of ledger.owned) {
273
+ if (entry.relativePath === currentReceipt.relativePath && currentStats === undefined) continue;
274
+ const verified = await verifyWorkingRecord(input.rootDir, entry);
275
+ if (!verified.ok) return verified;
276
+ }
277
+ const ordered = [
278
+ ...ledger.owned.filter((entry) => entry.relativePath !== currentReceipt.relativePath),
279
+ currentReceipt,
280
+ ];
281
+ const retainCount = input.keepSuperseded + 1;
282
+ const pendingPrune = ordered.slice(0, Math.max(0, ordered.length - retainCount));
283
+ for (const entry of pendingPrune) {
284
+ if (!await gitCarriesExactRecord(input.rootDir, entry)) {
285
+ return failure("retention_history_missing", `${entry.relativePath} is not preserved byte-for-byte at HEAD`);
286
+ }
287
+ }
288
+
289
+ // Reserve only an absent output. A crash after this point can recreate it,
290
+ // but can never reinterpret a pre-existing file as scope-owned.
291
+ ledger = { ...ledger, owned: ordered, pendingPrune: [] };
292
+ await writeLedger(ledgerPath, ledger);
293
+ if (currentStats === undefined) {
294
+ try {
295
+ await input.writeCurrent();
296
+ } catch (error) {
297
+ return failure("retention_write_failed", error instanceof Error ? error.message : String(error));
298
+ }
299
+ }
300
+ const verifiedCurrent = await verifyWorkingRecord(input.rootDir, currentReceipt);
301
+ if (!verifiedCurrent.ok) return verifiedCurrent;
302
+
303
+ if (pendingPrune.length > 0) {
304
+ const kept = ordered.slice(pendingPrune.length);
305
+ ledger = { ...ledger, owned: kept, pendingPrune };
306
+ await writeLedger(ledgerPath, ledger);
307
+ for (const entry of pendingPrune) {
308
+ try {
309
+ await unlink(path.join(input.rootDir, entry.relativePath));
310
+ } catch (error) {
311
+ return failure("retention_cleanup_failed", `could not remove ${entry.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
312
+ }
313
+ }
314
+ ledger = { ...ledger, pendingPrune: [] };
315
+ await writeLedger(ledgerPath, ledger);
316
+ }
317
+ return { ok: true, pruned: pendingPrune.map((entry) => entry.relativePath), ledgerPath };
318
+ });
319
+ } catch (error) {
320
+ if (error instanceof ProcessLockRefused) {
321
+ return failure("retention_lock_unavailable", `retention scope ${input.scope} is already being updated: ${error.reason}`);
322
+ }
323
+ throw error;
324
+ }
325
+ }