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.
- package/CHANGELOG.md +23 -0
- package/README.md +16 -10
- package/SPEC.md +152 -27
- package/dist/src/cli.js +2 -2
- package/dist/src/commands/dispose.js +88 -0
- package/dist/src/commands/get.js +24 -2
- package/dist/src/commands/index.js +4 -0
- package/dist/src/dispose.js +706 -0
- package/dist/src/inspect.js +207 -0
- package/dist/src/ledger.js +33 -22
- package/dist/src/renderers/inspect.js +108 -0
- package/dist/src/shared/flags.js +6 -0
- package/dist/src/shared/help-text.js +40 -0
- package/dist/src/shared/shell-quote.js +6 -0
- package/docs/agent-clean.html +30 -5
- package/docs/agent-monitor.html +2 -1
- package/docs/agent-review.html +37 -0
- package/docs/agent-usage.html +5 -3
- package/docs/agent-usage.md +12 -9
- package/docs/index.html +4 -0
- package/docs/install.html +2 -2
- package/docs/reference.html +43 -6
- package/package.json +1 -1
- package/skills/artshelf/SKILL.md +32 -40
|
@@ -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
|
+
}
|
package/dist/src/ledger.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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:
|
|
95
|
-
receiptPath:
|
|
96
|
-
cleanupPlanId:
|
|
97
|
-
age: ageOf(current,
|
|
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
|
|
218
|
-
errors.push(`${label}: trashed record missing
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
518
|
+
reason: "trashed record missing trash provenance"
|
|
510
519
|
});
|
|
511
520
|
continue;
|
|
512
521
|
}
|
|
513
|
-
const cleanedAt = new Date(
|
|
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:
|
|
530
|
-
receiptPath:
|
|
531
|
-
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
|
+
}
|
package/dist/src/shared/flags.js
CHANGED
|
@@ -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") {
|
package/docs/agent-clean.html
CHANGED
|
@@ -77,6 +77,29 @@ artshelf cleanup --execute --plan-id <id></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 <id> --action trash-resolve|resolve-only|snooze|keep --dry-run [--reason <text>] [--ttl <ttl>|--retain-until <date>] --ledger <ledger-path> --json
|
|
89
|
+
|
|
90
|
+
<span class="c"># approve: approve artshelf dispose ledger <ledger-path> plan <plan-id></span>
|
|
91
|
+
<span class="c"># only after a human approves this exact plan id</span>
|
|
92
|
+
artshelf dispose --execute --plan-id <plan-id> --ledger <ledger-path></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 <id> --status resolved --reason <text></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
|
-
|
|
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>.
|
|
96
|
-
|
|
97
|
-
|
|
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>
|
package/docs/agent-monitor.html
CHANGED
|
@@ -93,7 +93,7 @@ artshelf find --all --owner <agent-or-runtime> --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 <ledger-path> --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 <id>
|
|
162
|
+
artshelf dispose --execute --plan-id <id>
|
|
162
163
|
artshelf ledgers prune --execute --plan-id <id>
|
|
163
164
|
artshelf trash purge --execute --plan-id <id></code></pre>
|
|
164
165
|
</section>
|
package/docs/agent-review.html
CHANGED
|
@@ -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 <id> --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 <id> --inspect --ledger <ledger-path>
|
|
80
|
+
|
|
81
|
+
<span class="c"># compact, deterministic packet for an acting agent</span>
|
|
82
|
+
artshelf get <id> --inspect --agent --ledger <ledger-path></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>✓ <id> [backup] — keep
|
|
96
|
+
path: <backup-path>
|
|
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 <id> --action keep --dry-run --reason '<why>' --ledger <ledger-path>` to keep it quiet through a reviewed decision, or choose resolve-only/snooze deliberately.
|
|
101
|
+
ledger: <ledger-path></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 <ledger-path> plan <plan-id>
|
|
150
|
+
approve artshelf dispose ledger <ledger-path> plan <dispose-plan-id>
|
|
114
151
|
approve artshelf trash purge ledger <ledger-path> plan <purge-plan-id>
|
|
115
152
|
approve artshelf resolve missing ledger <ledger-path> ids <id...>
|
|
116
153
|
approve artshelf reconcile ledger <ledger-path> plan <plan-id>
|