artshelf 0.13.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
@@ -117,6 +126,14 @@
117
126
  - Moved `artshelf put` registry-warning output from stdout to stderr in human
118
127
  mode; `--json` output is unchanged (NGX-429).
119
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
+
120
137
  ## [0.13.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.12.0...artshelf-v0.13.0) (2026-06-15)
121
138
 
122
139
 
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
@@ -440,10 +440,19 @@ Rules:
440
440
  ledger, and its entries must be well-formed. A mismatched or malformed plan is
441
441
  refused without moving files or writing a receipt, mirroring the live-record
442
442
  re-checks `trash purge --execute` performs.
443
- - Writes a cleanup receipt and appends or refreshes an Artshelf-owned ledger record
444
- for that receipt with `owner=artshelf`, `kind=run-artifact`, `ttl=30d`,
445
- `cleanup=review`, and labels including `artshelf`, `cleanup-receipt`, and the
446
- 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.
447
456
  - Updates touched ledger records so handled artifacts stop appearing as active
448
457
  cleanup candidates.
449
458
  - Uses trash/review behavior by default.
@@ -829,11 +838,15 @@ Operational rules that back those boundaries:
829
838
  - Dry-run first.
830
839
  - Execute only by plan id.
831
840
  - Trash/review before delete.
832
- - Execute updates ledger state after writing the cleanup receipt. A trashed,
833
- review-required, or refused record no longer participates in future `due` or
834
- cleanup dry-run output by default.
835
- - Missing paths update the report; they are not treated as a successful cleanup
836
- 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.
837
850
  - Cleanup never scans arbitrary filesystem paths for deletion in v1.
838
851
  - Cleanup only acts on ledger entries.
839
852
  - Trash purge is scoped to one ledger, requires a reviewed purge plan id, and
@@ -957,7 +970,9 @@ human review.
957
970
  creates.
958
971
  - Cleanup execute refuses to run without a plan id, and refuses an unsafe,
959
972
  mismatched, or malformed plan before moving files or writing a receipt.
960
- - 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.
961
976
  - CLI can list trashed records (single ledger or `--all`) and purge them through
962
977
  an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
963
978
  receipt; purge refuses `--all` and never deletes without a reviewed plan id.
@@ -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
  }
@@ -38,8 +38,8 @@
38
38
  <h1>Execute only what was reviewed and approved.</h1>
39
39
  <p class="lede">
40
40
  Clean is meant to be boring. The human approves one plan, the agent runs
41
- exactly that plan, writes a receipt, and checks that the next review
42
- comes back quiet.
41
+ exactly that plan, leaves durable receipt evidence before moving files,
42
+ and checks that the next review comes back quiet.
43
43
  </p>
44
44
 
45
45
  <section>
@@ -90,10 +90,11 @@ artshelf resolve &lt;id&gt; --status resolved --reason &lt;text&gt;</code></pre>
90
90
  <h2>Verify quiet</h2>
91
91
  <p>After cleanup execute or resolve, verify with <code>artshelf review --all --json</code>.</p>
92
92
  <p>
93
- Execution writes a receipt and updates touched ledger records to
94
- <code>trashed</code>, <code>review-required</code>, or
95
- <code>cleanup-refused</code>. Generated plans and receipts are recorded as
96
- <code>owner=artshelf</code> artifacts.
93
+ Execution writes a started receipt before the first move, completes it after
94
+ ledger updates, and updates touched ledger records to <code>trashed</code>,
95
+ <code>review-required</code>, or <code>cleanup-refused</code>. Rerunning the same
96
+ plan id resumes or idempotently replays durable receipt/trash evidence.
97
+ Generated plans and receipts are recorded as <code>owner=artshelf</code> artifacts.
97
98
  </p>
98
99
  </section>
99
100
  </article>
@@ -170,7 +170,9 @@ artshelf cleanup --execute --plan-id &lt;id&gt; [--ledger &lt;path&gt;] [--json]
170
170
  <code>--dry-run</code> creates and registers a cleanup plan without moving files;
171
171
  no-op dry-runs report <code>not-created</code>, and matching dry-runs reuse the
172
172
  existing plan id. <code>--execute</code> is approval-only for one reviewed plan id:
173
- it writes a receipt, registers the receipt artifact, and updates touched records in the ledger.
173
+ it writes a started receipt before the first move, completes and registers the receipt
174
+ artifact, updates touched records in the ledger, and can resume or idempotently replay
175
+ the same plan id from durable receipt/trash evidence.
174
176
  </p>
175
177
  <div class="callout" data-kind="boundary">
176
178
  <span class="callout-label">Hard boundary</span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artshelf",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Tiny CLI for accountable temporary artifact retention.",
5
5
  "type": "module",
6
6
  "author": "Calvin",
@@ -225,8 +225,9 @@ Cleanup execution requires approval naming the reviewed ledger and plan id:
225
225
  artshelf cleanup --execute --plan-id <id> --ledger <ledger-path> --json
226
226
  ```
227
227
 
228
- Cleanup with `cleanup=trash` quarantines files into Artshelf trash. Physical
229
- deletion belongs to the separate Purge stage.
228
+ If cleanup is interrupted, rerun the same plan id; durable receipt/trash
229
+ evidence resumes or replays without a fresh plan. `cleanup=trash` quarantines
230
+ files into Artshelf trash. Physical deletion belongs to the separate Purge stage.
230
231
 
231
232
  Resolve only after confirmation; it updates the ledger and does not move or
232
233
  delete files: