artshelf 0.12.0 → 0.13.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Hardened `cleanup --execute` with durable resumability: a `started` receipt is
6
+ written before the first filesystem move so an interrupted run is detectable,
7
+ terminal receipt evidence preserves an artifact's original
8
+ `executedAt`/`cleanedAt`, an artifact already moved into the plan's trash
9
+ directory without terminal receipt evidence is recorded as `trashed` at resume
10
+ time without moving it again, a missing original path with no trash target and no
11
+ receipt evidence stays a skipped missing path rather than a success, and a
12
+ completed receipt replays idempotently without duplicating the Artshelf-owned
13
+ receipt record (NGX-427).
5
14
  - Renamed the published package and CLI binary from `shelf` to `artshelf`,
6
15
  moved project URLs to `calvinnwq/artshelf`, and prepared public npm publishing.
7
16
  - Added a user-level ledger registry plus `--all` review commands so Artshelf can
@@ -110,6 +119,34 @@
110
119
  state drifted since review, stamps the reconcile audit trail (`previousPath`,
111
120
  `reconcilePlanId`, `reconcileReceiptPath`, `reconciledAt`, `reconcileReason`) on
112
121
  every touched row, and writes an Artshelf-owned reconcile receipt.
122
+ - Integrated reconcile findings into `review --agent`, `status --agent`, and
123
+ `doctor --agent` triage: missing-path warnings now route to reconcile dry-run
124
+ guidance before approval, reconciled plans escalate to ready-for-approval, and
125
+ the `ArtshelfReviewReport` schema adds the `reconcile` action type (NGX-438).
126
+ - Moved `artshelf put` registry-warning output from stdout to stderr in human
127
+ mode; `--json` output is unchanged (NGX-429).
128
+
129
+ ## [0.13.1](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.13.0...artshelf-v0.13.1) (2026-06-15)
130
+
131
+
132
+ ### Bug Fixes
133
+
134
+ * **cleanup:** make cleanup --execute resumable after interruption ([d0188f7](https://github.com/calvinnwq/artshelf/commit/d0188f73a62b1ff2d173e26c61c826a67bbc9542))
135
+ * **cleanup:** make cleanup execution resumable ([7ec0ebe](https://github.com/calvinnwq/artshelf/commit/7ec0ebe113f589ccd00ed0fdd1a54034afc242ec))
136
+
137
+ ## [0.13.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.12.0...artshelf-v0.13.0) (2026-06-15)
138
+
139
+
140
+ ### Features
141
+
142
+ * **review:** integrate reconcile findings into agent review packets ([878785e](https://github.com/calvinnwq/artshelf/commit/878785e72c4e65bd8e09572525b05cc020d2f1e1))
143
+ * **review:** integrate reconcile findings into agent triage; move put registry-warning to stderr (NGX-438, NGX-429) ([2573470](https://github.com/calvinnwq/artshelf/commit/25734701b439f617a33609ac98c3fae895199640))
144
+
145
+
146
+ ### Bug Fixes
147
+
148
+ * **review:** include reconcile counts in all-ledger triage ([2eeb2fe](https://github.com/calvinnwq/artshelf/commit/2eeb2fea6eae58bfd652be959f2bb6e28d7cb90f))
149
+ * **review:** keep reconcile approval schema and blocked triage consistent ([0c8925a](https://github.com/calvinnwq/artshelf/commit/0c8925a851023622796f2b8d847fcc89cab3c5f0))
113
150
 
114
151
  ## [0.12.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.11.0...artshelf-v0.12.0) (2026-06-15)
115
152
 
package/README.md CHANGED
@@ -113,9 +113,10 @@ destructive deletion.
113
113
  - **No fresh-plan-then-execute shortcut** — review the plan, then run that plan.
114
114
  - **Trash before delete** — `cleanup=delete` stays refused; physical deletion
115
115
  needs its own reviewed trash purge. No silent deletion, ever.
116
- - **Durable, concurrency-safe writes** — ledger and registry mutations take a
117
- cross-process lock and commit atomically, so overlapping commands never lose
118
- records or leave a half-written ledger.
116
+ - **Durable, resumable cleanup** — execution writes a started receipt before
117
+ moving files, can replay the same plan id after interruption, and ledger and
118
+ registry mutations take a cross-process lock so overlapping commands never
119
+ lose records or leave a half-written ledger.
119
120
  - **`--json` on every command**, so agents can act on structured output.
120
121
  - **`--agent` on `review`/`status`/`doctor`**, a compact, token-efficient
121
122
  decision packet for agents, while the default render stays human-scannable.
package/SPEC.md CHANGED
@@ -99,8 +99,9 @@ Defaults:
99
99
  `put` should refuse to record a path that does not exist unless a future flag
100
100
  explicitly supports planned artifacts. After appending the record, `put`
101
101
  registers the ledger in the ledger registry. Registry registration is
102
- best-effort: if it fails, the record remains appended and output includes a
103
- registry warning or `registryError`.
102
+ best-effort: if it fails, the record remains appended and a registry warning is
103
+ printed to stderr in human mode, or surfaced as a `registryError` field in
104
+ `--json` output, so stdout stays machine-clean.
104
105
 
105
106
  ### `artshelf ledgers`
106
107
 
@@ -253,10 +254,13 @@ included with a `not-created` plan instead of writing a plan file.
253
254
 
254
255
  In `--all` mode, review emits an aggregate triage summary on top of the
255
256
  per-ledger detail. JSON includes a `summary` block with affected-ledger, due,
256
- manual-review, missing-path, executable, and skipped counts plus the preview
257
- plan ids; JSON also includes the next safe action. Human output adds a one-line
258
- triage count and states the same next safe action (repair broken ledgers, dry-run
259
- cleanup, inspect missing paths, or nothing to do). Review never writes a plan, so
257
+ manual-review, missing-path, executable, skipped, and reconcile entry/blocked
258
+ counts plus the preview plan ids; JSON also includes the next safe action. The
259
+ per-ledger human detail appends a `reconcile` count when a ledger has reconcile
260
+ drift. Human output adds a one-line triage count with the same reconcile counts
261
+ and states the same next safe action (repair broken ledgers, dry-run cleanup,
262
+ dry-run reconcile for missing-path or reconcile drift, or nothing to do). Review
263
+ never writes a plan, so
260
264
  the next action always points at an explicit follow-up command.
261
265
 
262
266
  `review`, `status`, and `doctor` share three render modes. The default human
@@ -265,9 +269,13 @@ stays the full, backward-compatible public audit report; and `--agent` emits a c
265
269
  deterministic single-line JSON decision packet for agents, taking precedence over
266
270
  `--json` when both are passed. For `review`, the packet sorts records into
267
271
  ready-for-approval, needs-review-first, and blocked groups. Because review is
268
- read-only and never mints a cleanup plan, the only exact approval target it emits
269
- is `resolve missing`; cleanup-eligible records stay needs-review-first and point
270
- at `cleanup --dry-run`, which mints the reviewed plan id to approve.
272
+ read-only and never mints a cleanup plan, the exact approval targets it emits are
273
+ `resolve missing` and `reconcile`; the `reconcile` target appears only when a
274
+ prior reviewed reconcile plan still matches the live drift. Cleanup-eligible
275
+ records and reconcile drift without a reviewed plan stay needs-review-first and
276
+ point at `cleanup --dry-run` or `reconcile --dry-run`, which mint the reviewed
277
+ plan id to approve. Blocked or ambiguous reconcile findings surface in the
278
+ blocked group with no approval target.
271
279
 
272
280
  ### `artshelf doctor`
273
281
 
@@ -432,10 +440,19 @@ Rules:
432
440
  ledger, and its entries must be well-formed. A mismatched or malformed plan is
433
441
  refused without moving files or writing a receipt, mirroring the live-record
434
442
  re-checks `trash purge --execute` performs.
435
- - Writes a cleanup receipt and appends or refreshes an Artshelf-owned ledger record
436
- for that receipt with `owner=artshelf`, `kind=run-artifact`, `ttl=30d`,
437
- `cleanup=review`, and labels including `artshelf`, `cleanup-receipt`, and the
438
- plan id.
443
+ - Writes a `started` cleanup receipt to `<ledger-dir>/receipts/<plan-id>.json` before
444
+ the first filesystem move, then completes the receipt with `completedAt` and the
445
+ per-entry `trashed`, `review-required`, `refused`, or `skipped` results.
446
+ - Appends or refreshes an Artshelf-owned ledger record for the completed receipt with
447
+ `owner=artshelf`, `kind=run-artifact`, `ttl=30d`, `cleanup=review`, and labels
448
+ including `artshelf`, `cleanup-receipt`, and the plan id.
449
+ - Resumes an interrupted run on rerun of the same plan id: terminal receipt evidence
450
+ for an artifact keeps its original `executedAt`/`cleanedAt`, an artifact already
451
+ moved into the plan's trash directory without terminal receipt evidence is recorded
452
+ as `trashed` at resume time without moving it again, a missing original path with no
453
+ trash target and no receipt evidence stays a skipped missing path rather than a
454
+ success, and a completed receipt replays idempotently without duplicating the
455
+ Artshelf-owned receipt record.
439
456
  - Updates touched ledger records so handled artifacts stop appearing as active
440
457
  cleanup candidates.
441
458
  - Uses trash/review behavior by default.
@@ -821,11 +838,15 @@ Operational rules that back those boundaries:
821
838
  - Dry-run first.
822
839
  - Execute only by plan id.
823
840
  - Trash/review before delete.
824
- - Execute updates ledger state after writing the cleanup receipt. A trashed,
825
- review-required, or refused record no longer participates in future `due` or
826
- cleanup dry-run output by default.
827
- - Missing paths update the report; they are not treated as a successful cleanup
828
- unless the user explicitly repairs the ledger later.
841
+ - Execute writes a `started` cleanup receipt before the first filesystem move,
842
+ updates ledger state after recording per-entry outcomes, and completes the
843
+ receipt with `completedAt`. A trashed, review-required, or refused record no
844
+ longer participates in future `due` or cleanup dry-run output by default.
845
+ - Rerunning the same plan id resumes or replays durable receipt/trash evidence:
846
+ terminal receipt evidence keeps its original cleanup timestamp, existing
847
+ plan-trash targets are not moved again, completed receipts are idempotent,
848
+ and missing paths without receipt or trash evidence stay skipped rather than
849
+ successful.
829
850
  - Cleanup never scans arbitrary filesystem paths for deletion in v1.
830
851
  - Cleanup only acts on ledger entries.
831
852
  - Trash purge is scoped to one ledger, requires a reviewed purge plan id, and
@@ -949,7 +970,9 @@ human review.
949
970
  creates.
950
971
  - Cleanup execute refuses to run without a plan id, and refuses an unsafe,
951
972
  mismatched, or malformed plan before moving files or writing a receipt.
952
- - Cleanup execute writes a receipt.
973
+ - Cleanup execute writes a started receipt before moving files, resumes or
974
+ replays the same plan id from receipt/trash evidence, and completes the
975
+ receipt idempotently.
953
976
  - CLI can list trashed records (single ledger or `--all`) and purge them through
954
977
  an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
955
978
  receipt; purge refuses `--all` and never deletes without a reviewed plan id.
@@ -31,6 +31,6 @@ export function handlePut(parsed, ledgerPath, json) {
31
31
  return printJson({ ok: true, record, ledgerPath, registryPath, ...(ledger ? { ledger } : {}), ...(registryError ? { registryError } : {}) });
32
32
  process.stdout.write(`recorded ${record.id}\npath: ${record.path}\nretains until: ${record.retainUntil ?? "manual review"}\nledger: ${ledgerPath}\n`);
33
33
  if (registryError)
34
- process.stdout.write(`registry warning: ${registryError}\n`);
34
+ process.stderr.write(`registry warning: ${registryError}\n`);
35
35
  return 0;
36
36
  }
@@ -15,7 +15,7 @@ export function handleReview(parsed, ledgerPath, json) {
15
15
  printCompactJson(buildReviewAgentPacketAll(results, summary, { path: registryPath, exists: existsSync(registryPath) }));
16
16
  return ok ? 0 : 1;
17
17
  }
18
- const nextAction = reviewNextAction(summary, "all");
18
+ const nextAction = reviewNextAction(summary, "all", undefined, registryPath);
19
19
  if (json) {
20
20
  printJson({ ok, registryPath, summary, nextAction, ledgers: results.map(reviewJsonResult) });
21
21
  return ok ? 0 : 1;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dueEntries, previewCleanupPlan, readLedger, validateLedger } from "../ledger.js";
3
3
  import { listRegisteredLedgers } from "../registry.js";
4
+ import { matchingReconcilePlan, previewReconcilePlan } from "../reconcile.js";
4
5
  import { printJson } from "../renderers/json.js";
5
6
  export function registeredLedgersOrThrow(registryPath) {
6
7
  const ledgers = listRegisteredLedgers(registryPath);
@@ -43,15 +44,19 @@ export function reviewLedger(ledger, registered = true) {
43
44
  ledgerExists,
44
45
  validate,
45
46
  due: [],
46
- plan: emptyReviewPlan(ledger.path)
47
+ plan: emptyReviewPlan(ledger.path),
48
+ reconcile: null
47
49
  };
48
50
  }
51
+ const reconcilePlan = previewReconcilePlan(ledger.path);
52
+ const reviewedReconcilePlan = reconcilePlan.entries.length > 0 || reconcilePlan.blocked.length > 0 ? matchingReconcilePlan(ledger.path, reconcilePlan) : null;
49
53
  return {
50
54
  ledger,
51
55
  ledgerExists,
52
56
  validate,
53
57
  due: dueEntries(readLedger(ledger.path)),
54
- plan: previewCleanupPlan(ledger.path)
58
+ plan: previewCleanupPlan(ledger.path),
59
+ reconcile: { plan: reconcilePlan, reviewedPlan: reviewedReconcilePlan }
55
60
  };
56
61
  }
57
62
  export function reviewJsonResult(result) {
@@ -147,6 +152,8 @@ export function summarizeReview(results) {
147
152
  missingPath: 0,
148
153
  executable: 0,
149
154
  skipped: 0,
155
+ reconcileEntries: 0,
156
+ reconcileBlocked: 0,
150
157
  previewPlanIds: []
151
158
  };
152
159
  for (const result of results) {
@@ -162,14 +169,18 @@ export function summarizeReview(results) {
162
169
  const due = result.due.filter((entry) => entry.dueStatus === "due").length;
163
170
  const manualReview = result.due.filter((entry) => entry.dueStatus === "manual-review").length;
164
171
  const missingPath = result.due.filter((entry) => entry.dueStatus === "missing-path").length;
172
+ const reconcileEntries = result.reconcile?.plan.entries.length ?? 0;
173
+ const reconcileBlocked = result.reconcile?.plan.blocked.length ?? 0;
165
174
  summary.due += due;
166
175
  summary.manualReview += manualReview;
167
176
  summary.missingPath += missingPath;
168
177
  summary.executable += result.plan.entries.length;
169
178
  summary.skipped += result.plan.skipped.length;
179
+ summary.reconcileEntries += reconcileEntries;
180
+ summary.reconcileBlocked += reconcileBlocked;
170
181
  if (result.plan.planId !== "not-created")
171
182
  summary.previewPlanIds.push(result.plan.planId);
172
- if (!result.validate.ok || due + manualReview + missingPath > 0 || result.plan.entries.length > 0) {
183
+ if (!result.validate.ok || due + manualReview + missingPath + reconcileEntries + reconcileBlocked > 0 || result.plan.entries.length > 0) {
173
184
  summary.affected += 1;
174
185
  }
175
186
  }
@@ -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
  }
@@ -64,6 +64,9 @@ export function createReconcilePlan(ledgerPath) {
64
64
  });
65
65
  return reviewed;
66
66
  }
67
+ export function matchingReconcilePlan(ledgerPath, plan) {
68
+ return matchingExistingReconcilePlan(ledgerPath, plan);
69
+ }
67
70
  // Apply a reviewed reconcile plan (NGX-437 `reconcile --execute`). This is the only
68
71
  // mutating reconcile entrypoint and it is deliberately conservative:
69
72
  // * It refuses up front when the plan id is missing, the plan file is absent, or the
@@ -3,12 +3,12 @@ const DOCTOR_ATTENTION_CATEGORIES = ["stale", "invalid", "warnings"];
3
3
  function doctorAttention(summary) {
4
4
  return DOCTOR_ATTENTION_CATEGORIES.filter((key) => summary[key] > 0);
5
5
  }
6
- function doctorNextAction(blockers, summary) {
6
+ function doctorNextAction(blockers, summary, registryPath) {
7
7
  if (blockers.length > 0) {
8
8
  return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
9
9
  }
10
10
  if (summary.warnings > 0) {
11
- return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf validate --all\` to inspect; nothing is auto-executed`;
11
+ 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`;
12
12
  }
13
13
  return "artshelf is healthy on this machine — cleanup safety enforced; no action needed";
14
14
  }
@@ -39,7 +39,7 @@ export function buildDoctorAgentPacket(report) {
39
39
  attention: doctorAttention(report.summary),
40
40
  blockers,
41
41
  cleanupSafety: report.cleanupSafety,
42
- nextAction: doctorNextAction(blockers, report.summary),
42
+ nextAction: doctorNextAction(blockers, report.summary, report.registryPath),
43
43
  verification: `artshelf doctor --agent --registry ${report.registryPath}`
44
44
  };
45
45
  }