artshelf 0.14.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Added read-only `artshelf get <id> --inspect` decision cards with human,
6
+ `--json`, and `--agent` render modes, including registry-wide `--all` lookup,
7
+ metadata-only existence/size reporting, recommendation buckets, and exact
8
+ next-safe actions without reading file contents or mutating ledgers.
5
9
  - Added approval-gated `artshelf ledgers prune` registry maintenance: dry-run
6
10
  writes or reuses a reviewed plan for missing registered ledger files, `--agent`
7
11
  emits the exact registry-prune approval target, execute binds to one registry
@@ -132,6 +136,15 @@
132
136
  - Moved `artshelf put` registry-warning output from stdout to stderr in human
133
137
  mode; `--json` output is unchanged (NGX-429).
134
138
 
139
+ ## [0.15.0](https://github.com/calvinnwq/artshelf/compare/v0.14.0...v0.15.0) (2026-06-20)
140
+
141
+
142
+ ### Features
143
+
144
+ * **inspect:** add read-only Artshelf review cards ([08478a8](https://github.com/calvinnwq/artshelf/commit/08478a871f624b048173a986511cc57cf2a2e609))
145
+ * **inspect:** add read-only inspect decision model ([334ea7d](https://github.com/calvinnwq/artshelf/commit/334ea7de99326ed0b0c3129228beaa0d33f378b9))
146
+ * **inspect:** wire get inspect output modes ([4a7270d](https://github.com/calvinnwq/artshelf/commit/4a7270df29f1895efe9032b2cfb7a2c1d47e971f))
147
+
135
148
  ## [0.14.0](https://github.com/calvinnwq/artshelf/compare/v0.13.1...v0.14.0) (2026-06-19)
136
149
 
137
150
 
package/README.md CHANGED
@@ -118,9 +118,9 @@ destructive deletion.
118
118
  registry mutations take a cross-process lock so overlapping commands never
119
119
  lose records or leave a half-written ledger.
120
120
  - **`--json` on every command**, so agents can act on structured output.
121
- - **`--agent` on `review`/`status`/`doctor` and `ledgers prune --dry-run`**, a
122
- compact, token-efficient decision packet for agents, while the default render
123
- stays human-scannable.
121
+ - **`--agent` on `review`/`status`/`doctor`, `ledgers prune --dry-run`, and
122
+ `get --inspect`**, a compact, token-efficient decision packet for agents,
123
+ while the default render stays human-scannable.
124
124
 
125
125
  ## Reference
126
126
 
@@ -137,6 +137,8 @@ artshelf list [--all] [--status active]
137
137
  artshelf find --path <path> --owner <agent-or-runtime> --label <task-or-run-id>
138
138
  artshelf find --all --owner <agent-or-runtime>
139
139
  artshelf get <id> [--all]
140
+ artshelf get <id> --inspect [--ledger <path>] [--json|--agent]
141
+ artshelf get <id> --inspect --all [--registry <path>] [--json|--agent]
140
142
  artshelf due [--all]
141
143
  artshelf validate [--all]
142
144
  artshelf review [--all]
@@ -157,9 +159,9 @@ Use `artshelf help` for a grouped command list, then `artshelf <command> --help`
157
159
  or `artshelf help <command>` for focused details. Nested commands such as
158
160
  `artshelf trash purge --help`, `artshelf ledgers add --help`, and
159
161
  `artshelf ledgers prune --help` show only that subcommand. All core commands
160
- support `--json`; `review`, `status`, `doctor`, and `ledgers prune --dry-run`
161
- also take `--agent` for a compact decision packet; `--ledger`, `--registry`, and
162
- `--all` are scope flags only on commands that list them.
162
+ support `--json`; `review`, `status`, `doctor`, `ledgers prune --dry-run`, and
163
+ `get --inspect` also take `--agent` for a compact decision packet; `--ledger`,
164
+ `--registry`, and `--all` are scope flags only on commands that list them.
163
165
  </details>
164
166
 
165
167
  <details>
package/SPEC.md CHANGED
@@ -199,10 +199,56 @@ Read-only lookup of a single ledger record by Artshelf id.
199
199
  artshelf get <id>
200
200
  artshelf get <id> --json
201
201
  artshelf get <id> --all --json
202
+ artshelf get <id> --inspect
203
+ artshelf get <id> --inspect --json
204
+ artshelf get <id> --inspect --agent
205
+ artshelf get <id> --inspect --all --registry <path> --agent
202
206
  ```
203
207
 
204
208
  `get` is for audit and handoff follow-up. Missing ids are an error. `--all`
205
- searches registered ledgers until the id is found.
209
+ searches registered ledgers until the id is found. With `--inspect --all`, the
210
+ registry is only used for lookup; the decision card reports the concrete ledger
211
+ that owns the matching record.
212
+
213
+ `--inspect` turns a record into a review decision card. It never moves files or
214
+ mutates the ledger; it only reports existence, node kind, size, age, retention/due and
215
+ manual-review state, cleanup mode, reason, and a recommendation bucket with the
216
+ exact next-safe action. It never reads or previews arbitrary file contents:
217
+
218
+ - `keep` — held for manual review, already resolved, already trashed, or due
219
+ with `cleanup=review`; it needs your judgment but nothing auto-runs.
220
+ - `snooze` — retention has not expired yet; re-inspect after it is due.
221
+ - `trash-safe` — due with `cleanup=trash`; safe to plan a reviewed cleanup.
222
+ - `resolve-only` — the recorded path is gone; resolve the record (ledger-only)
223
+ rather than cleaning a file.
224
+ - `blocked` — needs a human decision first: `cleanup=delete` (refused at
225
+ execute), a review-required flag, or a prior cleanup refusal.
226
+
227
+ File-content previews are intentionally outside Artshelf core; an acting agent or
228
+ host runtime may inspect file contents separately when appropriate.
229
+ `--inspect --json` returns `{ inspect: <report> }`; `--inspect --agent` returns a compact single-line
230
+ decision packet with a read-only safety block, the next-safe action, and a
231
+ reproducer command, and takes precedence over `--json`. Both shapes are
232
+ deterministic so portable agent skills can act without re-deriving anything.
233
+
234
+ Example pattern — the dogfooding case that motivated this surface was an old
235
+ rollback `backup` registered with `cleanup=review` (the kind of stale record
236
+ `ledgers prune` and `review --all` surface). Inspect it before deciding, using
237
+ the record id and ledger path rather than hardcoding either:
238
+
239
+ ```bash
240
+ artshelf get <id> --inspect --ledger <ledger-path>
241
+ ```
242
+
243
+ ```text
244
+ ✓ <id> [backup] — keep
245
+ path: <backup-path>
246
+ status: active · cleanup: review · owner: agent · labels: registry-prune
247
+ existence: present (directory, 49 B) · age: 14d · retention: manual-review · due: manual-review
248
+ reason: rollback backup before registry prune
249
+ next: Held for manual review — keep it, change its retention, or resolve it explicitly; no cleanup is scheduled.
250
+ ledger: <ledger-path>
251
+ ```
206
252
 
207
253
  ### `artshelf due`
208
254
 
@@ -279,12 +325,13 @@ registry-prune dry-run for missing registered ledgers, dry-run reconcile for
279
325
  missing-path or reconcile drift, or nothing to do). Review never writes a plan,
280
326
  so the next action always points at an explicit follow-up command.
281
327
 
282
- `review`, `status`, `doctor`, and `ledgers prune --dry-run` expose
283
- agent-oriented render modes. For review/status/doctor, the default human render
328
+ `review`, `status`, `doctor`, `ledgers prune --dry-run`, and `get --inspect`
329
+ expose agent-oriented render modes. For review/status/doctor, the default human render
284
330
  leads each ledger and summary line with a `✓`/`⚠` attention glyph. `--json` stays
285
331
  the full, backward-compatible public audit report; and `--agent` emits a compact,
286
332
  deterministic single-line JSON decision packet for agents, taking precedence over
287
- `--json` when both are passed. For `review`, the packet sorts records into
333
+ `--json` when both are passed. For `get --inspect`, `--agent` returns the
334
+ per-record decision packet and requires `--inspect`. For `review`, the packet sorts records into
288
335
  ready-for-approval, needs-review-first, and blocked groups. Because review is
289
336
  read-only and never mints a cleanup or registry-prune plan, the exact approval
290
337
  targets it emits are `resolve missing` and `reconcile`; the `reconcile` target
@@ -1024,9 +1071,9 @@ only dry-run and report plans for later human review.
1024
1071
  - Package includes the deterministic `ArtshelfReviewReport` schema, canonical
1025
1072
  example, and portable renderer script for agent-rendered review reports.
1026
1073
  - All core commands support `--json`.
1027
- - `review`, `status`, `doctor`, and `ledgers prune --dry-run` also support
1028
- `--agent`, a compact single-line JSON decision packet for agents that takes
1029
- precedence over `--json`.
1074
+ - `review`, `status`, `doctor`, `ledgers prune --dry-run`, and `get --inspect`
1075
+ also support `--agent`, a compact single-line JSON decision packet for agents
1076
+ that takes precedence over `--json`.
1030
1077
  - Tests cover record/list/find/get/status-filter/due/validate/resolve/registry,
1031
1078
  `artshelf doctor`, the `artshelf status` dashboard, `--all` review, stale-registry,
1032
1079
  dry-run, global-dry-run, execute-plan, cleanup plan-id validation, concurrent
package/dist/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { maybeNotifyAvailableUpdate, runCommand } from "./commands/index.js";
3
3
  import { VERSION } from "./config/package.js";
4
4
  import { formatCliError } from "./shared/errors.js";
5
- import { BOOLEAN_FLAGS, boolFlag, VALUE_FLAGS } from "./shared/flags.js";
5
+ import { boolFlag, isBooleanFlag, VALUE_FLAGS } from "./shared/flags.js";
6
6
  import { renderHelp, resolveHelpKey } from "./shared/help-text.js";
7
7
  async function main(argv) {
8
8
  try {
@@ -53,7 +53,7 @@ function parseArgs(argv) {
53
53
  }
54
54
  if (token.startsWith("--")) {
55
55
  const name = token.slice(2);
56
- if (BOOLEAN_FLAGS.has(name)) {
56
+ if (isBooleanFlag(name, command)) {
57
57
  flags.set(name, true);
58
58
  continue;
59
59
  }
@@ -1,20 +1,28 @@
1
+ import { buildInspectReport } from "../inspect.js";
1
2
  import { getRecord, readLedger } from "../ledger.js";
2
3
  import { normalizeRegistryPath } from "../registry.js";
3
- import { printJson } from "../renderers/json.js";
4
+ import { buildInspectAgentPacket, printInspect } from "../renderers/inspect.js";
5
+ import { printCompactJson, printJson } from "../renderers/json.js";
4
6
  import { boolFlag, stringFlag } from "../shared/flags.js";
5
7
  import { printRegisteredLedgerValidation, validateRegisteredLedgersOrThrow } from "./shared.js";
6
8
  export function handleGet(parsed, ledgerPath, json) {
7
9
  const id = parsed.positionals[0];
8
10
  if (!id)
9
11
  throw new Error("get requires <id>");
12
+ const inspect = boolFlag(parsed, "inspect");
13
+ const agent = boolFlag(parsed, "agent");
14
+ if (agent && !inspect)
15
+ throw new Error("--agent requires --inspect for get");
10
16
  if (boolFlag(parsed, "all")) {
11
17
  const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
12
18
  const validation = validateRegisteredLedgersOrThrow(registryPath);
13
19
  if (!validation.ok)
14
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
20
+ return printRegisteredLedgerValidation(registryPath, validation.results, json || agent);
15
21
  for (const { ledger } of validation.results) {
16
22
  const record = readLedger(ledger.path).find((entry) => entry.id === id);
17
23
  if (record) {
24
+ if (inspect)
25
+ return renderInspect(record, ledger.path, json, agent);
18
26
  if (json)
19
27
  return printJson({ ok: true, registryPath, ledger, record });
20
28
  process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledger.path}\nregistry: ${registryPath}\n`);
@@ -24,8 +32,22 @@ export function handleGet(parsed, ledgerPath, json) {
24
32
  throw new Error(`Artshelf record not found: ${id}`);
25
33
  }
26
34
  const record = getRecord(readLedger(ledgerPath), id);
35
+ if (inspect)
36
+ return renderInspect(record, ledgerPath, json, agent);
27
37
  if (json)
28
38
  return printJson({ ok: true, ledgerPath, record });
29
39
  process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledgerPath}\n`);
30
40
  return 0;
31
41
  }
42
+ // Read-only inspect surface (NGX-482): builds a deterministic decision report and
43
+ // renders it as a human card, full JSON, or a compact agent packet. --agent wins
44
+ // over --json, matching `review`. Never mutates the ledger or the filesystem.
45
+ function renderInspect(record, ledgerPath, json, agent) {
46
+ const report = buildInspectReport(record, { ledgerPath });
47
+ if (agent)
48
+ return printCompactJson(buildInspectAgentPacket(report, ledgerPath));
49
+ if (json)
50
+ return printJson({ ok: true, ledgerPath, inspect: report });
51
+ printInspect(report, ledgerPath);
52
+ return 0;
53
+ }
@@ -0,0 +1,204 @@
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.retention.mode === "manual-review")
135
+ return "manual-review";
136
+ if (!record.retainUntil)
137
+ return "due";
138
+ return new Date(record.retainUntil).getTime() <= at.getTime() ? "due" : "kept";
139
+ }
140
+ function recommend(record, existence, dueState, ledgerPath) {
141
+ const idArg = shellArg(record.id);
142
+ const ledgerArg = shellArg(ledgerPath);
143
+ if (record.status === "resolved") {
144
+ return { recommendation: "keep", nextAction: "Already resolved — no action needed." };
145
+ }
146
+ if (record.status === "trashed") {
147
+ if (existence === "missing") {
148
+ return {
149
+ recommendation: "resolve-only",
150
+ nextAction: `Trashed target is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
151
+ };
152
+ }
153
+ return {
154
+ recommendation: "keep",
155
+ nextAction: "Already trashed — permanent removal is the separate approval-gated `artshelf trash purge` flow."
156
+ };
157
+ }
158
+ if (record.status === "review-required") {
159
+ return {
160
+ recommendation: "blocked",
161
+ nextAction: "A cleanup run flagged this for manual review — inspect the artifact, then resolve or re-plan deliberately."
162
+ };
163
+ }
164
+ if (record.status === "cleanup-refused") {
165
+ return {
166
+ recommendation: "blocked",
167
+ nextAction: "A prior cleanup refused this record — handle it manually; Artshelf will not retry automatically."
168
+ };
169
+ }
170
+ if (existence === "missing" || dueState === "missing-path") {
171
+ return {
172
+ recommendation: "resolve-only",
173
+ nextAction: `Path is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
174
+ };
175
+ }
176
+ if (dueState === "kept") {
177
+ return {
178
+ recommendation: "snooze",
179
+ nextAction: `Retention holds until ${record.retainUntil ?? "the configured date"} — re-inspect after it expires; nothing is due now.`
180
+ };
181
+ }
182
+ if (dueState === "manual-review") {
183
+ return {
184
+ recommendation: "keep",
185
+ nextAction: "Held for manual review — keep it, change its retention, or resolve it explicitly; no cleanup is scheduled."
186
+ };
187
+ }
188
+ if (record.cleanup === "trash") {
189
+ return {
190
+ recommendation: "trash-safe",
191
+ nextAction: `Due and disposable — run \`artshelf cleanup --dry-run --ledger ${ledgerArg}\`, then approve the reviewed plan id.`
192
+ };
193
+ }
194
+ if (record.cleanup === "delete") {
195
+ return {
196
+ recommendation: "blocked",
197
+ nextAction: "Due with cleanup=delete, which Artshelf refuses — switch it to cleanup=trash and plan a cleanup, or resolve it manually."
198
+ };
199
+ }
200
+ return {
201
+ recommendation: "keep",
202
+ nextAction: "Due and held for review — keep it, change its retention, resolve it, or switch cleanup to trash and plan a cleanup."
203
+ };
204
+ }
@@ -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,4 +1,5 @@
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",
4
5
  "kind",
@@ -16,6 +17,9 @@ export const VALUE_FLAGS = new Set([
16
17
  "status",
17
18
  "ttl"
18
19
  ]);
20
+ export function isBooleanFlag(name, command) {
21
+ return BOOLEAN_FLAGS.has(name) || (command !== undefined && (COMMAND_BOOLEAN_FLAGS.get(command)?.has(name) ?? false));
22
+ }
19
23
  export function requiredStringFlag(parsed, name) {
20
24
  const value = stringFlag(parsed, name);
21
25
  if (!value)
@@ -170,8 +170,24 @@ Find is read-only. Use it before put when an integration needs idempotent artifa
170
170
  return `Usage:
171
171
  artshelf get <id> [--ledger <path>] [--json]
172
172
  artshelf get <id> --all [--registry <path>] [--json]
173
+ artshelf get <id> --inspect [--ledger <path>] [--json|--agent]
174
+ artshelf get <id> --inspect --all [--registry <path>] [--json|--agent]
173
175
 
174
176
  Get is read-only and returns one ledger record by Artshelf id.
177
+
178
+ --inspect adds a read-only review decision card for one record: existence,
179
+ size, age, retention/due state, a recommendation bucket (keep, snooze,
180
+ trash-safe, resolve-only, blocked), and the exact next-safe action. It never
181
+ moves files or touches the ledger. It does not read or preview arbitrary
182
+ file contents; agents can inspect contents separately when appropriate.
183
+ With --all, the registry is only used to find the id; the card reports the
184
+ concrete ledger that owns the record.
185
+
186
+ Render modes:
187
+ (default) Human record line, or a decision card with --inspect.
188
+ --json Full read-only report (record, or { inspect } with --inspect).
189
+ --agent Compact single-line JSON decision packet (requires --inspect);
190
+ takes precedence over --json.
175
191
  `;
176
192
  }
177
193
  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
+ }
@@ -65,6 +65,41 @@
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 <code>artshelf resolve</code>,
91
+ while a due disposable record reads as <code>trash-safe</code> and points
92
+ at a reviewed cleanup plan.
93
+ </p>
94
+ <pre><code>✓ &lt;id&gt; [backup] — keep
95
+ path: &lt;backup-path&gt;
96
+ status: active · cleanup: review · owner: agent · labels: registry-prune
97
+ existence: present (directory, 49 B) · age: 14d · retention: manual-review · due: manual-review
98
+ reason: rollback backup before registry prune
99
+ next: Held for manual review — keep it, change its retention, or resolve it explicitly; no cleanup is scheduled.
100
+ ledger: &lt;ledger-path&gt;</code></pre>
101
+ </section>
102
+
68
103
  <section>
69
104
  <h2>Review plan report schema</h2>
70
105
  <p>For richer host cards, attachments, or audit packets, construct an <code>ArtshelfReviewReport</code> JSON packet first, then render a compact decision card.</p>
@@ -90,13 +90,15 @@
90
90
  <section>
91
91
  <h2>Render modes</h2>
92
92
  <p>
93
- <code>review</code>, <code>status</code>, <code>doctor</code>, and <code>ledgers prune --dry-run</code> share agent-oriented render modes
93
+ <code>review</code>, <code>status</code>, <code>doctor</code>, <code>ledgers prune --dry-run</code>, and per-record
94
+ <code>get --inspect</code> share agent-oriented render modes
94
95
  so the same data fits both people and agents. Reach for <code>--agent</code> when an agent
95
96
  decides and acts; reach for <code>--json</code> for full record, plan, or health detail.
97
+ On <code>get</code>, <code>--agent</code> requires <code>--inspect</code>.
96
98
  </p>
97
99
  <dl class="def-rows">
98
100
  <div><dt>default</dt><dd>human render: scannable grouped counts, attention states, and a short next action for a person at the terminal</dd></div>
99
- <div><dt>--agent</dt><dd>a deterministic, token-efficient decision packet (single-line compact JSON) with health, counts, classifications, blockers, approval targets where applicable, and a verification command</dd></div>
101
+ <div><dt>--agent</dt><dd>a deterministic, token-efficient decision packet (single-line compact JSON) with health, counts, classifications, blockers, record recommendations, approval targets where applicable, and a verification command</dd></div>
100
102
  <div><dt>--json</dt><dd>the backward-compatible public audit contract: complete machine-readable JSON for debugging and integrations</dd></div>
101
103
  </dl>
102
104
  </section>
@@ -53,20 +53,23 @@ The browsable docs split the workflow into focused child pages:
53
53
 
54
54
  ## Render modes
55
55
 
56
- `review`, `status`, `doctor`, and `ledgers prune --dry-run` share agent-oriented render modes so the same data fits
57
- both people and agents:
56
+ `review`, `status`, `doctor`, `ledgers prune --dry-run`, and per-record
57
+ `get --inspect` share agent-oriented render modes so the same data fits both
58
+ people and agents:
58
59
 
59
60
  - **default**: a human render — scannable grouped counts, attention states, and a
60
61
  short next action for a person at the terminal.
61
62
  - **`--agent`**: a deterministic, token-efficient decision packet (single-line
62
- compact JSON) with health, counts, classifications, blockers, approval targets where applicable, and a
63
- verification command. Use it when an agent acts on the result.
63
+ compact JSON) with health, counts, classifications, blockers, record
64
+ recommendations, approval targets where applicable, and a verification
65
+ command. Use it when an agent acts on the result.
64
66
  - **`--json`**: the backward-compatible public audit contract — complete
65
67
  machine-readable JSON for debugging and integrations.
66
68
 
67
69
  Reach for `--agent` when an agent needs to decide and act cheaply; reach for
68
70
  `--json` when you want the full record, plan, or health detail for audit or
69
- debugging. `--agent` takes precedence if both flags are passed.
71
+ debugging. `--agent` takes precedence if both flags are passed; on `get`, it
72
+ requires `--inspect`.
70
73
 
71
74
  ## Portable Skill
72
75
 
@@ -117,12 +117,22 @@ artshelf list [--all] [--status active|review-required|trashed|cleanup-refused|r
117
117
  artshelf find --path &lt;path&gt; [--owner &lt;o&gt;] [--label &lt;l&gt;] [--json]
118
118
 
119
119
  <span class="c"># fetch one record by id</span>
120
- artshelf get &lt;id&gt; [--all] [--json]</code></pre>
120
+ artshelf get &lt;id&gt; [--all] [--json]
121
+
122
+ <span class="c"># review one record as a decision card (read-only, no mutation)</span>
123
+ artshelf get &lt;id&gt; --inspect [--ledger &lt;path&gt;] [--json|--agent]
124
+ artshelf get &lt;id&gt; --inspect --all [--registry &lt;path&gt;] [--json|--agent]</code></pre>
121
125
  <p>
122
126
  <code>list</code> shows ledger entries and current status. <code>find</code> looks up
123
127
  existing records by path, owner, labels, and status. Use it before <code>put</code>
124
128
  for idempotent registration; it requires at least one selector and never mutates records.
125
- <code>get</code> is the audit lookup for one record by Artshelf id.
129
+ <code>get</code> is the audit lookup for one record by Artshelf id;
130
+ <code>--inspect</code> renders it as a review decision card — existence, size, age,
131
+ retention/due state, and a recommendation bucket (<code>keep</code>, <code>snooze</code>, <code>trash-safe</code>,
132
+ <code>resolve-only</code>, or <code>blocked</code>) with the exact next-safe action,
133
+ without moving files, touching the ledger, or previewing arbitrary file contents.
134
+ Combine <code>--inspect --all</code> to find the id through the registry while the card
135
+ reports the concrete ledger that owns the record.
126
136
  </p>
127
137
  </section>
128
138
 
@@ -276,9 +286,9 @@ artshelf reconcile --execute --plan-id &lt;id&gt; --ledger &lt;path&gt; [--json]
276
286
  output stays clean.
277
287
  </p>
278
288
  <p>
279
- <code>review</code>, <code>status</code>, <code>doctor</code>, and <code>ledgers prune --dry-run</code> add <code>--agent</code>:
289
+ <code>review</code>, <code>status</code>, <code>doctor</code>, <code>ledgers prune --dry-run</code>, and <code>get --inspect</code> add <code>--agent</code>:
280
290
  a deterministic, token-efficient decision packet emitted as a single line of compact JSON.
281
- It names the relevant health or prune counts, classifications, blockers, approval targets where applicable, and the command
291
+ It names the relevant health, prune counts, or record recommendation, classifications, blockers, approval targets where applicable, and the command
282
292
  to re-run for verification, so an agent can act without parsing decorative output.
283
293
  </p>
284
294
  <p>
@@ -289,8 +299,8 @@ artshelf reconcile --execute --plan-id &lt;id&gt; --ledger &lt;path&gt; [--json]
289
299
  </p>
290
300
  <table class="opts">
291
301
  <tr><th>option</th><th>meaning</th></tr>
292
- <tr><td>(default)</td><td>human render: grouped counts, ✓/⚠ attention glyphs, and a short next action</td></tr>
293
- <tr><td>--agent</td><td>token-efficient decision packet for agents on <code>review</code>, <code>status</code>, <code>doctor</code>, and <code>ledgers prune --dry-run</code></td></tr>
302
+ <tr><td>(default)</td><td>human render: grouped counts or a record card, ✓/⚠ attention glyphs, and a short next action</td></tr>
303
+ <tr><td>--agent</td><td>token-efficient decision packet for agents on <code>review</code>, <code>status</code>, <code>doctor</code>, <code>ledgers prune --dry-run</code>, and <code>get --inspect</code></td></tr>
294
304
  <tr><td>--json</td><td>full machine-readable audit JSON on commands that return data</td></tr>
295
305
  </table>
296
306
  </section>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artshelf",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Tiny CLI for accountable temporary artifact retention.",
5
5
  "type": "module",
6
6
  "author": "Calvin",
@@ -96,9 +96,9 @@ artshelf trash list --all --json
96
96
 
97
97
  `artshelf ledgers list --json` reports per-ledger validation status. `--plain`
98
98
  skips validation. `--all` is for discovery and review, not mutation permission.
99
- Use `--agent` on `review`, `status`, `doctor`, and `ledgers prune --dry-run`
100
- for compact decisions; use `--json` for full audit/API payloads, custom
101
- rendering, or debugging.
99
+ Use `--agent` on `review`, `status`, `doctor`, `ledgers prune --dry-run`, and
100
+ `get --inspect` for compact decisions; use `--json` for full audit/API payloads,
101
+ custom rendering, or debugging. On `get`, `--agent` requires `--inspect`.
102
102
 
103
103
  Register existing project ledgers explicitly:
104
104
 
@@ -150,7 +150,10 @@ count dump.
150
150
  3. If missing-path warnings exist inside valid ledgers, run `artshelf validate --all --json` then `artshelf reconcile --dry-run --all --json --registry <registry-path>` for renames, moves, deletes, topology after handoff/finalization, and `.shelf`/`.artshelf` migration fallout.
151
151
  4. If cleanup attention exists, run `artshelf cleanup --dry-run --all --json`.
152
152
  5. Classify candidates as `trash-safe`, `needs-human-review`,
153
- `resolve-candidate`, or `registry-problem`.
153
+ `resolve-candidate`, or `registry-problem`. For one flagged record (e.g. a
154
+ stale `cleanup=review` backup), read-only `artshelf get <id> --inspect --agent`
155
+ returns a per-record decision (`keep`, `snooze`, `trash-safe`, `resolve-only`,
156
+ `blocked`) and the exact next-safe action without mutating anything.
154
157
  6. Use the built-in `--agent` packet when the CLI output is enough to decide,
155
158
  because it is deterministic and token-efficient. Use
156
159
  `ArtshelfReviewReport` from `schemas/artshelf-review-report.schema.json` and `examples/artshelf-review-report.json` when you need a host-specific card, attachment, or richer audit record.