artshelf 0.8.0 → 0.10.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/dist/src/cli.js CHANGED
@@ -9,7 +9,7 @@ const VERSION = readPackageVersion();
9
9
  const PACKAGE_NAME = "artshelf";
10
10
  const NPM_REGISTRY_URL = process.env.ARTSHELF_NPM_REGISTRY_URL ?? `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
11
11
  const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
12
- const BOOLEAN_FLAGS = new Set(["all", "json", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
12
+ const BOOLEAN_FLAGS = new Set(["all", "json", "agent", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
13
13
  const VALUE_FLAGS = new Set([
14
14
  "cleanup",
15
15
  "kind",
@@ -45,7 +45,7 @@ async function main(argv) {
45
45
  return maybeNotifyUpdateAndReturn(0, parsed);
46
46
  }
47
47
  if (parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || boolFlag(parsed, "help")) {
48
- printHelp(parsed.command === "help" ? parsed.positionals[0] : parsed.command);
48
+ printHelp(resolveHelpKey(parsed));
49
49
  return maybeNotifyUpdateAndReturn(0, parsed);
50
50
  }
51
51
  switch (parsed.command) {
@@ -147,6 +147,10 @@ function handlePut(parsed, ledgerPath, json) {
147
147
  function handleLedgers(parsed, json) {
148
148
  const action = parsed.positionals[0] ?? "list";
149
149
  const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
150
+ if (action === "help") {
151
+ printHelp("ledgers");
152
+ return 0;
153
+ }
150
154
  if (action === "add") {
151
155
  const ledgerPath = normalizeLedgerPath(requiredStringFlag(parsed, "ledger"));
152
156
  if (!existsSync(ledgerPath))
@@ -531,12 +535,17 @@ function handleTrashPurge(parsed, ledgerPath, json) {
531
535
  return 0;
532
536
  }
533
537
  function handleReview(parsed, ledgerPath, json) {
538
+ const agent = boolFlag(parsed, "agent");
534
539
  if (boolFlag(parsed, "all")) {
535
540
  const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
536
541
  const results = registeredLedgersOrThrow(registryPath).map((ledger) => reviewLedger(ledger));
537
542
  const ok = results.every((entry) => entry.validate.ok);
538
543
  const summary = summarizeReview(results);
539
- const nextAction = reviewNextAction(summary);
544
+ if (agent) {
545
+ printCompactJson(buildReviewAgentPacketAll(results, summary, registryPath));
546
+ return ok ? 0 : 1;
547
+ }
548
+ const nextAction = reviewNextAction(summary, "all");
540
549
  if (json) {
541
550
  printJson({ ok, registryPath, summary, nextAction, ledgers: results });
542
551
  return ok ? 0 : 1;
@@ -545,6 +554,10 @@ function handleReview(parsed, ledgerPath, json) {
545
554
  return ok ? 0 : 1;
546
555
  }
547
556
  const result = reviewLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
557
+ if (agent) {
558
+ printCompactJson(buildReviewAgentPacketSingle(result, ledgerPath));
559
+ return result.validate.ok ? 0 : 1;
560
+ }
548
561
  if (json) {
549
562
  printJson({ ok: result.validate.ok, ledger: result });
550
563
  return result.validate.ok ? 0 : 1;
@@ -554,6 +567,10 @@ function handleReview(parsed, ledgerPath, json) {
554
567
  }
555
568
  function handleDoctor(parsed, ledgerPath, json) {
556
569
  const report = buildDoctorReport(ledgerPath, normalizeRegistryPath(stringFlag(parsed, "registry")));
570
+ if (boolFlag(parsed, "agent")) {
571
+ printCompactJson(buildDoctorAgentPacket(report));
572
+ return report.ok ? 0 : 1;
573
+ }
557
574
  if (json) {
558
575
  printJson(report);
559
576
  return report.ok ? 0 : 1;
@@ -620,16 +637,72 @@ function buildDoctorReport(ledgerPath, registryPath) {
620
637
  errors
621
638
  };
622
639
  }
640
+ // Actionable categories only — ok ledgers are healthy states, never attention.
641
+ // Order is fixed so the packet is byte-for-byte deterministic. Warnings surface
642
+ // even when health is ok (they never fail the machine), mirroring status attention.
643
+ const DOCTOR_ATTENTION_CATEGORIES = ["stale", "invalid", "warnings"];
644
+ function doctorAttention(summary) {
645
+ return DOCTOR_ATTENTION_CATEGORIES.filter((key) => summary[key] > 0);
646
+ }
647
+ function doctorNextAction(blockers, summary) {
648
+ if (blockers.length > 0) {
649
+ return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
650
+ }
651
+ if (summary.warnings > 0) {
652
+ return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf validate --all\` to inspect; nothing is auto-executed`;
653
+ }
654
+ return "artshelf is healthy on this machine — cleanup safety enforced; no action needed";
655
+ }
656
+ function buildDoctorAgentPacket(report) {
657
+ const blockers = [];
658
+ if (report.registryError)
659
+ blockers.push(`registry unreadable: ${report.registryError}`);
660
+ for (const ledger of report.ledgers) {
661
+ if (ledger.status !== "ok") {
662
+ blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
663
+ }
664
+ }
665
+ return {
666
+ schemaVersion: 1,
667
+ command: "doctor",
668
+ health: report.ok ? "ok" : "attention",
669
+ version: report.version,
670
+ node: report.node,
671
+ ledgerPath: report.ledgerPath,
672
+ registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
673
+ ledgers: {
674
+ total: report.summary.ledgers,
675
+ ok: report.summary.ok,
676
+ stale: report.summary.stale,
677
+ invalid: report.summary.invalid,
678
+ warnings: report.summary.warnings
679
+ },
680
+ attention: doctorAttention(report.summary),
681
+ blockers,
682
+ cleanupSafety: report.cleanupSafety,
683
+ nextAction: doctorNextAction(blockers, report.summary),
684
+ verification: `artshelf doctor --agent --registry ${report.registryPath}`
685
+ };
686
+ }
687
+ // Human render (NGX-396): a scannable left-column glyph so attention state is
688
+ // obvious at a glance — ✓ clear, ⚠ needs attention. Plain Unicode (no ANSI
689
+ // color) keeps redirected/piped human output clean, and the `--agent`/`--json`
690
+ // renders never carry glyphs (those stay machine contracts).
691
+ const HUMAN_OK_GLYPH = "✓";
692
+ const HUMAN_ATTENTION_GLYPH = "⚠";
693
+ function attentionGlyph(needsAttention) {
694
+ return needsAttention ? HUMAN_ATTENTION_GLYPH : HUMAN_OK_GLYPH;
695
+ }
623
696
  function printDoctor(report) {
624
697
  process.stdout.write(`artshelf ${report.version} (node ${report.node})\n`);
625
- process.stdout.write(`health: ${report.ok ? "ok" : "needs attention"}\n`);
698
+ process.stdout.write(`${attentionGlyph(!report.ok)} health: ${report.ok ? "ok" : "needs attention"}\n`);
626
699
  process.stdout.write(`ledger: ${report.ledgerPath}${report.ledgerExists ? "" : " (absent)"}\n`);
627
700
  process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"}\n`);
628
701
  if (report.registryError)
629
702
  process.stdout.write(`registry error: ${report.registryError}\n`);
630
703
  process.stdout.write(`registered ledgers: ${report.summary.ledgers} (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
631
704
  for (const ledger of report.ledgers) {
632
- process.stdout.write(` ${ledger.status} ${ledger.name} ${ledger.path}\n`);
705
+ process.stdout.write(` ${attentionGlyph(ledger.status !== "ok")} ${ledger.status} ${ledger.name} ${ledger.path}\n`);
633
706
  for (const message of ledger.errors)
634
707
  process.stdout.write(` error: ${message}\n`);
635
708
  }
@@ -640,8 +713,13 @@ function printDoctor(report) {
640
713
  }
641
714
  }
642
715
  function handleStatus(parsed, ledgerPath, json) {
716
+ const agent = boolFlag(parsed, "agent");
643
717
  if (boolFlag(parsed, "all")) {
644
718
  const report = buildStatusReport(normalizeRegistryPath(stringFlag(parsed, "registry")));
719
+ if (agent) {
720
+ printCompactJson(buildStatusAgentPacketAll(report));
721
+ return report.ok ? 0 : 1;
722
+ }
645
723
  if (json) {
646
724
  printJson(report);
647
725
  return report.ok ? 0 : 1;
@@ -650,6 +728,10 @@ function handleStatus(parsed, ledgerPath, json) {
650
728
  return report.ok ? 0 : 1;
651
729
  }
652
730
  const ledger = statusLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
731
+ if (agent) {
732
+ printCompactJson(buildStatusAgentPacketSingle(ledger, ledgerPath));
733
+ return ledger.ok ? 0 : 1;
734
+ }
653
735
  if (json) {
654
736
  printJson({ ok: ledger.ok, ledger });
655
737
  return ledger.ok ? 0 : 1;
@@ -733,23 +815,101 @@ function sumStatusCounts(ledgers, key) {
733
815
  function formatStatusCounts(counts) {
734
816
  return `active ${counts.active} · due ${counts.due} · manual-review ${counts.manualReview} · missing ${counts.missingPath} · kept ${counts.kept} · pending ${counts.pendingCleanup}`;
735
817
  }
818
+ // Actionable categories only — active and kept are healthy states, never
819
+ // attention. Order is fixed so the packet is byte-for-byte deterministic.
820
+ const STATUS_ATTENTION_CATEGORIES = ["due", "manualReview", "missingPath", "pendingCleanup"];
821
+ function statusAttention(counts) {
822
+ return STATUS_ATTENTION_CATEGORIES.filter((key) => counts[key] > 0);
823
+ }
824
+ function statusCommand(scope, command, ledgerPath) {
825
+ if (scope === "all")
826
+ return `artshelf ${command} --all`;
827
+ return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
828
+ }
829
+ function statusNextAction(blockers, counts, scope, ledgerPath) {
830
+ if (blockers.length > 0) {
831
+ const verify = statusCommand(scope, "status", ledgerPath);
832
+ return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
833
+ }
834
+ const review = statusCommand(scope, "review", ledgerPath);
835
+ if (counts.pendingCleanup > 0 || counts.due > 0) {
836
+ return `run \`${review}\` to preview cleanup plans; nothing is auto-executed`;
837
+ }
838
+ if (counts.manualReview > 0) {
839
+ return `run \`${review}\` to inspect manual-review records; nothing is auto-executed`;
840
+ }
841
+ if (counts.missingPath > 0) {
842
+ return "inspect missing-path records and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
843
+ }
844
+ return "nothing due — no broken ledgers and no due, manual-review, missing-path, or pending cleanup entries";
845
+ }
846
+ function buildStatusAgentPacketAll(report) {
847
+ const blockers = [];
848
+ if (report.registryError)
849
+ blockers.push(`registry unreadable: ${report.registryError}`);
850
+ for (const ledger of report.ledgers) {
851
+ if (ledger.status !== "ok") {
852
+ blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
853
+ }
854
+ }
855
+ const counts = {
856
+ active: report.totals.active,
857
+ due: report.totals.due,
858
+ manualReview: report.totals.manualReview,
859
+ missingPath: report.totals.missingPath,
860
+ kept: report.totals.kept,
861
+ pendingCleanup: report.totals.pendingCleanup
862
+ };
863
+ return {
864
+ schemaVersion: 1,
865
+ command: "status",
866
+ scope: "all",
867
+ health: report.ok ? "ok" : "attention",
868
+ registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
869
+ ledgers: { total: report.totals.ledgers, ok: report.totals.ok, stale: report.totals.stale, invalid: report.totals.invalid },
870
+ counts,
871
+ attention: statusAttention(counts),
872
+ blockers,
873
+ nextAction: statusNextAction(blockers, counts, "all"),
874
+ verification: `artshelf status --all --agent --registry ${report.registryPath}`
875
+ };
876
+ }
877
+ function buildStatusAgentPacketSingle(ledger, ledgerPath) {
878
+ const blockers = ledger.ok
879
+ ? []
880
+ : [`${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`];
881
+ return {
882
+ schemaVersion: 1,
883
+ command: "status",
884
+ scope: "single",
885
+ health: ledger.ok ? "ok" : "attention",
886
+ ledgerPath,
887
+ counts: ledger.counts,
888
+ attention: statusAttention(ledger.counts),
889
+ blockers,
890
+ nextAction: statusNextAction(blockers, ledger.counts, "single", ledgerPath),
891
+ verification: `artshelf status --agent --ledger ${ledgerPath}`
892
+ };
893
+ }
736
894
  function printStatusAll(report) {
737
- process.stdout.write(`artshelf status: ${report.ok ? "ok" : "needs attention"}\n`);
895
+ const anyActionable = report.ledgers.some((ledger) => statusAttention(ledger.counts).length > 0);
896
+ process.stdout.write(`${attentionGlyph(!report.ok || anyActionable)} artshelf status: ${report.ok ? "ok" : "needs attention"}\n`);
738
897
  process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"} — ${report.totals.ledgers} ledgers (${report.totals.ok} ok, ${report.totals.stale} stale, ${report.totals.invalid} invalid)\n`);
739
898
  if (report.registryError)
740
899
  process.stdout.write(`registry error: ${report.registryError}\n`);
741
900
  for (const ledger of report.ledgers) {
742
901
  if (ledger.status === "ok") {
743
- process.stdout.write(`[${ledger.name}] ${formatStatusCounts(ledger.counts)}\n`);
902
+ process.stdout.write(`${attentionGlyph(statusAttention(ledger.counts).length > 0)} [${ledger.name}] ${formatStatusCounts(ledger.counts)}\n`);
744
903
  }
745
904
  else {
746
- process.stdout.write(`[${ledger.name}] ${ledger.status}: ${ledger.errors.join("; ")}\n`);
905
+ process.stdout.write(`${HUMAN_ATTENTION_GLYPH} [${ledger.name}] ${ledger.status}: ${ledger.errors.join("; ")}\n`);
747
906
  }
748
907
  }
749
908
  process.stdout.write(`total: ${formatStatusCounts(report.totals)}\n`);
750
909
  }
751
910
  function printStatusSingle(ledger) {
752
- process.stdout.write(`artshelf status: ${ledger.ok ? "ok" : ledger.status}\n`);
911
+ const needsAttention = !ledger.ok || statusAttention(ledger.counts).length > 0;
912
+ process.stdout.write(`${attentionGlyph(needsAttention)} artshelf status: ${ledger.ok ? "ok" : ledger.status}\n`);
753
913
  process.stdout.write(`ledger: ${ledger.path}\n`);
754
914
  if (ledger.ok) {
755
915
  process.stdout.write(`${formatStatusCounts(ledger.counts)}\n`);
@@ -950,6 +1110,14 @@ function parseArgs(argv) {
950
1110
  const arg = rest[index];
951
1111
  if (!arg)
952
1112
  continue;
1113
+ if (arg === "-h") {
1114
+ flags.set("help", true);
1115
+ continue;
1116
+ }
1117
+ if (arg === "-v") {
1118
+ flags.set("version", true);
1119
+ continue;
1120
+ }
953
1121
  if (!arg.startsWith("--")) {
954
1122
  positionals.push(arg);
955
1123
  continue;
@@ -996,6 +1164,12 @@ function printJson(value) {
996
1164
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
997
1165
  return 0;
998
1166
  }
1167
+ // Agent/compact surface: a single minified JSON line. The default `--json`
1168
+ // stays pretty-printed for audit/debug; agent packets optimize for tokens.
1169
+ function printCompactJson(value) {
1170
+ process.stdout.write(`${JSON.stringify(value)}\n`);
1171
+ return 0;
1172
+ }
999
1173
  function registeredLedgersOrThrow(registryPath) {
1000
1174
  const ledgers = listRegisteredLedgers(registryPath);
1001
1175
  if (ledgers.length === 0)
@@ -1145,13 +1319,16 @@ function summarizeReview(results) {
1145
1319
  }
1146
1320
  return summary;
1147
1321
  }
1148
- function reviewNextAction(summary) {
1322
+ function reviewNextAction(summary, scope, ledgerPath) {
1149
1323
  const broken = summary.invalid + summary.stale;
1324
+ const review = statusCommand(scope, "review", ledgerPath);
1150
1325
  if (broken > 0) {
1151
- return `repair ${broken} broken ledger(s) above (re-register or fix the file), then re-run \`artshelf review --all\``;
1326
+ const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
1327
+ return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
1152
1328
  }
1153
1329
  if (summary.executable > 0) {
1154
- return "run `artshelf cleanup --dry-run --all` to generate plans, then `artshelf cleanup --execute --plan-id <id> --ledger <path>` for each reviewed plan";
1330
+ const dryRun = scope === "all" ? "artshelf cleanup --dry-run --all" : `artshelf cleanup --dry-run${ledgerPath ? ` --ledger ${ledgerPath}` : ""}`;
1331
+ return `run \`${dryRun}\` to generate plans, then \`artshelf cleanup --execute --plan-id <id> --ledger <path>\` for each reviewed plan`;
1155
1332
  }
1156
1333
  if (summary.missingPath > 0) {
1157
1334
  return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
@@ -1160,7 +1337,7 @@ function reviewNextAction(summary) {
1160
1337
  }
1161
1338
  function printReviewAll(results, summary, nextAction, registryPath) {
1162
1339
  const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
1163
- process.stdout.write(`artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
1340
+ process.stdout.write(`${attentionGlyph(needsAttention)} artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
1164
1341
  process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
1165
1342
  printReview(results);
1166
1343
  process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
@@ -1169,12 +1346,236 @@ function printReviewAll(results, summary, nextAction, registryPath) {
1169
1346
  function printReview(results) {
1170
1347
  for (const result of results) {
1171
1348
  const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
1172
- process.stdout.write(`[${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
1349
+ const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0;
1350
+ process.stdout.write(`${attentionGlyph(needsAttention)} [${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
1173
1351
  process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
1174
1352
  process.stdout.write(`ledger: ${result.ledger.path}\n`);
1175
1353
  }
1176
1354
  }
1177
- function printHelp(command) {
1355
+ // review is read-only, so every safety guarantee holds unconditionally.
1356
+ const REVIEW_SAFETY = {
1357
+ dryRunOnly: true,
1358
+ executeAllRefused: true,
1359
+ noExecuteRan: true,
1360
+ noResolveRan: true,
1361
+ noDeleteRan: true
1362
+ };
1363
+ // Classify each registered ledger's records into decision groups. Order is
1364
+ // fixed (registry order, then a stable per-ledger sub-order) so the packet is
1365
+ // byte-for-byte deterministic.
1366
+ function buildReviewDecisions(results, scope) {
1367
+ const readyForApproval = [];
1368
+ const needsReviewFirst = [];
1369
+ const blocked = [];
1370
+ const review = scope === "all" ? "artshelf review --all" : "artshelf review";
1371
+ for (const result of results) {
1372
+ const { ledger, validate, due } = result;
1373
+ if (!validate.ok) {
1374
+ const status = existsSync(ledger.path) ? "invalid" : "missing";
1375
+ const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
1376
+ blocked.push({
1377
+ label: `Repair ${ledger.name} ledger (${status})`,
1378
+ itemIds: [],
1379
+ actionType: "fix-registry",
1380
+ approvalTarget: null,
1381
+ reason: validate.errors[0] ?? `${scope === "all" ? "registered ledger" : "ledger"} is ${status}`,
1382
+ nextStep: `${repair}, then re-run \`${review}\``
1383
+ });
1384
+ continue;
1385
+ }
1386
+ const missingPath = due.filter((entry) => entry.dueStatus === "missing-path");
1387
+ const trashSafe = due.filter((entry) => entry.dueStatus === "due" && entry.cleanup === "trash");
1388
+ const inspectItems = due.filter((entry) => entry.dueStatus === "manual-review" ||
1389
+ (entry.dueStatus === "due" && (entry.cleanup === "review" || entry.cleanup === "delete")));
1390
+ // Ready for approval: missing-path records resolve ledger-only with an exact,
1391
+ // plan-less approval target. Resolution updates the ledger and never touches
1392
+ // files, so it is the one action review can hand an agent directly.
1393
+ if (missingPath.length > 0) {
1394
+ const ids = missingPath.map((entry) => entry.id).sort();
1395
+ readyForApproval.push({
1396
+ label: `Resolve ${ids.length} missing-path record(s) in ${ledger.name}`,
1397
+ itemIds: ids,
1398
+ actionType: "resolve-missing",
1399
+ approvalTarget: `approve artshelf resolve missing ledger ${ledger.path} ids ${ids.join(" ")}`,
1400
+ reason: "the recorded path is already missing",
1401
+ nextStep: "confirm the artifact is no longer needed, then approve the ledger-only resolve"
1402
+ });
1403
+ }
1404
+ // Trash-safe records are cleanup-eligible, but review never mints a plan, so
1405
+ // they carry no approval target: the next step is the dry-run that produces
1406
+ // the reviewed plan id to approve.
1407
+ if (trashSafe.length > 0) {
1408
+ const ids = trashSafe.map((entry) => entry.id).sort();
1409
+ needsReviewFirst.push({
1410
+ label: `Plan cleanup for ${ids.length} trash-eligible artifact(s) in ${ledger.name}`,
1411
+ itemIds: ids,
1412
+ actionType: "cleanup",
1413
+ approvalTarget: null,
1414
+ reason: "disposable artifacts are due but no reviewed cleanup plan exists yet",
1415
+ nextStep: `run \`artshelf cleanup --dry-run --ledger ${ledger.path} --json\`, then approve \`approve artshelf cleanup ledger ${ledger.path} plan <plan-id>\``
1416
+ });
1417
+ }
1418
+ // manual-review and cleanup=review records need a human decision before any
1419
+ // cleanup; cleanup=delete is refused outright. None carry an approval target.
1420
+ if (inspectItems.length > 0) {
1421
+ const ids = inspectItems.map((entry) => entry.id).sort();
1422
+ const hasDelete = inspectItems.some((entry) => entry.cleanup === "delete");
1423
+ needsReviewFirst.push({
1424
+ label: `Inspect ${ids.length} record(s) in ${ledger.name} before cleanup`,
1425
+ itemIds: ids,
1426
+ actionType: "inspect",
1427
+ approvalTarget: null,
1428
+ reason: hasDelete
1429
+ ? "records need manual review; cleanup=delete is refused and never deletes files"
1430
+ : "records are held for manual review before any cleanup",
1431
+ nextStep: "inspect each path, then keep, change retention, resolve, or set cleanup=trash and plan a cleanup"
1432
+ });
1433
+ }
1434
+ }
1435
+ return { readyForApproval, needsReviewFirst, blocked };
1436
+ }
1437
+ function reviewCounts(summary) {
1438
+ return {
1439
+ due: summary.due,
1440
+ manualReview: summary.manualReview,
1441
+ missingPath: summary.missingPath,
1442
+ executable: summary.executable,
1443
+ skipped: summary.skipped
1444
+ };
1445
+ }
1446
+ function buildReviewAgentPacketAll(results, summary, registryPath) {
1447
+ const groups = buildReviewDecisions(results, "all");
1448
+ return {
1449
+ schemaVersion: 1,
1450
+ command: "review",
1451
+ scope: "all",
1452
+ health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1453
+ registry: { path: registryPath, exists: existsSync(registryPath) },
1454
+ ledgers: { total: summary.ledgers, ok: summary.ok, stale: summary.stale, invalid: summary.invalid },
1455
+ counts: reviewCounts(summary),
1456
+ decisionSummary: {
1457
+ readyForApproval: groups.readyForApproval.length,
1458
+ needsReviewFirst: groups.needsReviewFirst.length,
1459
+ blocked: groups.blocked.length
1460
+ },
1461
+ readyForApproval: groups.readyForApproval,
1462
+ needsReviewFirst: groups.needsReviewFirst,
1463
+ blocked: groups.blocked,
1464
+ safety: REVIEW_SAFETY,
1465
+ nextAction: reviewNextAction(summary, "all"),
1466
+ verification: `artshelf review --all --agent --registry ${registryPath}`
1467
+ };
1468
+ }
1469
+ function buildReviewAgentPacketSingle(result, ledgerPath) {
1470
+ const summary = summarizeReview([result]);
1471
+ const groups = buildReviewDecisions([result], "single");
1472
+ return {
1473
+ schemaVersion: 1,
1474
+ command: "review",
1475
+ scope: "single",
1476
+ health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1477
+ ledgerPath,
1478
+ counts: reviewCounts(summary),
1479
+ decisionSummary: {
1480
+ readyForApproval: groups.readyForApproval.length,
1481
+ needsReviewFirst: groups.needsReviewFirst.length,
1482
+ blocked: groups.blocked.length
1483
+ },
1484
+ readyForApproval: groups.readyForApproval,
1485
+ needsReviewFirst: groups.needsReviewFirst,
1486
+ blocked: groups.blocked,
1487
+ safety: REVIEW_SAFETY,
1488
+ nextAction: reviewNextAction(summary, "single", ledgerPath),
1489
+ verification: `artshelf review --agent --ledger ${ledgerPath}`
1490
+ };
1491
+ }
1492
+ const COMMAND_GROUPS = [
1493
+ {
1494
+ group: "Create",
1495
+ commands: [{ name: "put", summary: "Record an artifact with a reason and retention" }]
1496
+ },
1497
+ {
1498
+ group: "Inspect",
1499
+ commands: [
1500
+ { name: "list", summary: "List ledger records" },
1501
+ { name: "find", summary: "Find records by path, owner, label, or status" },
1502
+ { name: "get", summary: "Show one record by id" },
1503
+ { name: "due", summary: "Show due, manual-review, and missing-path records" },
1504
+ { name: "status", summary: "Summarize ledger and registry counts" }
1505
+ ]
1506
+ },
1507
+ {
1508
+ group: "Review",
1509
+ commands: [
1510
+ { name: "validate", summary: "Check ledger shape and report warnings" },
1511
+ { name: "review", summary: "Preview validate, due, and cleanup plans (read-only)" }
1512
+ ]
1513
+ },
1514
+ {
1515
+ group: "Clean",
1516
+ commands: [
1517
+ { name: "cleanup", summary: "Plan and execute approved cleanups" },
1518
+ { name: "trash", summary: "Inspect and purge Artshelf trash" },
1519
+ { name: "resolve", summary: "Mark a record manually resolved" }
1520
+ ]
1521
+ },
1522
+ {
1523
+ group: "System",
1524
+ commands: [
1525
+ { name: "ledgers", summary: "Manage the ledger registry" },
1526
+ { name: "doctor", summary: "Report Artshelf health on this machine" },
1527
+ { name: "update", summary: "Update the Artshelf CLI" }
1528
+ ]
1529
+ }
1530
+ ];
1531
+ // Commands with subcommands that carry their own focused help. Used to route
1532
+ // `artshelf <command> <subcommand> --help` to a nested help key.
1533
+ const NESTED_HELP = new Map([
1534
+ ["trash", new Set(["list", "purge"])],
1535
+ ["ledgers", new Set(["list", "add"])]
1536
+ ]);
1537
+ function resolveHelpKey(parsed) {
1538
+ // `artshelf help [command [subcommand]]`
1539
+ if (parsed.command === "help") {
1540
+ return joinHelpKey(parsed.positionals[0], parsed.positionals[1]);
1541
+ }
1542
+ // `artshelf [--help|-h]` with no command resolves to the top-level help.
1543
+ if (!parsed.command || parsed.command === "--help" || parsed.command === "-h") {
1544
+ return "";
1545
+ }
1546
+ // `artshelf <command> [subcommand] --help`
1547
+ return joinHelpKey(parsed.command, parsed.positionals[0]);
1548
+ }
1549
+ function joinHelpKey(command, subcommand) {
1550
+ if (!command)
1551
+ return "";
1552
+ const subcommands = NESTED_HELP.get(command);
1553
+ if (subcommands && subcommand && subcommands.has(subcommand)) {
1554
+ return `${command} ${subcommand}`;
1555
+ }
1556
+ return command;
1557
+ }
1558
+ function renderTopLevelHelp() {
1559
+ const names = COMMAND_GROUPS.flatMap((entry) => entry.commands.map((command) => command.name));
1560
+ const width = Math.max(...names.map((name) => name.length)) + 2;
1561
+ const lines = [
1562
+ `Artshelf ${VERSION} — approval-first retention for the temporary files agents leave behind.`,
1563
+ "",
1564
+ "Usage:",
1565
+ " artshelf <command> [options]",
1566
+ "",
1567
+ "Available Commands:"
1568
+ ];
1569
+ for (const { group, commands } of COMMAND_GROUPS) {
1570
+ lines.push(` ${group}`);
1571
+ for (const command of commands) {
1572
+ lines.push(` ${command.name.padEnd(width)}${command.summary}`);
1573
+ }
1574
+ }
1575
+ lines.push("", "Global Options:", " -h, --help Show help for artshelf or a specific command", " -v, --version Show the Artshelf version", "", "Output:", " --json Emit machine-readable JSON on commands that return data", "", "Scope (command-specific):", " --ledger <path> Target an explicit JSONL ledger", " --registry <path> Target an explicit ledger registry", " --all Read every registered ledger (on commands that support it)", "", `Use "artshelf <command> --help" for more information about a command.`, "");
1576
+ return lines.join("\n");
1577
+ }
1578
+ function printHelp(command = "") {
1178
1579
  if (command === "put") {
1179
1580
  process.stdout.write(`Usage:
1180
1581
  artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review) [options]
@@ -1208,30 +1609,36 @@ Global --all mode is dry-run only.
1208
1609
  return;
1209
1610
  }
1210
1611
  if (command === "trash") {
1211
- process.stdout.write(`Usage:
1212
- artshelf trash list [--ledger <path>] [--all] [--json]
1213
- artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
1214
- artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
1612
+ process.stdout.write(`Inspect and purge Artshelf trash.
1215
1613
 
1216
- Trash is approval-first. Use list to inspect what is currently in Artshelf trash and
1217
- dry-run purge to generate a reviewed plan id for age-based deletion. Purge
1218
- requires either --dry-run or --execute. Execute requires a reviewed plan id, and
1219
- trash purge is always scoped to one --ledger; --all is not supported for purge
1220
- (only for trash list).
1221
- Trash receipt artifacts are registered when purge executes. Completed receipts are
1222
- refused on repeat execute; started receipts from interrupted purges may be resumed
1223
- and reconciled. Purged records are resolved and no longer reappear as trashed.
1614
+ Usage:
1615
+ artshelf trash [command]
1616
+
1617
+ Available Commands:
1618
+ list List records currently held in Artshelf trash
1619
+ purge Plan or execute approved permanent trash deletion
1620
+
1621
+ Flags:
1622
+ -h, --help help for trash
1623
+
1624
+ Use "artshelf trash <command> --help" for more information about a command.
1224
1625
  `);
1225
1626
  return;
1226
1627
  }
1227
1628
  if (command === "ledgers") {
1228
- process.stdout.write(`Usage:
1229
- artshelf ledgers list [--plain] [--registry <path>] [--json]
1230
- artshelf ledgers add --ledger <path> [--name <name>] [--scope repo|user|other] [--registry <path>] [--json]
1629
+ process.stdout.write(`Manage the ledger registry.
1630
+
1631
+ Usage:
1632
+ artshelf ledgers [command]
1231
1633
 
1232
- The ledger registry is a global index of known ledgers. It gives Artshelf one read-only entry point without moving project records into one global ledger.
1233
- By default \`list\` validates each registered ledger and reports ok/missing/invalid status, entry counts, and warning/error counts so agents can spot stale registry entries without a separate validate pass; it exits non-zero when the registry or any registered ledger is broken.
1234
- Use \`--plain\` for the fast path that lists registered ledgers without reading them.
1634
+ Available Commands:
1635
+ list List and validate registered ledgers
1636
+ add Register an existing ledger file
1637
+
1638
+ Flags:
1639
+ -h, --help help for ledgers
1640
+
1641
+ Use "artshelf ledgers <command> --help" for more information about a command.
1235
1642
  `);
1236
1643
  return;
1237
1644
  }
@@ -1283,17 +1690,28 @@ Resolved records stay in the audit trail but no longer participate in due or cle
1283
1690
  }
1284
1691
  if (command === "review") {
1285
1692
  process.stdout.write(`Usage:
1286
- artshelf review [--ledger <path>] [--json]
1287
- artshelf review --all [--registry <path>] [--json]
1693
+ artshelf review [--ledger <path>] [--json|--agent]
1694
+ artshelf review --all [--registry <path>] [--json|--agent]
1288
1695
 
1289
- Review runs validate, due, and cleanup plan preview without moving files or writing a plan.
1290
- With --all, review adds aggregate triage counts and the next safe action.
1696
+ Review runs validate, due, and cleanup plan preview without moving files or
1697
+ writing a plan. With --all, review adds aggregate triage counts and the next
1698
+ safe action.
1699
+
1700
+ Render modes:
1701
+ (default) Human summary of validation, triage counts, and the next safe action.
1702
+ --json Full read-only audit report (backward-compatible).
1703
+ --agent Compact single-line JSON decision packet for agents: health, triage
1704
+ counts, and classified decision groups (ready for approval, needs
1705
+ review first, blocked) with exact approval targets where they are
1706
+ safe. Review is read-only, so cleanup approval targets are minted by
1707
+ \`cleanup --dry-run\`, never leaked from a preview plan id.
1708
+ Token-efficient; --agent takes precedence over --json.
1291
1709
  `);
1292
1710
  return;
1293
1711
  }
1294
1712
  if (command === "doctor") {
1295
1713
  process.stdout.write(`Usage:
1296
- artshelf doctor [--registry <path>] [--ledger <path>] [--json]
1714
+ artshelf doctor [--registry <path>] [--ledger <path>] [--json|--agent]
1297
1715
 
1298
1716
  Doctor reports whether Artshelf is healthy on this machine: CLI version, selected
1299
1717
  or default ledger path, selected or global registry path, registered ledger health
@@ -1302,6 +1720,14 @@ selected or default ledger and still requires a reviewed plan id; --all execute
1302
1720
  and cleanup=delete are refused, while physical trash purge requires a separate
1303
1721
  reviewed purge plan.
1304
1722
 
1723
+ Render modes:
1724
+ (default) Human summary of machine health and cleanup safety.
1725
+ --json Full audit report (backward-compatible; suitable for cron/reporting).
1726
+ --agent Compact single-line JSON decision packet for agents: health, registry
1727
+ and registered-ledger health, blockers, cleanup-safety posture, next
1728
+ action, and a verify command. Token-efficient; --agent takes
1729
+ precedence over --json.
1730
+
1305
1731
  Run it after install, when --all commands behave unexpectedly, or on a schedule to
1306
1732
  catch stale registry entries. Doctor is read-only. A healthy machine exits 0; a
1307
1733
  broken registry or registered ledger exits non-zero with actionable errors.
@@ -1310,8 +1736,8 @@ broken registry or registered ledger exits non-zero with actionable errors.
1310
1736
  }
1311
1737
  if (command === "status") {
1312
1738
  process.stdout.write(`Usage:
1313
- artshelf status [--ledger <path>] [--json]
1314
- artshelf status --all [--registry <path>] [--json]
1739
+ artshelf status [--ledger <path>] [--json|--agent]
1740
+ artshelf status --all [--registry <path>] [--json|--agent]
1315
1741
 
1316
1742
  Status is the lightweight daily "what is going on?" view. Without --all, it
1317
1743
  reports counts for the selected or default ledger only. With --all, it adds
@@ -1319,10 +1745,16 @@ registry health, total ledgers, and aggregated counts across registered ledgers.
1319
1745
  Counts include active artifacts, kept, due, manual-review, missing-path, and
1320
1746
  pending cleanup entries.
1321
1747
 
1322
- Human output is short enough to paste into a chat; \`artshelf status --all --json\`
1323
- is suitable for cron and reporting. Status is read-only: it never creates plans
1324
- or receipts and never mutates records. A healthy selected ledger exits 0; with
1325
- --all, a broken registry or any stale or invalid registered ledger exits non-zero.
1748
+ Render modes:
1749
+ (default) Human summary, short enough to paste into a chat.
1750
+ --json Full audit report (backward-compatible; suitable for cron/reporting).
1751
+ --agent Compact single-line JSON decision packet for agents: health, counts,
1752
+ attention categories, blockers, next action, and a verify command.
1753
+ Token-efficient; --agent takes precedence over --json.
1754
+
1755
+ Status is read-only: it never creates plans or receipts and never mutates
1756
+ records. A healthy selected ledger exits 0; with --all, a broken registry or any
1757
+ stale or invalid registered ledger exits non-zero.
1326
1758
  `);
1327
1759
  return;
1328
1760
  }
@@ -1341,50 +1773,99 @@ installs should update by pulling, rebuilding, and linking the checkout.
1341
1773
  `);
1342
1774
  return;
1343
1775
  }
1344
- process.stdout.write(`Artshelf ${VERSION}
1776
+ if (command === "due") {
1777
+ process.stdout.write(`Usage:
1778
+ artshelf due [--ledger <path>] [--json]
1779
+ artshelf due --all [--registry <path>] [--json]
1345
1780
 
1346
- Usage:
1347
- artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review)
1348
- artshelf ledgers list [--plain] [--json]
1349
- artshelf ledgers add --ledger <path> [--name <name>] [--json]
1350
- artshelf list [--json]
1351
- artshelf list --all [--json]
1352
- artshelf list --status active [--json]
1353
- artshelf find --path <path> [--json]
1354
- artshelf find --all --owner <name> [--json]
1355
- artshelf get <id> [--json]
1356
- artshelf get <id> --all [--json]
1357
- artshelf due [--json]
1358
- artshelf due --all [--json]
1359
- artshelf validate [--json]
1360
- artshelf validate --all [--json]
1361
- artshelf review [--json]
1362
- artshelf review --all [--json]
1363
- artshelf doctor [--json]
1364
- artshelf status [--json]
1365
- artshelf status --all [--json]
1366
- artshelf update [--json]
1367
- artshelf cleanup --dry-run [--json]
1368
- artshelf cleanup --dry-run --all [--json]
1369
- artshelf cleanup --execute --plan-id <id> [--json]
1370
- artshelf trash list [--all] [--ledger <path>] [--json]
1781
+ Due lists records whose retention has elapsed or that need attention: due,
1782
+ manual-review, and missing-path entries. Kept entries are hidden in human output.
1783
+ Due is read-only and never moves files or writes plans.
1784
+ `);
1785
+ return;
1786
+ }
1787
+ if (command === "validate") {
1788
+ process.stdout.write(`Usage:
1789
+ artshelf validate [--ledger <path>] [--json]
1790
+ artshelf validate --all [--registry <path>] [--json]
1791
+
1792
+ Validate checks ledger shape and reports errors and warnings, such as records
1793
+ that point at missing artifact paths, without changing anything. A clean ledger
1794
+ exits 0; shape errors exit non-zero. With --all it validates every registered
1795
+ ledger.
1796
+ `);
1797
+ return;
1798
+ }
1799
+ if (command === "trash list") {
1800
+ process.stdout.write(`Usage:
1801
+ artshelf trash list [--ledger <path>] [--all] [--registry <path>] [--json]
1802
+
1803
+ Options:
1804
+ --ledger <path> Use a specific ledger file
1805
+ --all Include records from all registered ledgers
1806
+ --registry <path> Registry path used with --all
1807
+ --json Emit machine-readable output
1808
+
1809
+ Trash list shows records currently held in Artshelf trash without deleting anything.
1810
+ With --all it reports trashed records across every registered ledger.
1811
+ `);
1812
+ return;
1813
+ }
1814
+ if (command === "trash purge") {
1815
+ process.stdout.write(`Usage:
1371
1816
  artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
1372
1817
  artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
1373
- artshelf resolve <id> --status resolved --reason <text> [--json]
1374
1818
 
1375
- Global options:
1376
- --ledger <path> Use an explicit JSONL ledger
1377
- --registry <path> Use an explicit ledger registry
1378
- --all Read all registered ledgers for supported commands
1379
- --json Emit machine-readable JSON
1380
- --help Show help
1381
- --version Show version
1819
+ Options:
1820
+ --older-than <ttl> Purge trashed records older than this duration
1821
+ --dry-run Build a reviewed purge plan and output a plan id
1822
+ --execute Execute a reviewed purge plan
1823
+ --plan-id <id> Execute only this reviewed purge plan
1824
+ --ledger <path> Target one specific ledger
1825
+ --json Emit machine-readable output
1382
1826
 
1383
- Examples:
1384
- artshelf put tmp/run-output --reason "debug parser output" --ttl 3d --kind scratch
1385
- artshelf cleanup --dry-run --json
1386
- artshelf cleanup --execute --plan-id plan_20260601_120000_ab12
1827
+ Trash purge permanently deletes aged trash from a reviewed plan. --dry-run turns
1828
+ --older-than into a reviewed purge plan id; --execute deletes only that one reviewed
1829
+ plan id. Purge is always scoped to one --ledger; --all is not supported for purge.
1830
+ Completed receipts are refused on repeat execute; an interrupted purge may be resumed
1831
+ and reconciled.
1387
1832
  `);
1833
+ return;
1834
+ }
1835
+ if (command === "ledgers list") {
1836
+ process.stdout.write(`Usage:
1837
+ artshelf ledgers list [--plain] [--registry <path>] [--json]
1838
+
1839
+ Options:
1840
+ --plain Skip ledger validation and list registrations directly
1841
+ --registry <path> Registry path to use
1842
+ --json Emit machine-readable output
1843
+
1844
+ Ledgers list validates every registered ledger and reports ok/missing/invalid
1845
+ status, entry counts, and warnings so agents can spot stale registry entries
1846
+ without a separate validate pass. Use --plain for the fast path that lists
1847
+ registered ledgers without reading them. It exits non-zero when the registry or
1848
+ any registered ledger is broken.
1849
+ `);
1850
+ return;
1851
+ }
1852
+ if (command === "ledgers add") {
1853
+ process.stdout.write(`Usage:
1854
+ artshelf ledgers add --ledger <path> [--name <name>] [--scope repo|user|other] [--registry <path>] [--json]
1855
+
1856
+ Options:
1857
+ --ledger <path> Register this ledger file
1858
+ --name <name> Override the ledger display name
1859
+ --scope <scope> Registry scope: repo, user, or other
1860
+ --registry <path> Registry path to update
1861
+ --json Emit machine-readable output
1862
+
1863
+ Ledgers add registers an existing ledger file in the global registry so --all
1864
+ commands and the registry index can find it. The ledger file must already exist.
1865
+ `);
1866
+ return;
1867
+ }
1868
+ process.stdout.write(renderTopLevelHelp());
1388
1869
  }
1389
1870
  main(process.argv.slice(2))
1390
1871
  .then((status) => {