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 +37 -0
- package/README.md +4 -3
- package/SPEC.md +42 -19
- package/dist/src/commands/put.js +1 -1
- package/dist/src/commands/review.js +1 -1
- package/dist/src/commands/shared.js +14 -3
- package/dist/src/ledger.js +141 -22
- package/dist/src/reconcile.js +3 -0
- package/dist/src/renderers/doctor.js +3 -3
- package/dist/src/renderers/review.js +73 -9
- package/dist/src/renderers/status.js +4 -3
- package/dist/src/shared/help-text.js +2 -1
- package/docs/agent-clean.html +7 -6
- package/docs/agent-monitor.html +7 -0
- package/docs/agent-review.html +9 -3
- package/docs/examples/artshelf-review-report.json +74 -19
- package/docs/reference.html +8 -5
- package/docs/schemas/artshelf-review-report.schema.json +318 -73
- package/examples/artshelf-review-report.json +74 -19
- package/package.json +1 -1
- package/schemas/artshelf-review-report.schema.json +318 -73
- package/skills/artshelf/SKILL.md +13 -12
- package/skills/artshelf/examples/artshelf-review-report.json +74 -19
- package/skills/artshelf/schemas/artshelf-review-report.schema.json +318 -73
- package/skills/artshelf/scripts/render-review-report.mjs +4 -3
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,
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
103
|
-
|
|
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,
|
|
257
|
-
plan ids; JSON also includes the next safe action.
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
`
|
|
438
|
-
|
|
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
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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.
|
package/dist/src/commands/put.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/src/ledger.js
CHANGED
|
@@ -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
|
|
560
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
571
|
-
results
|
|
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 === "
|
|
575
|
-
|
|
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 === "
|
|
579
|
-
results
|
|
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
|
-
|
|
583
|
-
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1031
|
+
atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
913
1032
|
}
|
package/dist/src/reconcile.js
CHANGED
|
@@ -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
|
|
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
|
}
|