artshelf 0.14.0 → 0.16.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.
@@ -0,0 +1,207 @@
1
+ import * as fs from "node:fs";
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { shellArg } from "./shared/shell-quote.js";
5
+ import { ageOf, now as currentTime } from "./time.js";
6
+ const DIRECTORY_SIZE_MAX_ENTRIES = 10_000;
7
+ const openDirectorySync = fs.opendirSync;
8
+ export function buildInspectReport(record, options) {
9
+ const at = options.now ?? currentTime();
10
+ const subjectPath = subjectPathOf(record);
11
+ const node = describeNode(subjectPath, record.status === "active");
12
+ const dueState = classifyDueState(record, at, node.existence);
13
+ const { recommendation, nextAction } = recommend(record, node.existence, dueState, options.ledgerPath);
14
+ return {
15
+ schemaVersion: 1,
16
+ id: record.id,
17
+ path: record.path,
18
+ subjectPath,
19
+ kind: record.kind,
20
+ owner: record.owner,
21
+ labels: record.labels,
22
+ status: record.status,
23
+ cleanup: record.cleanup,
24
+ reason: record.reason,
25
+ existence: node.existence,
26
+ nodeKind: node.nodeKind,
27
+ byteSize: node.byteSize,
28
+ byteSizeTruncated: node.byteSizeTruncated,
29
+ age: ageOf(at, record.createdAt),
30
+ retention: record.retention,
31
+ retainUntil: record.retainUntil ?? null,
32
+ dueState,
33
+ recommendation,
34
+ nextAction
35
+ };
36
+ }
37
+ function subjectPathOf(record) {
38
+ if (record.status === "trashed" && record.targetPath)
39
+ return record.targetPath;
40
+ return record.path;
41
+ }
42
+ function describeNode(subjectPath, followSymlinkExistence = false) {
43
+ if (followSymlinkExistence && !existsSync(subjectPath)) {
44
+ return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
45
+ }
46
+ let stat;
47
+ try {
48
+ stat = lstatSync(subjectPath);
49
+ }
50
+ catch {
51
+ return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
52
+ }
53
+ if (stat.isSymbolicLink())
54
+ return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
55
+ if (stat.isFile())
56
+ return { existence: "present", nodeKind: "file", byteSize: stat.size, byteSizeTruncated: false };
57
+ if (stat.isDirectory()) {
58
+ const size = directorySize(subjectPath);
59
+ return { existence: "present", nodeKind: "directory", byteSize: size.bytes, byteSizeTruncated: size.truncated };
60
+ }
61
+ return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
62
+ }
63
+ // Bounded recursive size: sums regular-file bytes, never follows symlinks, and stops at
64
+ // a fixed entry budget so a pathological tree cannot stall a read-only inspect.
65
+ function directorySize(root) {
66
+ let total = 0;
67
+ let visited = 0;
68
+ let incomplete = false;
69
+ const stack = [root];
70
+ while (stack.length > 0) {
71
+ const dir = stack.pop();
72
+ if (dir === undefined)
73
+ break;
74
+ let entries;
75
+ try {
76
+ entries = openDirectorySync(dir);
77
+ }
78
+ catch {
79
+ incomplete = true;
80
+ continue;
81
+ }
82
+ try {
83
+ while (true) {
84
+ let entry;
85
+ try {
86
+ entry = entries.readSync();
87
+ }
88
+ catch {
89
+ incomplete = true;
90
+ break;
91
+ }
92
+ if (entry === null)
93
+ break;
94
+ if (visited >= DIRECTORY_SIZE_MAX_ENTRIES)
95
+ return { bytes: total, truncated: true };
96
+ visited += 1;
97
+ const child = join(dir, entry.name);
98
+ let stat;
99
+ try {
100
+ stat = lstatSync(child);
101
+ }
102
+ catch {
103
+ incomplete = true;
104
+ continue;
105
+ }
106
+ if (stat.isSymbolicLink())
107
+ continue;
108
+ if (stat.isDirectory()) {
109
+ stack.push(child);
110
+ continue;
111
+ }
112
+ if (stat.isFile())
113
+ total += stat.size;
114
+ }
115
+ }
116
+ finally {
117
+ try {
118
+ entries.closeSync();
119
+ }
120
+ catch {
121
+ incomplete = true;
122
+ }
123
+ }
124
+ }
125
+ return { bytes: total, truncated: incomplete };
126
+ }
127
+ // Mirrors ledger due classification, but reuses the existence already observed so a
128
+ // trashed/terminal record never re-stats. Only active records carry a due state.
129
+ function classifyDueState(record, at, existence) {
130
+ if (record.status !== "active")
131
+ return null;
132
+ if (existence === "missing")
133
+ return "missing-path";
134
+ if (record.disposeAction === "keep" && record.disposePlanId && record.disposeReceiptPath && record.disposedAt)
135
+ return "kept";
136
+ if (record.retention.mode === "manual-review")
137
+ return "manual-review";
138
+ if (!record.retainUntil)
139
+ return "due";
140
+ return new Date(record.retainUntil).getTime() <= at.getTime() ? "due" : "kept";
141
+ }
142
+ function recommend(record, existence, dueState, ledgerPath) {
143
+ const idArg = shellArg(record.id);
144
+ const ledgerArg = shellArg(ledgerPath);
145
+ const disposeDryRun = (action, extra = "") => `artshelf dispose --id ${idArg} --action ${action} --dry-run${extra} --ledger ${ledgerArg}`;
146
+ if (record.status === "resolved") {
147
+ return { recommendation: "keep", nextAction: "Already resolved — no action needed." };
148
+ }
149
+ if (record.status === "trashed") {
150
+ if (existence === "missing") {
151
+ return {
152
+ recommendation: "resolve-only",
153
+ nextAction: `Trashed target is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
154
+ };
155
+ }
156
+ return {
157
+ recommendation: "keep",
158
+ nextAction: "Already trashed — permanent removal is the separate approval-gated `artshelf trash purge` flow."
159
+ };
160
+ }
161
+ if (record.status === "review-required") {
162
+ return {
163
+ recommendation: "blocked",
164
+ nextAction: "A cleanup run flagged this for manual review — inspect the artifact, then resolve or re-plan deliberately."
165
+ };
166
+ }
167
+ if (record.status === "cleanup-refused") {
168
+ return {
169
+ recommendation: "blocked",
170
+ nextAction: "A prior cleanup refused this record — handle it manually; Artshelf will not retry automatically."
171
+ };
172
+ }
173
+ if (existence === "missing" || dueState === "missing-path") {
174
+ return {
175
+ recommendation: "resolve-only",
176
+ nextAction: `Path is missing — confirm the artifact is gone, then run \`${disposeDryRun("resolve-only", " --reason '<why>'")}\`, then approve the reviewed plan id.`
177
+ };
178
+ }
179
+ if (dueState === "kept") {
180
+ return {
181
+ recommendation: "snooze",
182
+ nextAction: `Retention holds until ${record.retainUntil ?? "the configured date"} — re-inspect after it expires; nothing is due now.`
183
+ };
184
+ }
185
+ if (dueState === "manual-review") {
186
+ return {
187
+ recommendation: "keep",
188
+ nextAction: `Held for manual review — run \`${disposeDryRun("keep", " --reason '<why>'")}\` to keep it quiet through a reviewed decision, or choose resolve-only/snooze deliberately.`
189
+ };
190
+ }
191
+ if (record.cleanup === "trash") {
192
+ return {
193
+ recommendation: "trash-safe",
194
+ nextAction: `Due and disposable after review — run \`${disposeDryRun("trash-resolve", " --reason '<why>'")}\`, then approve the reviewed plan id.`
195
+ };
196
+ }
197
+ if (record.cleanup === "delete") {
198
+ return {
199
+ recommendation: "blocked",
200
+ nextAction: "Due with cleanup=delete, which Artshelf refuses — switch it to cleanup=trash and plan a cleanup, or resolve it manually."
201
+ };
202
+ }
203
+ return {
204
+ recommendation: "keep",
205
+ nextAction: `Due and held for review — run \`${disposeDryRun("keep", " --reason '<why>'")}\`, or choose resolve-only/snooze deliberately.`
206
+ };
207
+ }
@@ -17,6 +17,15 @@ const KINDS = new Set([
17
17
  const CLEANUP_ACTIONS = new Set(["trash", "review", "delete"]);
18
18
  const STATUSES = new Set(["active", "review-required", "trashed", "cleanup-refused", "resolved"]);
19
19
  const RESOLVE_STATUSES = new Set(["resolved"]);
20
+ function trashProvenance(record) {
21
+ if (record.cleanedAt && record.receiptPath && record.cleanupPlanId) {
22
+ return { cleanedAt: record.cleanedAt, receiptPath: record.receiptPath, cleanupPlanId: record.cleanupPlanId };
23
+ }
24
+ if (record.disposedAt && record.disposeReceiptPath && record.disposePlanId) {
25
+ return { cleanedAt: record.disposedAt, receiptPath: record.disposeReceiptPath, cleanupPlanId: record.disposePlanId };
26
+ }
27
+ return null;
28
+ }
20
29
  export function defaultLedgerPath(cwd = process.cwd()) {
21
30
  const repoRoot = findGitRoot(cwd);
22
31
  if (repoRoot)
@@ -85,16 +94,17 @@ export function listTrashedRecords(ledgerPath) {
85
94
  const records = readLedger(ledgerPath).filter((record) => record.status === "trashed");
86
95
  const current = now();
87
96
  return records.map((record) => {
88
- if (!record.id || !record.targetPath || !record.cleanedAt || !record.receiptPath || !record.cleanupPlanId) {
89
- throw new Error(`trashed record ${record.id ?? "<missing id>"} missing cleanup metadata`);
97
+ const provenance = trashProvenance(record);
98
+ if (!record.id || !record.targetPath || !provenance) {
99
+ throw new Error(`trashed record ${record.id ?? "<missing id>"} missing trash provenance`);
90
100
  }
91
101
  return {
92
102
  id: record.id,
93
103
  targetPath: record.targetPath,
94
- cleanedAt: record.cleanedAt,
95
- receiptPath: record.receiptPath,
96
- cleanupPlanId: record.cleanupPlanId,
97
- age: ageOf(current, record.cleanedAt)
104
+ cleanedAt: provenance.cleanedAt,
105
+ receiptPath: provenance.receiptPath,
106
+ cleanupPlanId: provenance.cleanupPlanId,
107
+ age: ageOf(current, provenance.cleanedAt)
98
108
  };
99
109
  });
100
110
  }
@@ -214,12 +224,8 @@ export function validateLedger(ledgerPath) {
214
224
  warnings.push(`${label}: recorded path is missing`);
215
225
  }
216
226
  if (record.status === "trashed") {
217
- if (!record.cleanupPlanId)
218
- errors.push(`${label}: trashed record missing cleanupPlanId`);
219
- if (!record.receiptPath)
220
- errors.push(`${label}: trashed record missing receiptPath`);
221
- if (!record.cleanedAt)
222
- errors.push(`${label}: trashed record missing cleanedAt`);
227
+ if (!trashProvenance(record))
228
+ errors.push(`${label}: trashed record missing trash provenance`);
223
229
  if (!record.targetPath) {
224
230
  errors.push(`${label}: trashed record missing targetPath`);
225
231
  }
@@ -341,15 +347,17 @@ export function executeTrashPurgePlan(ledgerPath, purgePlanId) {
341
347
  results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `record is ${record.status}` });
342
348
  continue;
343
349
  }
350
+ const provenance = trashProvenance(record);
344
351
  if (record.targetPath !== entry.targetPath ||
345
- record.cleanedAt !== entry.cleanedAt ||
346
- record.receiptPath !== entry.receiptPath ||
347
- record.cleanupPlanId !== entry.cleanupPlanId) {
352
+ !provenance ||
353
+ provenance.cleanedAt !== entry.cleanedAt ||
354
+ provenance.receiptPath !== entry.receiptPath ||
355
+ provenance.cleanupPlanId !== entry.cleanupPlanId) {
348
356
  results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "plan entry no longer matches ledger record" });
349
357
  continue;
350
358
  }
351
359
  const targetPath = resolve(entry.targetPath);
352
- const expectedPlanTrashRoot = resolve(trashRoot, record.cleanupPlanId);
360
+ const expectedPlanTrashRoot = resolve(trashRoot, entry.cleanupPlanId);
353
361
  if (!isPathWithin(trashRoot, targetPath)) {
354
362
  results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is outside Artshelf trash" });
355
363
  continue;
@@ -502,15 +510,16 @@ function buildTrashPurgePlan(ledgerPath, olderThan) {
502
510
  for (const record of records) {
503
511
  if (record.status !== "trashed")
504
512
  continue;
505
- if (!record.id || !record.targetPath || !record.cleanedAt || !record.receiptPath || !record.cleanupPlanId) {
513
+ const provenance = trashProvenance(record);
514
+ if (!record.id || !record.targetPath || !provenance) {
506
515
  skipped.push({
507
516
  id: record.id ?? "",
508
517
  targetPath: record.targetPath ?? "",
509
- reason: "trashed record missing cleanup metadata"
518
+ reason: "trashed record missing trash provenance"
510
519
  });
511
520
  continue;
512
521
  }
513
- const cleanedAt = new Date(record.cleanedAt);
522
+ const cleanedAt = new Date(provenance.cleanedAt);
514
523
  if (Number.isNaN(cleanedAt.getTime())) {
515
524
  skipped.push({
516
525
  id: record.id,
@@ -526,9 +535,9 @@ function buildTrashPurgePlan(ledgerPath, olderThan) {
526
535
  entries.push({
527
536
  id: record.id,
528
537
  targetPath: record.targetPath,
529
- cleanedAt: record.cleanedAt,
530
- receiptPath: record.receiptPath,
531
- cleanupPlanId: record.cleanupPlanId
538
+ cleanedAt: provenance.cleanedAt,
539
+ receiptPath: provenance.receiptPath,
540
+ cleanupPlanId: provenance.cleanupPlanId
532
541
  });
533
542
  }
534
543
  const purgePlanId = makePurgePlanId(generatedAt);
@@ -896,6 +905,8 @@ function buildRetention(input, createdAt) {
896
905
  function classifyDue(record, at) {
897
906
  if (!existsSync(record.path))
898
907
  return "missing-path";
908
+ if (record.disposeAction === "keep" && record.disposePlanId && record.disposeReceiptPath && record.disposedAt)
909
+ return "kept";
899
910
  if (record.retention.mode === "manual-review")
900
911
  return "manual-review";
901
912
  if (!record.retainUntil)
@@ -0,0 +1,108 @@
1
+ import { shellArg } from "../shared/shell-quote.js";
2
+ import { attentionGlyph } from "./attention.js";
3
+ // Buckets whose next-safe action is something other than "leave it alone" get the
4
+ // attention glyph so a scanned card reads as a decision, not just a status line.
5
+ const ATTENTION_RECOMMENDATIONS = new Set([
6
+ "trash-safe",
7
+ "resolve-only",
8
+ "blocked"
9
+ ]);
10
+ export function buildInspectAgentPacket(report, ledgerPath) {
11
+ return {
12
+ schemaVersion: 1,
13
+ command: "get",
14
+ mode: "inspect",
15
+ ledgerPath,
16
+ inspect: report,
17
+ safety: {
18
+ readOnly: true,
19
+ noFileMoves: true,
20
+ noLedgerMutation: true
21
+ },
22
+ nextAction: report.nextAction,
23
+ verification: `artshelf get ${shellArg(report.id)} --inspect --agent --ledger ${shellArg(ledgerPath)}`
24
+ };
25
+ }
26
+ export function printInspect(report, ledgerPath) {
27
+ const glyph = attentionGlyph(ATTENTION_RECOMMENDATIONS.has(report.recommendation));
28
+ const labels = report.labels.length > 0 ? report.labels.map(sanitizeHumanLine).join(", ") : "none";
29
+ const lines = [];
30
+ lines.push(`${glyph} ${sanitizeHumanLine(report.id)} [${sanitizeHumanLine(report.kind)}] — ${sanitizeHumanLine(report.recommendation)}`);
31
+ lines.push(`path: ${sanitizeHumanLine(report.path)}`);
32
+ // Trashed records point `path` at the now-empty original; existence and size
33
+ // describe the trash target, so name it explicitly when the two differ.
34
+ if (report.subjectPath !== report.path)
35
+ lines.push(`trash target: ${sanitizeHumanLine(report.subjectPath)}`);
36
+ lines.push(`status: ${sanitizeHumanLine(report.status)} · cleanup: ${sanitizeHumanLine(report.cleanup)} · owner: ${sanitizeHumanLine(report.owner)} · labels: ${labels}`);
37
+ lines.push(`existence: ${sanitizeHumanLine(formatExistence(report))} · age: ${sanitizeHumanLine(report.age)} · retention: ${sanitizeHumanLine(formatRetention(report.retention))} · due: ${sanitizeHumanLine(report.dueState ?? "n/a")}`);
38
+ lines.push(`reason: ${sanitizeHumanLine(report.reason)}`);
39
+ lines.push(`next: ${sanitizeHumanLine(report.nextAction)}`);
40
+ lines.push(`ledger: ${sanitizeHumanLine(ledgerPath)}`);
41
+ process.stdout.write(`${lines.join("\n")}\n`);
42
+ }
43
+ function formatExistence(report) {
44
+ if (report.existence === "missing")
45
+ return "missing";
46
+ if (report.nodeKind === "file" || report.nodeKind === "directory") {
47
+ const size = report.byteSize === null ? "size unavailable" : formatBytes(report.byteSize);
48
+ if (report.nodeKind === "directory" && report.byteSizeTruncated) {
49
+ return `present (directory, at least ${size}; scan capped)`;
50
+ }
51
+ return `present (${report.nodeKind}, ${size})`;
52
+ }
53
+ return "present (other)";
54
+ }
55
+ function formatRetention(retention) {
56
+ if (retention.mode === "ttl")
57
+ return `ttl ${retention.ttl}`;
58
+ if (retention.mode === "retain-until")
59
+ return `retain-until ${retention.retainUntil}`;
60
+ return "manual-review";
61
+ }
62
+ function formatBytes(bytes) {
63
+ if (bytes < 1024)
64
+ return `${bytes} B`;
65
+ if (bytes < 1024 * 1024)
66
+ return `${(bytes / 1024).toFixed(1)} KB`;
67
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
68
+ }
69
+ function sanitizeHumanLine(value) {
70
+ let out = "";
71
+ for (const char of value) {
72
+ const code = char.codePointAt(0);
73
+ if (code === undefined)
74
+ continue;
75
+ if (char === "\\") {
76
+ out += "\\\\";
77
+ }
78
+ else if (char === "\n") {
79
+ out += "\\n";
80
+ }
81
+ else if (char === "\r") {
82
+ out += "\\r";
83
+ }
84
+ else if (char === "\t") {
85
+ out += "\\t";
86
+ }
87
+ else if (code < 32 || (code >= 0x7f && code <= 0x9f) || isUnicodeFormatControl(code)) {
88
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
89
+ }
90
+ else {
91
+ out += char;
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ function isUnicodeFormatControl(code) {
97
+ if (code === 0x00ad || code === 0x061c)
98
+ return true;
99
+ if (code >= 0x200b && code <= 0x200f)
100
+ return true;
101
+ if (code >= 0x202a && code <= 0x202e)
102
+ return true;
103
+ if (code >= 0x2060 && code <= 0x206f)
104
+ return true;
105
+ if (code >= 0xfe00 && code <= 0xfe0f)
106
+ return true;
107
+ return code >= 0xfff9 && code <= 0xfffb;
108
+ }
@@ -1,6 +1,9 @@
1
1
  export const BOOLEAN_FLAGS = new Set(["all", "json", "agent", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
2
+ const COMMAND_BOOLEAN_FLAGS = new Map([["get", new Set(["inspect"])]]);
2
3
  export const VALUE_FLAGS = new Set([
3
4
  "cleanup",
5
+ "action",
6
+ "id",
4
7
  "kind",
5
8
  "label",
6
9
  "ledger",
@@ -16,6 +19,9 @@ export const VALUE_FLAGS = new Set([
16
19
  "status",
17
20
  "ttl"
18
21
  ]);
22
+ export function isBooleanFlag(name, command) {
23
+ return BOOLEAN_FLAGS.has(name) || (command !== undefined && (COMMAND_BOOLEAN_FLAGS.get(command)?.has(name) ?? false));
24
+ }
19
25
  export function requiredStringFlag(parsed, name) {
20
26
  const value = stringFlag(parsed, name);
21
27
  if (!value)
@@ -53,6 +53,7 @@ const COMMAND_GROUPS = [
53
53
  group: "Clean",
54
54
  commands: [
55
55
  { name: "cleanup", summary: "Plan and execute approved cleanups" },
56
+ { name: "dispose", summary: "Plan and execute reviewed artifact decisions" },
56
57
  { name: "reconcile", summary: "Reconcile drifted ledger paths via approval-gated plans" },
57
58
  { name: "trash", summary: "Inspect and purge Artshelf trash" },
58
59
  { name: "resolve", summary: "Mark a record manually resolved" }
@@ -109,6 +110,29 @@ Dry-run writes and registers a plan only when executable cleanup entries exist;
109
110
  Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
110
111
  Execute writes and registers an Artshelf-owned receipt artifact.
111
112
  Global --all mode is dry-run only.
113
+ `;
114
+ }
115
+ if (command === "dispose") {
116
+ return `Usage:
117
+ artshelf dispose --id <id> --action trash-resolve --dry-run [--reason <text>] [--ledger <path>] [--json|--agent]
118
+ artshelf dispose --id <id> --action resolve-only --dry-run --reason <text> [--ledger <path>] [--json|--agent]
119
+ artshelf dispose --id <id> --action snooze --dry-run (--ttl <ttl>|--retain-until <date>) [--reason <text>] [--ledger <path>] [--json|--agent]
120
+ artshelf dispose --id <id> --action keep --dry-run [--reason <text>] [--ledger <path>] [--json|--agent]
121
+ artshelf dispose --execute --plan-id <id> [--ledger <path>] [--json]
122
+
123
+ Dispose is for records after human review, usually from \`get --inspect\`.
124
+ Dry-run classifies exactly one record/action, writes a reviewed dispose plan when
125
+ actionable, and prints the exact approval target:
126
+ approve artshelf dispose ledger <ledger-path> plan <plan-id>
127
+
128
+ Actions:
129
+ trash-resolve Move the recorded path into plan-scoped Artshelf trash and resolve the row
130
+ resolve-only Resolve the ledger row only; requires --reason
131
+ snooze Extend retention; requires --ttl or --retain-until
132
+ keep Stamp that the record was reviewed and kept
133
+
134
+ Execute applies exactly one reviewed plan id against one ledger. There is no
135
+ dispose --all, no fresh-plan-then-execute, no daemon, and no physical delete.
112
136
  `;
113
137
  }
114
138
  if (command === "reconcile") {
@@ -170,8 +194,24 @@ Find is read-only. Use it before put when an integration needs idempotent artifa
170
194
  return `Usage:
171
195
  artshelf get <id> [--ledger <path>] [--json]
172
196
  artshelf get <id> --all [--registry <path>] [--json]
197
+ artshelf get <id> --inspect [--ledger <path>] [--json|--agent]
198
+ artshelf get <id> --inspect --all [--registry <path>] [--json|--agent]
173
199
 
174
200
  Get is read-only and returns one ledger record by Artshelf id.
201
+
202
+ --inspect adds a read-only review decision card for one record: existence,
203
+ size, age, retention/due state, a recommendation bucket (keep, snooze,
204
+ trash-safe, resolve-only, blocked), and the exact next-safe action. It never
205
+ moves files or touches the ledger. It does not read or preview arbitrary
206
+ file contents; agents can inspect contents separately when appropriate.
207
+ With --all, the registry is only used to find the id; the card reports the
208
+ concrete ledger that owns the record.
209
+
210
+ Render modes:
211
+ (default) Human record line, or a decision card with --inspect.
212
+ --json Full read-only report (record, or { inspect } with --inspect).
213
+ --agent Compact single-line JSON decision packet (requires --inspect);
214
+ takes precedence over --json.
175
215
  `;
176
216
  }
177
217
  if (command === "resolve") {
@@ -0,0 +1,6 @@
1
+ const SHELL_SAFE = /^[A-Za-z0-9_./:@%+=,-]+$/;
2
+ export function shellArg(value) {
3
+ if (value.length > 0 && SHELL_SAFE.test(value))
4
+ return value;
5
+ return `'${value.replace(/'/g, "'\\''")}'`;
6
+ }
@@ -77,6 +77,29 @@ artshelf cleanup --execute --plan-id &lt;id&gt;</code></pre>
77
77
  </div>
78
78
  </section>
79
79
 
80
+ <section>
81
+ <h2>Dispose one reviewed record</h2>
82
+ <p>
83
+ <code>dispose</code> is the per-record reviewed path that follows
84
+ <code>get --inspect</code>: one record id, one action, one reviewed plan id.
85
+ It never runs in batch and never deletes physically.
86
+ </p>
87
+ <pre><code><span class="c"># classify one inspected record into a reviewed plan</span>
88
+ artshelf dispose --id &lt;id&gt; --action trash-resolve|resolve-only|snooze|keep --dry-run [--reason &lt;text&gt;] [--ttl &lt;ttl&gt;|--retain-until &lt;date&gt;] --ledger &lt;ledger-path&gt; --json
89
+
90
+ <span class="c"># approve: approve artshelf dispose ledger &lt;ledger-path&gt; plan &lt;plan-id&gt;</span>
91
+ <span class="c"># only after a human approves this exact plan id</span>
92
+ artshelf dispose --execute --plan-id &lt;plan-id&gt; --ledger &lt;ledger-path&gt;</code></pre>
93
+ <p>
94
+ Actions: <code>trash-resolve</code> moves the subject into plan-scoped Artshelf
95
+ trash and resolves the row; <code>resolve-only</code> resolves the ledger row only
96
+ (requires <code>--reason</code>); <code>snooze</code> extends retention (requires
97
+ <code>--ttl</code> or <code>--retain-until</code>); <code>keep</code> stamps the
98
+ record reviewed-and-kept. Execute re-snapshots the subject and refuses drift or a
99
+ target conflict; reruns are idempotent and a receipt is left for audit.
100
+ </p>
101
+ </section>
102
+
80
103
  <section>
81
104
  <h2>Resolve confirmed records</h2>
82
105
  <p>Resolve only updates the ledger; it does not move or delete files.</p>
@@ -88,13 +111,15 @@ artshelf resolve &lt;id&gt; --status resolved --reason &lt;text&gt;</code></pre>
88
111
 
89
112
  <section>
90
113
  <h2>Verify quiet</h2>
91
- <p>After cleanup execute or resolve, verify with <code>artshelf review --all --json</code>.</p>
114
+ <p>After cleanup execute, dispose execute, or resolve, verify with <code>artshelf review --all --json</code>.</p>
92
115
  <p>
93
- Execution writes a started receipt before the first move, completes it after
116
+ Cleanup execution writes a started receipt before the first move, completes it after
94
117
  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.
118
+ <code>review-required</code>, or <code>cleanup-refused</code>. Dispose execution writes
119
+ its own receipt, stamps dispose audit fields, and either resolves, snoozes,
120
+ or keeps the reviewed row. Rerunning the same plan id resumes or idempotently
121
+ replays durable receipt/trash evidence. Generated plans and receipts are
122
+ recorded as <code>owner=artshelf</code> artifacts.
98
123
  </p>
99
124
  </section>
100
125
  </article>
@@ -93,7 +93,7 @@ artshelf find --all --owner &lt;agent-or-runtime&gt; --json</code></pre>
93
93
  <li><strong>Read-only.</strong> Validate, status, due, review, doctor, and trash list are fine.</li>
94
94
  <li><strong>Quiet by default.</strong> Send nothing when the review is clean unless a summary was requested.</li>
95
95
  <li><strong>No network mode.</strong> Set <code>ARTSHELF_NO_UPDATE_CHECK=1</code> when jobs must avoid npm update checks and cache writes.</li>
96
- <li><strong>Never schedule execution.</strong> Scheduled jobs must not run cleanup execute or trash purge execute.</li>
96
+ <li><strong>Never schedule execution.</strong> Scheduled jobs must not run cleanup execute, dispose execute, or trash purge execute.</li>
97
97
  </ul>
98
98
  <pre><code><span class="c"># ledger health, current ledger or all registered ledgers</span>
99
99
  artshelf validate --json
@@ -159,6 +159,7 @@ artshelf trash purge --older-than 7d --dry-run --ledger &lt;ledger-path&gt; --js
159
159
  </div>
160
160
  <pre><code><span class="c"># mutating commands: approval only, never from a schedule</span>
161
161
  artshelf cleanup --execute --plan-id &lt;id&gt;
162
+ artshelf dispose --execute --plan-id &lt;id&gt;
162
163
  artshelf ledgers prune --execute --plan-id &lt;id&gt;
163
164
  artshelf trash purge --execute --plan-id &lt;id&gt;</code></pre>
164
165
  </section>
@@ -65,6 +65,42 @@
65
65
  </p>
66
66
  </section>
67
67
 
68
+ <section>
69
+ <h2>Inspect a single record</h2>
70
+ <p>
71
+ When a review flags one record — most often a stale
72
+ <code>cleanup=review</code> backup that <code>ledgers prune</code> and
73
+ <code>review --all</code> surface — drill into it with
74
+ <code>get &lt;id&gt; --inspect</code> instead of running <code>get</code>,
75
+ <code>ls</code>, and <code>du</code> by hand. It is read-only: it never
76
+ moves files or mutates the ledger.
77
+ </p>
78
+ <pre><code><span class="c"># review one record as a decision card</span>
79
+ artshelf get &lt;id&gt; --inspect --ledger &lt;ledger-path&gt;
80
+
81
+ <span class="c"># compact, deterministic packet for an acting agent</span>
82
+ artshelf get &lt;id&gt; --inspect --agent --ledger &lt;ledger-path&gt;</code></pre>
83
+ <p>
84
+ Each card reports existence, size, age, retention/due state, cleanup
85
+ mode, and reason, then ends with a recommendation bucket and the exact
86
+ next-safe action. It does not read or preview arbitrary file contents:
87
+ <code>keep</code>, <code>snooze</code>, <code>trash-safe</code>,
88
+ <code>resolve-only</code>, or <code>blocked</code>. These map onto the
89
+ daily-review classifications — a missing-path record reads as
90
+ <code>resolve-only</code> and points at a reviewed
91
+ <code>dispose</code> plan, while a due disposable record reads as
92
+ <code>trash-safe</code> and points at a reviewed <code>dispose</code>
93
+ plan.
94
+ </p>
95
+ <pre><code>✓ &lt;id&gt; [backup] — keep
96
+ path: &lt;backup-path&gt;
97
+ status: active · cleanup: review · owner: agent · labels: registry-prune
98
+ existence: present (directory, 49 B) · age: 14d · retention: manual-review · due: manual-review
99
+ reason: rollback backup before registry prune
100
+ next: Held for manual review — run `artshelf dispose --id &lt;id&gt; --action keep --dry-run --reason '&lt;why&gt;' --ledger &lt;ledger-path&gt;` to keep it quiet through a reviewed decision, or choose resolve-only/snooze deliberately.
101
+ ledger: &lt;ledger-path&gt;</code></pre>
102
+ </section>
103
+
68
104
  <section>
69
105
  <h2>Review plan report schema</h2>
70
106
  <p>For richer host cards, attachments, or audit packets, construct an <code>ArtshelfReviewReport</code> JSON packet first, then render a compact decision card.</p>
@@ -111,6 +147,7 @@ Dry-run only. No execute, resolve, or delete ran.</code></pre>
111
147
  asks for it. Always include the exact approval target in the message body as a fallback.
112
148
  </p>
113
149
  <pre><code>approve artshelf cleanup ledger &lt;ledger-path&gt; plan &lt;plan-id&gt;
150
+ approve artshelf dispose ledger &lt;ledger-path&gt; plan &lt;dispose-plan-id&gt;
114
151
  approve artshelf trash purge ledger &lt;ledger-path&gt; plan &lt;purge-plan-id&gt;
115
152
  approve artshelf resolve missing ledger &lt;ledger-path&gt; ids &lt;id...&gt;
116
153
  approve artshelf reconcile ledger &lt;ledger-path&gt; plan &lt;plan-id&gt;