artshelf 0.13.0 → 0.14.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.
@@ -553,41 +553,94 @@ export function executeCleanupPlan(ledgerPath, planId) {
553
553
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
554
554
  assertCleanupPlanExecutable(plan, planId, ledgerPath);
555
555
  const trashRoot = join(dirname(ledgerPath), "trash", planId);
556
+ const receiptPath = receiptPathFor(ledgerPath, planId);
556
557
  return withLedgerLock(ledgerPath, () => {
558
+ const existingReceipt = existsSync(receiptPath) ? readCleanupReceiptIfValid(receiptPath) : null;
559
+ const planReceipt = existingReceipt?.planId === planId ? existingReceipt : null;
560
+ const priorResultById = new Map((planReceipt?.results ?? []).map((result) => [result.id, result]));
557
561
  const records = readLedger(ledgerPath);
562
+ if (planReceipt?.completedAt) {
563
+ if (!records.some((record) => record.status === "active" && isMatchingArtshelfArtifact(record, receiptPath, ["artshelf", "cleanup-receipt", planId]))) {
564
+ registerArtshelfArtifact(ledgerPath, receiptPath, {
565
+ reason: `Artshelf cleanup receipt for plan ${planId}`,
566
+ ttl: "30d",
567
+ kind: "run-artifact",
568
+ cleanup: "review",
569
+ labels: ["artshelf", "cleanup-receipt", planId]
570
+ });
571
+ }
572
+ return { planId, receiptPath, results: planReceipt.results };
573
+ }
558
574
  const recordsById = new Map(records.map((record) => [record.id, record]));
559
- const results = [];
560
- for (const entry of plan.entries) {
575
+ const executedAt = toIso(now());
576
+ const results = plan.entries.map((entry) => {
577
+ const prior = priorResultById.get(entry.id);
578
+ if (prior && isTerminalCleanupResult(prior)) {
579
+ return withCleanupResultExecutedAt(prior, cleanupResultExecutedAt(prior, planReceipt, executedAt));
580
+ }
581
+ return { id: entry.id, action: entry.action, status: "pending", path: entry.path };
582
+ });
583
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
584
+ for (const [index, entry] of plan.entries.entries()) {
561
585
  const record = recordsById.get(entry.id);
586
+ const prior = priorResultById.get(entry.id);
587
+ const priorExecutedAt = prior && isTerminalCleanupResult(prior)
588
+ ? cleanupResultExecutedAt(prior, planReceipt, executedAt)
589
+ : executedAt;
562
590
  if (!record) {
563
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" });
591
+ results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" };
592
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
564
593
  continue;
565
594
  }
566
595
  if (record.status !== "active") {
567
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` });
596
+ if (prior && priorCleanupResultMatchesLedger(record, prior, { planId, receiptPath, executedAt: priorExecutedAt })) {
597
+ results[index] = withCleanupResultExecutedAt(prior, priorExecutedAt);
598
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
599
+ continue;
600
+ }
601
+ results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` };
602
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
568
603
  continue;
569
604
  }
570
- if (!existsSync(entry.path)) {
571
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" });
605
+ if (prior && isTerminalCleanupResult(prior)) {
606
+ results[index] = withCleanupResultExecutedAt(prior, priorExecutedAt);
607
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
572
608
  continue;
573
609
  }
574
- if (entry.action === "delete") {
575
- results.push({ id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1" });
610
+ if (entry.action === "trash") {
611
+ const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
612
+ if (existsSync(target)) {
613
+ results[index] = { id: entry.id, action: entry.action, status: "trashed", path: entry.path, target, executedAt };
614
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
615
+ continue;
616
+ }
617
+ if (existsSync(entry.path)) {
618
+ mkdirSync(trashRoot, { recursive: true });
619
+ renameSync(entry.path, target);
620
+ results[index] = { id: entry.id, action: entry.action, status: "trashed", path: entry.path, target, executedAt };
621
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
622
+ continue;
623
+ }
624
+ results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" };
625
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
626
+ continue;
627
+ }
628
+ if (!existsSync(entry.path)) {
629
+ results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" };
630
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
576
631
  continue;
577
632
  }
578
- if (entry.action === "review") {
579
- results.push({ id: entry.id, action: entry.action, status: "review-required", path: entry.path });
633
+ if (entry.action === "delete") {
634
+ results[index] = { id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1", executedAt };
635
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
580
636
  continue;
581
637
  }
582
- mkdirSync(trashRoot, { recursive: true });
583
- const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
584
- renameSync(entry.path, target);
585
- results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
638
+ results[index] = { id: entry.id, action: entry.action, status: "review-required", path: entry.path, executedAt };
639
+ writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
586
640
  }
587
- const receiptPath = receiptPathFor(ledgerPath, planId);
588
- const executedAt = toIso(now());
589
- writeJson(receiptPath, { planId, executedAt, results });
590
- updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
641
+ const receiptExecutedAt = cleanupReceiptExecutedAt(results, executedAt);
642
+ updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt: receiptExecutedAt, results });
643
+ writeCleanupReceipt(receiptPath, { planId, executedAt: receiptExecutedAt, completedAt: toIso(now()), results });
591
644
  registerArtshelfArtifact(ledgerPath, receiptPath, {
592
645
  reason: `Artshelf cleanup receipt for plan ${planId}`,
593
646
  ttl: "30d",
@@ -598,6 +651,69 @@ export function executeCleanupPlan(ledgerPath, planId) {
598
651
  return { planId, receiptPath, results };
599
652
  });
600
653
  }
654
+ function priorCleanupResultMatchesLedger(record, result, receipt) {
655
+ if (record.cleanupPlanId !== receipt.planId)
656
+ return false;
657
+ if (record.receiptPath !== receipt.receiptPath)
658
+ return false;
659
+ if (record.cleanedAt !== receipt.executedAt)
660
+ return false;
661
+ if (result.status === "trashed") {
662
+ return record.status === "trashed" && !!result.target && record.targetPath === result.target;
663
+ }
664
+ if (result.status === "review-required") {
665
+ return record.status === "review-required";
666
+ }
667
+ if (result.status === "refused") {
668
+ return record.status === "cleanup-refused" && (!result.reason || record.cleanupReason === result.reason);
669
+ }
670
+ return false;
671
+ }
672
+ function isTerminalCleanupResult(result) {
673
+ return result.status === "trashed" || result.status === "review-required" || result.status === "refused";
674
+ }
675
+ function cleanupResultExecutedAt(result, receipt, fallback) {
676
+ if (typeof result.executedAt === "string")
677
+ return result.executedAt;
678
+ if (isTerminalCleanupResult(result) && typeof receipt?.executedAt === "string")
679
+ return receipt.executedAt;
680
+ return fallback;
681
+ }
682
+ function withCleanupResultExecutedAt(result, executedAt) {
683
+ return { ...result, executedAt };
684
+ }
685
+ function cleanupReceiptExecutedAt(results, fallback) {
686
+ const terminalExecutedAt = results
687
+ .filter(isTerminalCleanupResult)
688
+ .map((result) => result.executedAt)
689
+ .filter((value) => typeof value === "string");
690
+ const firstExecutedAt = terminalExecutedAt[0];
691
+ if (firstExecutedAt && terminalExecutedAt.every((value) => value === firstExecutedAt)) {
692
+ return firstExecutedAt;
693
+ }
694
+ return fallback;
695
+ }
696
+ function readCleanupReceiptIfValid(receiptPath) {
697
+ try {
698
+ return readCleanupReceipt(receiptPath);
699
+ }
700
+ catch {
701
+ return null;
702
+ }
703
+ }
704
+ function readCleanupReceipt(receiptPath) {
705
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
706
+ return {
707
+ ...(typeof receipt.planId === "string" ? { planId: receipt.planId } : {}),
708
+ ...(typeof receipt.executedAt === "string" ? { executedAt: receipt.executedAt } : {}),
709
+ ...(typeof receipt.completedAt === "string" ? { completedAt: receipt.completedAt } : {}),
710
+ ...(typeof receipt.status === "string" ? { status: receipt.status } : {}),
711
+ results: Array.isArray(receipt.results) ? receipt.results : []
712
+ };
713
+ }
714
+ function writeCleanupReceipt(receiptPath, receipt) {
715
+ writeJson(receiptPath, receipt);
716
+ }
601
717
  // Exported so the reconcile plan layer (src/reconcile.ts) registers its dry-run plan
602
718
  // artifacts through the same upsert-by-path-and-labels path that cleanup plans use,
603
719
  // keeping plan files tracked and reused under a stable plan id.
@@ -712,31 +828,34 @@ function updateLedgerAfterCleanup(ledgerPath, records, receipt) {
712
828
  if (!result)
713
829
  return record;
714
830
  if (result.status === "trashed") {
831
+ const cleanedAt = result.executedAt ?? receipt.executedAt;
715
832
  return {
716
833
  ...record,
717
834
  status: "trashed",
718
835
  cleanupPlanId: receipt.planId,
719
836
  receiptPath: receipt.receiptPath,
720
- cleanedAt: receipt.executedAt,
837
+ cleanedAt,
721
838
  ...(result.target ? { targetPath: result.target } : {})
722
839
  };
723
840
  }
724
841
  if (result.status === "review-required") {
842
+ const cleanedAt = result.executedAt ?? receipt.executedAt;
725
843
  return {
726
844
  ...record,
727
845
  status: "review-required",
728
846
  cleanupPlanId: receipt.planId,
729
847
  receiptPath: receipt.receiptPath,
730
- cleanedAt: receipt.executedAt
848
+ cleanedAt
731
849
  };
732
850
  }
733
851
  if (result.status === "refused") {
852
+ const cleanedAt = result.executedAt ?? receipt.executedAt;
734
853
  return {
735
854
  ...record,
736
855
  status: "cleanup-refused",
737
856
  cleanupPlanId: receipt.planId,
738
857
  receiptPath: receipt.receiptPath,
739
- cleanedAt: receipt.executedAt,
858
+ cleanedAt,
740
859
  ...(result.reason ? { cleanupReason: result.reason } : {})
741
860
  };
742
861
  }
@@ -909,5 +1028,5 @@ function assertCleanupPlanExecutable(plan, planId, ledgerPath) {
909
1028
  }
910
1029
  function writeJson(path, value) {
911
1030
  mkdirSync(dirname(path), { recursive: true });
912
- writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
1031
+ atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
913
1032
  }
@@ -0,0 +1,259 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { assertSafeGeneratedId } from "./ledger.js";
5
+ import { withPathLock } from "./locks.js";
6
+ import { listRegisteredLedgers, normalizeRegistryPath, removeRegisteredLedgers } from "./registry.js";
7
+ import { now, toIso } from "./time.js";
8
+ // Classify the registry into prune findings (read-only). A registration is prunable
9
+ // when its ledger file is missing — the same "missing/stale" signal `ledgers list`
10
+ // reports via existence of the ledger path. Registrations whose resolved path appears
11
+ // more than once are ambiguous: pruning one would silently drop a sibling, so they are
12
+ // blocked for manual resolution instead of pruned. Present ledger files yield nothing.
13
+ export function classifyRegistryPruneFindings(registryPath) {
14
+ const entries = listRegisteredLedgers(normalizeRegistryPath(registryPath));
15
+ const pathCounts = new Map();
16
+ for (const entry of entries) {
17
+ pathCounts.set(entry.path, (pathCounts.get(entry.path) ?? 0) + 1);
18
+ }
19
+ const findings = [];
20
+ for (const entry of entries) {
21
+ if ((pathCounts.get(entry.path) ?? 0) > 1) {
22
+ findings.push(finding(entry, "blocked", "ambiguous duplicate registry path; resolve manually before pruning"));
23
+ continue;
24
+ }
25
+ if (existsSync(entry.path))
26
+ continue;
27
+ findings.push(finding(entry, "prune", "registered ledger file is missing"));
28
+ }
29
+ return findings;
30
+ }
31
+ // Build the registry-prune plan without persisting anything (dry-run preview). Fully
32
+ // read-only: it classifies the registry and returns the plan a `--dry-run` would
33
+ // create, but never writes a plan file or mutates the registry. A plan with no
34
+ // actionable entries collapses to the not-created shape so callers can render
35
+ // "nothing to prune" the same way cleanup and reconcile do.
36
+ export function previewRegistryPrunePlan(registryPath) {
37
+ const plan = buildRegistryPrunePlan(normalizeRegistryPath(registryPath));
38
+ return plan.entries.length === 0 ? noCreatedRegistryPrunePlan(plan) : plan;
39
+ }
40
+ // Create (or reuse) a reviewed registry-prune plan (dry-run). This is the only part of
41
+ // dry-run that writes, and it only writes the plan file — never the registry. When an
42
+ // earlier plan already covers the same prunable entries it is reused verbatim (stable
43
+ // plan id), and when nothing is actionable no plan artifact is created at all, keeping
44
+ // dry-run side-effect-free in that case. The plan file lives next to the registry under
45
+ // `registry-prune-plans/` so a later `--execute` can discover it by exact plan id.
46
+ export function createRegistryPrunePlan(registryPath) {
47
+ const normalized = normalizeRegistryPath(registryPath);
48
+ const plan = buildRegistryPrunePlan(normalized);
49
+ if (plan.entries.length === 0)
50
+ return noCreatedRegistryPrunePlan(plan);
51
+ const existing = matchingExistingRegistryPrunePlan(normalized, plan);
52
+ const reviewed = existing ? { ...plan, planId: existing.planId, planPath: existing.planPath } : plan;
53
+ if (!reviewed.planPath)
54
+ throw new Error("registry prune plan path was not created");
55
+ writeRegistryPrunePlanFile(reviewed.planPath, reviewed);
56
+ return reviewed;
57
+ }
58
+ // Apply a reviewed registry-prune plan (NGX-481 `ledgers prune --execute`). This is the
59
+ // only mutating registry-prune entrypoint and it is deliberately conservative:
60
+ // * It refuses up front when the plan id is missing, the registry is absent, the plan
61
+ // file is absent, or the plan file's declared id/registry does not match the scoped
62
+ // request (no fresh plan, no `--all`; it binds to one exact reviewed plan id against
63
+ // one exact registry path).
64
+ // * Inside one registry lock it re-classifies the live registry and only removes a
65
+ // planned entry that still classifies as prunable; entries whose ledger file
66
+ // reappeared or whose path became an ambiguous duplicate are skipped, not removed.
67
+ // * It writes a rollback copy of the registry before mutating and a receipt after,
68
+ // then verifies the removed registrations are actually gone.
69
+ export function executeRegistryPrunePlan(registryPath, planId) {
70
+ if (!planId)
71
+ throw new Error("ledgers prune --execute requires --plan-id");
72
+ const normalized = normalizeRegistryPath(registryPath);
73
+ if (!existsSync(normalized))
74
+ throw new Error(`Registry not found: ${normalized}`);
75
+ const planPath = registryPrunePlanPath(normalized, planId);
76
+ if (!existsSync(planPath))
77
+ throw new Error(`Registry prune plan not found: ${planId}`);
78
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
79
+ assertRegistryPrunePlanExecutable(plan, planId, normalized);
80
+ const receiptPath = registryPruneReceiptPath(normalized, planId);
81
+ const rollbackPath = registryPruneRollbackPath(normalized, planId);
82
+ return withPathLock(normalized, () => {
83
+ const existingReceipt = readExistingRegistryPruneReceipt(receiptPath, planId, normalized);
84
+ if (existingReceipt)
85
+ return existingReceipt;
86
+ const liveByKey = new Map(classifyRegistryPruneFindings(normalized).map((item) => [pruneKey(item.name, item.path), item]));
87
+ const removable = [];
88
+ const skipped = [];
89
+ for (const entry of plan.entries) {
90
+ if (liveByKey.get(pruneKey(entry.name, entry.path))?.status === "prune")
91
+ removable.push(entry);
92
+ else
93
+ skipped.push(removal(entry.name, entry.path, entry.scope));
94
+ }
95
+ let removedEntries = [];
96
+ const receiptRollbackPath = removable.length > 0 ? rollbackPath : null;
97
+ if (removable.length > 0) {
98
+ copyRegistrySnapshot(normalized, rollbackPath);
99
+ removedEntries = removeRegisteredLedgers(normalized, removable.map((entry) => ({ name: entry.name, path: entry.path })));
100
+ }
101
+ const removed = removedEntries.map((entry) => removal(entry.name, entry.path, entry.scope));
102
+ const verification = verifyRegistryPrune(normalized, removed);
103
+ const receipt = {
104
+ planId,
105
+ registryPath: normalized,
106
+ executedAt: toIso(now()),
107
+ rollbackPath: receiptRollbackPath,
108
+ removed,
109
+ skipped,
110
+ verification,
111
+ receiptPath
112
+ };
113
+ writeRegistryPruneReceiptFile(receiptPath, receipt);
114
+ return receipt;
115
+ }, "Artshelf ledger registry");
116
+ }
117
+ function finding(entry, status, reason) {
118
+ return { name: entry.name, path: entry.path, scope: entry.scope, status, reason };
119
+ }
120
+ function buildRegistryPrunePlan(registryPath) {
121
+ const generatedAt = now();
122
+ const findings = classifyRegistryPruneFindings(registryPath);
123
+ const entries = findings.filter((item) => item.status === "prune").map(planEntry);
124
+ const skipped = findings.filter((item) => item.status === "blocked").map(planEntry);
125
+ const planId = makeRegistryPrunePlanId(generatedAt);
126
+ return {
127
+ planId,
128
+ generatedAt: toIso(generatedAt),
129
+ registryPath,
130
+ entries,
131
+ skipped,
132
+ planPath: registryPrunePlanPath(registryPath, planId)
133
+ };
134
+ }
135
+ function planEntry(item) {
136
+ return { name: item.name, path: item.path, scope: item.scope, reason: item.reason };
137
+ }
138
+ function noCreatedRegistryPrunePlan(plan) {
139
+ return { ...plan, planId: "not-created", planPath: null };
140
+ }
141
+ // Reuse an unexecuted earlier plan whose prunable entries match this one's, so repeated
142
+ // dry-runs converge on a single stable plan id (mirrors cleanup/reconcile plan reuse).
143
+ // Only the structural entry fields are fingerprinted; volatile fields (generatedAt) and
144
+ // the review-only skipped list do not affect reuse.
145
+ function matchingExistingRegistryPrunePlan(registryPath, plan) {
146
+ const plansDir = join(dirname(registryPath), "registry-prune-plans");
147
+ if (!existsSync(plansDir))
148
+ return null;
149
+ const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
150
+ for (const filename of filenames) {
151
+ const planPath = join(plansDir, filename);
152
+ try {
153
+ const candidate = JSON.parse(readFileSync(planPath, "utf8"));
154
+ if (candidate.registryPath !== registryPath)
155
+ continue;
156
+ if (registryPrunePlanFingerprint(candidate) !== registryPrunePlanFingerprint(plan))
157
+ continue;
158
+ if (existsSync(registryPruneReceiptPath(registryPath, candidate.planId)))
159
+ continue;
160
+ return { ...candidate, planPath };
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ function registryPrunePlanFingerprint(plan) {
169
+ return JSON.stringify(plan.entries.map((entry) => ({ name: entry.name, path: entry.path, scope: entry.scope })));
170
+ }
171
+ function writeRegistryPrunePlanFile(planPath, plan) {
172
+ mkdirSync(dirname(planPath), { recursive: true });
173
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
174
+ }
175
+ function makeRegistryPrunePlanId(date) {
176
+ return `registry-prune_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
177
+ }
178
+ function registryPrunePlanPath(registryPath, planId) {
179
+ assertSafeGeneratedId(planId, "registry prune plan id");
180
+ return join(dirname(registryPath), "registry-prune-plans", `${planId}.json`);
181
+ }
182
+ // Bind a loaded registry-prune plan to the request before any registry mutation,
183
+ // mirroring reconcile's assertReconcilePlanExecutable: the plan must declare the
184
+ // requested id, belong to the executing registry, and carry well-formed entries.
185
+ function assertRegistryPrunePlanExecutable(plan, planId, registryPath) {
186
+ if (plan.planId !== planId) {
187
+ throw new Error(`Registry prune plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
188
+ }
189
+ if (plan.registryPath !== registryPath) {
190
+ throw new Error(`Registry prune plan registry mismatch: plan was created for ${plan.registryPath}, executing ${registryPath}`);
191
+ }
192
+ if (!Array.isArray(plan.entries)) {
193
+ throw new Error(`Registry prune plan entries are malformed: ${planId}`);
194
+ }
195
+ for (const entry of plan.entries) {
196
+ if (!entry || typeof entry.name !== "string" || typeof entry.path !== "string") {
197
+ throw new Error(`Registry prune plan entries are malformed: ${planId}`);
198
+ }
199
+ }
200
+ }
201
+ // Re-scan the registry after mutation and confirm every removed registration is gone.
202
+ // `ok` stays true only when none of them resurface; `remainingPrunable` counts any
203
+ // prunable registrations left registry-wide so the receipt reflects whether the
204
+ // registry is fully clean or other plans still have work.
205
+ function verifyRegistryPrune(registryPath, removed) {
206
+ const live = classifyRegistryPruneFindings(registryPath);
207
+ const stillPresent = removed.filter((entry) => live.some((item) => item.name === entry.name && item.path === entry.path));
208
+ const remainingPrunable = live.filter((item) => item.status === "prune").length;
209
+ return {
210
+ ok: stillPresent.length === 0,
211
+ remainingPrunable,
212
+ detail: stillPresent.length === 0
213
+ ? "removed registrations are gone; registry re-scan is clean of them"
214
+ : `still registered after prune: ${stillPresent.map((entry) => entry.name).join(", ")}`
215
+ };
216
+ }
217
+ // Snapshot the registry verbatim before mutation so the receipt's rollbackPath points
218
+ // at a restorable copy. The registry is always UTF-8 JSON, so a read/write round-trip
219
+ // reproduces it byte-for-byte and keeps file I/O consistent with the rest of the code.
220
+ function copyRegistrySnapshot(registryPath, rollbackPath) {
221
+ mkdirSync(dirname(rollbackPath), { recursive: true });
222
+ writeFileSync(rollbackPath, readFileSync(registryPath, "utf8"));
223
+ }
224
+ function readExistingRegistryPruneReceipt(receiptPath, planId, registryPath) {
225
+ if (!existsSync(receiptPath))
226
+ return null;
227
+ let receipt;
228
+ try {
229
+ receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
230
+ }
231
+ catch {
232
+ throw new Error(`Registry prune receipt already exists but is unreadable: ${receiptPath}`);
233
+ }
234
+ if (receipt.planId !== planId || receipt.registryPath !== registryPath || receipt.receiptPath !== receiptPath) {
235
+ throw new Error(`Registry prune receipt already exists for a different execution: ${receiptPath}`);
236
+ }
237
+ if (!Array.isArray(receipt.removed) || !Array.isArray(receipt.skipped) || !receipt.verification) {
238
+ throw new Error(`Registry prune receipt already exists but is malformed: ${receiptPath}`);
239
+ }
240
+ return receipt;
241
+ }
242
+ function removal(name, path, scope) {
243
+ return { name, path, scope };
244
+ }
245
+ function pruneKey(name, path) {
246
+ return JSON.stringify([name, path]);
247
+ }
248
+ function registryPruneReceiptPath(registryPath, planId) {
249
+ assertSafeGeneratedId(planId, "registry prune plan id");
250
+ return join(dirname(registryPath), "registry-prune-receipts", `${planId}.json`);
251
+ }
252
+ function registryPruneRollbackPath(registryPath, planId) {
253
+ assertSafeGeneratedId(planId, "registry prune plan id");
254
+ return join(dirname(registryPath), "registry-prune-rollbacks", `${planId}.json`);
255
+ }
256
+ function writeRegistryPruneReceiptFile(receiptPath, receipt) {
257
+ mkdirSync(dirname(receiptPath), { recursive: true });
258
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
259
+ }
@@ -51,6 +51,33 @@ export function registerLedger(input) {
51
51
  return entry;
52
52
  });
53
53
  }
54
+ // Remove registrations matching the given (name, path) targets, returning the entries
55
+ // actually removed. The approval-gated registry prune execute composes this under its
56
+ // own registry lock (re-entrant), so classification, rollback copy, mutation, and
57
+ // verification all stay inside one critical section. Matching on both name and path
58
+ // keeps removal precise when two registrations happen to share a path. The registry is
59
+ // only rewritten when something is actually removed, so a no-op target list is inert.
60
+ export function removeRegisteredLedgers(registryPath, targets) {
61
+ const normalized = normalizeRegistryPath(registryPath);
62
+ return withRegistryLock(normalized, () => {
63
+ const registry = readRegistry(normalized);
64
+ const wanted = new Set(targets.map((target) => removalKey(target.name, resolve(target.path))));
65
+ const removed = [];
66
+ const kept = [];
67
+ for (const entry of registry.ledgers) {
68
+ if (wanted.has(removalKey(entry.name, entry.path)))
69
+ removed.push(entry);
70
+ else
71
+ kept.push(entry);
72
+ }
73
+ if (removed.length > 0)
74
+ writeRegistry(normalized, { version: 1, ledgers: kept });
75
+ return removed;
76
+ });
77
+ }
78
+ function removalKey(name, path) {
79
+ return JSON.stringify([name, path]);
80
+ }
54
81
  function writeRegistry(registryPath, registry) {
55
82
  mkdirSync(dirname(registryPath), { recursive: true });
56
83
  const tmpPath = `${registryPath}.${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.tmp`;
@@ -5,7 +5,17 @@ function doctorAttention(summary) {
5
5
  }
6
6
  function doctorNextAction(blockers, summary, registryPath) {
7
7
  if (blockers.length > 0) {
8
- return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
8
+ const fixes = [];
9
+ if (summary.stale > 0) {
10
+ fixes.push(`run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`);
11
+ }
12
+ if (summary.invalid > 0) {
13
+ fixes.push(`repair ${summary.invalid} invalid ledger file(s) above`);
14
+ }
15
+ if (fixes.length === 0) {
16
+ return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
17
+ }
18
+ return `${fixes.join("; ")}, then re-run \`artshelf doctor\``;
9
19
  }
10
20
  if (summary.warnings > 0) {
11
21
  return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf reconcile --dry-run --all --registry ${registryPath}\` to prepare reconcile-ready approvals, then run \`artshelf review --all --registry ${registryPath}\`; nothing is auto-executed`;
@@ -11,6 +11,14 @@ export function reviewNextAction(summary, scope, ledgerPath, registryPath) {
11
11
  const broken = summary.invalid + summary.stale;
12
12
  const review = statusCommand(scope, "review", ledgerPath);
13
13
  if (broken > 0) {
14
+ if (scope === "all" && summary.stale > 0 && registryPath) {
15
+ const fixes = [
16
+ `run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`
17
+ ];
18
+ if (summary.invalid > 0)
19
+ fixes.push(`repair ${summary.invalid} invalid ledger(s) above (re-register or fix the file)`);
20
+ return `${fixes.join("; ")}, then re-run \`${review}\``;
21
+ }
14
22
  const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
15
23
  return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
16
24
  }
@@ -45,7 +53,7 @@ export function printReview(results) {
45
53
  process.stdout.write(`ledger: ${result.ledger.path}\n`);
46
54
  }
47
55
  }
48
- function buildReviewDecisions(results, scope) {
56
+ function buildReviewDecisions(results, scope, registryPath) {
49
57
  const readyForApproval = [];
50
58
  const needsReviewFirst = [];
51
59
  const blocked = [];
@@ -54,6 +62,20 @@ function buildReviewDecisions(results, scope) {
54
62
  const { ledger, validate, due } = result;
55
63
  if (!validate.ok) {
56
64
  const status = result.ledgerExists ? "invalid" : "missing";
65
+ // A missing registered ledger is repaired through the approval-gated registry-prune
66
+ // flow rather than hand-editing the registry; an invalid-but-present file still needs
67
+ // a manual re-register/fix.
68
+ if (scope === "all" && status === "missing" && registryPath) {
69
+ blocked.push({
70
+ label: `Prune ${ledger.name} registration (missing)`,
71
+ itemIds: [],
72
+ actionType: "fix-registry",
73
+ approvalTarget: null,
74
+ reason: validate.errors[0] ?? "the registered ledger file is missing",
75
+ nextStep: `run \`artshelf ledgers prune --dry-run --registry ${registryPath} --json\` to review removing it, then approve \`approve artshelf ledgers prune registry ${registryPath} plan <plan-id>\``
76
+ });
77
+ continue;
78
+ }
57
79
  const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
58
80
  blocked.push({
59
81
  label: `Repair ${ledger.name} ledger (${status})`,
@@ -177,7 +199,7 @@ function reviewCounts(summary) {
177
199
  };
178
200
  }
179
201
  export function buildReviewAgentPacketAll(results, summary, registry) {
180
- const groups = buildReviewDecisions(results, "all");
202
+ const groups = buildReviewDecisions(results, "all", registry.path);
181
203
  return {
182
204
  schemaVersion: 1,
183
205
  command: "review",
@@ -14,9 +14,17 @@ export function statusCommand(scope, command, ledgerPath) {
14
14
  return `artshelf ${command} --all`;
15
15
  return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
16
16
  }
17
- function statusNextAction(blockers, counts, scope, ledgerPath, registryPath) {
17
+ function statusNextAction(blockers, counts, scope, ledgerPath, registryPath, stale = 0, invalid = 0) {
18
18
  if (blockers.length > 0) {
19
19
  const verify = statusCommand(scope, "status", ledgerPath);
20
+ if (scope === "all" && stale > 0 && registryPath) {
21
+ const fixes = [
22
+ `run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${stale} missing/stale registration(s)`
23
+ ];
24
+ if (invalid > 0)
25
+ fixes.push(`repair ${invalid} invalid ledger(s) above`);
26
+ return `${fixes.join("; ")}, then re-run \`${verify}\``;
27
+ }
20
28
  return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
21
29
  }
22
30
  const review = statusCommand(scope, "review", ledgerPath);
@@ -59,7 +67,7 @@ export function buildStatusAgentPacketAll(report) {
59
67
  counts,
60
68
  attention: statusAttention(counts),
61
69
  blockers,
62
- nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath),
70
+ nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath, report.totals.stale, report.totals.invalid),
63
71
  verification: `artshelf status --all --agent --registry ${report.registryPath}`
64
72
  };
65
73
  }