artshelf 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,846 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { addTtl, assertIsoDate, ageOf, now, ttlToMs, toIso } from "./time.js";
6
+ const KINDS = new Set([
7
+ "scratch",
8
+ "backup",
9
+ "run-artifact",
10
+ "evidence",
11
+ "cache",
12
+ "quarantine",
13
+ "other"
14
+ ]);
15
+ const CLEANUP_ACTIONS = new Set(["trash", "review", "delete"]);
16
+ const STATUSES = new Set(["active", "review-required", "trashed", "cleanup-refused", "resolved"]);
17
+ const RESOLVE_STATUSES = new Set(["resolved"]);
18
+ export function defaultLedgerPath(cwd = process.cwd()) {
19
+ const repoRoot = findGitRoot(cwd);
20
+ if (repoRoot)
21
+ return join(repoRoot, ".shelf", "ledger.jsonl");
22
+ return join(homedir(), ".shelf", "ledger.jsonl");
23
+ }
24
+ export function normalizeLedgerPath(path) {
25
+ return resolve(path ?? defaultLedgerPath());
26
+ }
27
+ export function putRecord(ledgerPath, input) {
28
+ const record = prepareRecord(input);
29
+ appendPreparedRecord(ledgerPath, record);
30
+ return record;
31
+ }
32
+ export function prepareRecord(input) {
33
+ const artifactPath = resolve(input.path);
34
+ if (!existsSync(artifactPath)) {
35
+ throw new Error(`Path does not exist: ${input.path}`);
36
+ }
37
+ if (!input.reason || input.reason.trim().length === 0) {
38
+ throw new Error("Missing required --reason");
39
+ }
40
+ const retentionCount = [input.ttl, input.retainUntil, input.manualReview].filter(Boolean).length;
41
+ if (retentionCount !== 1) {
42
+ throw new Error("Choose exactly one of --ttl, --retain-until, or --manual-review");
43
+ }
44
+ const kind = assertKind(input.kind ?? "other");
45
+ const cleanup = assertCleanup(input.cleanup ?? "review");
46
+ const createdAt = now();
47
+ const retentionPlan = buildRetention(input, createdAt);
48
+ const record = {
49
+ id: makeId(createdAt),
50
+ path: artifactPath,
51
+ kind,
52
+ reason: input.reason.trim(),
53
+ createdAt: toIso(createdAt),
54
+ ...(retentionPlan.retainUntil ? { retainUntil: retentionPlan.retainUntil } : {}),
55
+ retention: retentionPlan.retention,
56
+ cleanup,
57
+ owner: input.owner ?? "manual",
58
+ labels: input.labels,
59
+ status: "active"
60
+ };
61
+ return record;
62
+ }
63
+ export function appendPreparedRecord(ledgerPath, record) {
64
+ appendRecord(ledgerPath, record);
65
+ }
66
+ export function readLedger(ledgerPath) {
67
+ if (!existsSync(ledgerPath))
68
+ return [];
69
+ const content = readFileSync(ledgerPath, "utf8").trim();
70
+ if (!content)
71
+ return [];
72
+ return content.split(/\n+/).map((line, index) => {
73
+ try {
74
+ return JSON.parse(line);
75
+ }
76
+ catch (error) {
77
+ throw new Error(`Invalid JSONL at line ${index + 1}: ${error.message}`);
78
+ }
79
+ });
80
+ }
81
+ export function listTrashedRecords(ledgerPath) {
82
+ const records = readLedger(ledgerPath).filter((record) => record.status === "trashed");
83
+ const current = now();
84
+ return records.map((record) => {
85
+ if (!record.id || !record.targetPath || !record.cleanedAt || !record.receiptPath || !record.cleanupPlanId) {
86
+ throw new Error(`trashed record ${record.id ?? "<missing id>"} missing cleanup metadata`);
87
+ }
88
+ return {
89
+ id: record.id,
90
+ targetPath: record.targetPath,
91
+ cleanedAt: record.cleanedAt,
92
+ receiptPath: record.receiptPath,
93
+ cleanupPlanId: record.cleanupPlanId,
94
+ age: ageOf(current, record.cleanedAt)
95
+ };
96
+ });
97
+ }
98
+ export function filterRecordsByStatus(records, status) {
99
+ if (!status)
100
+ return records;
101
+ const normalized = assertStatus(status);
102
+ return records.filter((record) => record.status === normalized);
103
+ }
104
+ export function getRecord(records, id) {
105
+ if (!id || id.trim().length === 0)
106
+ throw new Error("get requires <id>");
107
+ const record = records.find((entry) => entry.id === id);
108
+ if (!record)
109
+ throw new Error(`Artshelf record not found: ${id}`);
110
+ return record;
111
+ }
112
+ export function findRecords(records, input) {
113
+ const hasQuery = Boolean(input.path || input.owner || input.labels.length > 0 || input.status);
114
+ if (!hasQuery) {
115
+ throw new Error("find requires at least one of --path, --owner, --label, or --status");
116
+ }
117
+ const normalizedPath = input.path ? resolve(input.path) : undefined;
118
+ const normalizedStatus = input.status ? assertStatus(input.status) : undefined;
119
+ return records.filter((record) => {
120
+ if (normalizedPath && record.path !== normalizedPath)
121
+ return false;
122
+ if (input.owner && record.owner !== input.owner)
123
+ return false;
124
+ if (normalizedStatus && record.status !== normalizedStatus)
125
+ return false;
126
+ for (const label of input.labels) {
127
+ if (!record.labels.includes(label))
128
+ return false;
129
+ }
130
+ return true;
131
+ });
132
+ }
133
+ export function resolveRecord(ledgerPath, input) {
134
+ if (!input.id || input.id.trim().length === 0)
135
+ throw new Error("resolve requires <id>");
136
+ if (!input.reason || input.reason.trim().length === 0)
137
+ throw new Error("Missing required --reason");
138
+ const status = assertResolveStatus(input.status);
139
+ const records = readLedger(ledgerPath);
140
+ const index = records.findIndex((record) => record.id === input.id);
141
+ if (index === -1)
142
+ throw new Error(`Artshelf record not found: ${input.id}`);
143
+ const current = records[index];
144
+ if (!current)
145
+ throw new Error(`Artshelf record not found: ${input.id}`);
146
+ if (current.status === "resolved") {
147
+ throw new Error(`Artshelf record is already resolved: ${input.id}`);
148
+ }
149
+ const updated = {
150
+ ...current,
151
+ status,
152
+ resolvedAt: toIso(now()),
153
+ resolutionReason: input.reason.trim()
154
+ };
155
+ records[index] = updated;
156
+ writeLedger(ledgerPath, records);
157
+ return updated;
158
+ }
159
+ export function dueEntries(records, at = now()) {
160
+ return records.filter((record) => record.status === "active").map((record) => {
161
+ const dueStatus = classifyDue(record, at);
162
+ return {
163
+ id: record.id,
164
+ path: record.path,
165
+ reason: record.reason,
166
+ cleanup: record.cleanup,
167
+ dueStatus,
168
+ ...(record.retainUntil ? { retainUntil: record.retainUntil } : {})
169
+ };
170
+ });
171
+ }
172
+ export function validateLedger(ledgerPath) {
173
+ const errors = [];
174
+ const warnings = [];
175
+ let records = [];
176
+ try {
177
+ records = readLedger(ledgerPath);
178
+ }
179
+ catch (error) {
180
+ errors.push(error.message);
181
+ return { ok: false, errors, warnings, entries: 0 };
182
+ }
183
+ const ids = new Set();
184
+ for (const [index, record] of records.entries()) {
185
+ const label = record.id ?? `line ${index + 1}`;
186
+ for (const field of ["id", "path", "kind", "reason", "createdAt", "retention", "cleanup", "owner", "labels", "status"]) {
187
+ if (!(field in record))
188
+ errors.push(`${label}: missing ${field}`);
189
+ }
190
+ if (record.id) {
191
+ if (ids.has(record.id))
192
+ errors.push(`${record.id}: duplicate id`);
193
+ ids.add(record.id);
194
+ }
195
+ if (record.path && !isAbsolute(record.path))
196
+ errors.push(`${label}: path must be absolute`);
197
+ if (record.kind && !KINDS.has(record.kind))
198
+ errors.push(`${label}: unknown kind ${record.kind}`);
199
+ if (record.cleanup && !CLEANUP_ACTIONS.has(record.cleanup)) {
200
+ errors.push(`${label}: unknown cleanup ${record.cleanup}`);
201
+ }
202
+ if (!Array.isArray(record.labels))
203
+ errors.push(`${label}: labels must be an array`);
204
+ if (record.status && !STATUSES.has(record.status))
205
+ errors.push(`${label}: unknown status ${record.status}`);
206
+ if (!validRetention(record))
207
+ errors.push(`${label}: invalid retention`);
208
+ if ((record.status === "active" || record.status === "review-required") && record.path && !existsSync(record.path)) {
209
+ warnings.push(`${label}: recorded path is missing`);
210
+ }
211
+ if (record.status === "trashed") {
212
+ if (!record.cleanupPlanId)
213
+ errors.push(`${label}: trashed record missing cleanupPlanId`);
214
+ if (!record.receiptPath)
215
+ errors.push(`${label}: trashed record missing receiptPath`);
216
+ if (!record.cleanedAt)
217
+ errors.push(`${label}: trashed record missing cleanedAt`);
218
+ if (!record.targetPath) {
219
+ errors.push(`${label}: trashed record missing targetPath`);
220
+ }
221
+ else if (!existsSync(record.targetPath)) {
222
+ warnings.push(`${label}: trashed target path is missing`);
223
+ }
224
+ }
225
+ if (record.status === "review-required" || record.status === "cleanup-refused") {
226
+ if (!record.cleanupPlanId)
227
+ errors.push(`${label}: ${record.status} record missing cleanupPlanId`);
228
+ if (!record.receiptPath)
229
+ errors.push(`${label}: ${record.status} record missing receiptPath`);
230
+ if (!record.cleanedAt)
231
+ errors.push(`${label}: ${record.status} record missing cleanedAt`);
232
+ }
233
+ if (record.status === "resolved") {
234
+ if (!record.resolvedAt)
235
+ errors.push(`${label}: resolved record missing resolvedAt`);
236
+ if (!record.resolutionReason)
237
+ errors.push(`${label}: resolved record missing resolutionReason`);
238
+ }
239
+ }
240
+ return { ok: errors.length === 0, errors, warnings, entries: records.length };
241
+ }
242
+ export function createCleanupPlan(ledgerPath) {
243
+ const plan = buildCleanupPlan(ledgerPath);
244
+ if (plan.entries.length === 0)
245
+ return noCreatedPlan(plan);
246
+ const existingPlan = matchingExistingCleanupPlan(ledgerPath, plan);
247
+ if (existingPlan) {
248
+ const refreshedPlan = {
249
+ ...plan,
250
+ planId: existingPlan.planId,
251
+ planPath: existingPlan.planPath
252
+ };
253
+ if (!refreshedPlan.planPath)
254
+ throw new Error("cleanup plan path was not created");
255
+ writeJson(refreshedPlan.planPath, refreshedPlan);
256
+ registerArtshelfArtifact(ledgerPath, refreshedPlan.planPath, {
257
+ reason: `Artshelf cleanup dry-run plan ${refreshedPlan.planId}`,
258
+ ttl: "14d",
259
+ kind: "run-artifact",
260
+ cleanup: "trash",
261
+ labels: ["artshelf", "cleanup-plan", refreshedPlan.planId]
262
+ });
263
+ return refreshedPlan;
264
+ }
265
+ if (!plan.planPath)
266
+ throw new Error("cleanup plan path was not created");
267
+ writeJson(plan.planPath, plan);
268
+ registerArtshelfArtifact(ledgerPath, plan.planPath, {
269
+ reason: `Artshelf cleanup dry-run plan ${plan.planId}`,
270
+ ttl: "14d",
271
+ kind: "run-artifact",
272
+ cleanup: "trash",
273
+ labels: ["artshelf", "cleanup-plan", plan.planId]
274
+ });
275
+ return plan;
276
+ }
277
+ export function previewCleanupPlan(ledgerPath) {
278
+ const plan = buildCleanupPlan(ledgerPath);
279
+ return plan.entries.length === 0 ? noCreatedPlan(plan) : plan;
280
+ }
281
+ export function createTrashPurgePlan(ledgerPath, olderThan) {
282
+ const plan = buildTrashPurgePlan(ledgerPath, olderThan);
283
+ if (plan.entries.length === 0)
284
+ return noCreatedTrashPurgePlan(plan);
285
+ if (!plan.planPath)
286
+ throw new Error("trash purge plan path was not created");
287
+ writeJson(plan.planPath, plan);
288
+ registerArtshelfArtifact(ledgerPath, plan.planPath, {
289
+ reason: `Artshelf trash purge dry-run plan ${plan.purgePlanId}`,
290
+ ttl: "14d",
291
+ kind: "run-artifact",
292
+ cleanup: "review",
293
+ labels: ["artshelf", "trash-purge-plan", plan.purgePlanId]
294
+ });
295
+ return plan;
296
+ }
297
+ export function previewTrashPurgePlan(ledgerPath, olderThan) {
298
+ const plan = buildTrashPurgePlan(ledgerPath, olderThan);
299
+ return plan.entries.length === 0 ? noCreatedTrashPurgePlan(plan) : plan;
300
+ }
301
+ export function executeTrashPurgePlan(ledgerPath, purgePlanId) {
302
+ if (!purgePlanId)
303
+ throw new Error("trash purge --execute requires --plan-id");
304
+ const planPath = trashPurgePlanPath(ledgerPath, purgePlanId);
305
+ if (!existsSync(planPath))
306
+ throw new Error(`Trash purge plan not found: ${purgePlanId}`);
307
+ const receiptPath = trashPurgeReceiptPath(ledgerPath, purgePlanId);
308
+ const existingReceipt = existsSync(receiptPath) ? readTrashPurgeReceipt(receiptPath) : null;
309
+ if (existingReceipt?.completedAt)
310
+ throw new Error(`Trash purge receipt already exists: ${purgePlanId}`);
311
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
312
+ const records = readLedger(ledgerPath);
313
+ const recordsById = new Map(records.map((record) => [record.id, record]));
314
+ const trashRoot = resolve(dirname(ledgerPath), "trash");
315
+ const executedAt = existingReceipt?.executedAt ?? toIso(now());
316
+ let results = existingReceipt?.results ?? [];
317
+ const candidates = [];
318
+ for (const entry of plan.entries) {
319
+ const existingResult = results.find((result) => result.id === entry.id);
320
+ if (existingResult && ["failed", "purged", "skipped"].includes(existingResult.status))
321
+ continue;
322
+ const record = recordsById.get(entry.id);
323
+ if (!record) {
324
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "record is missing from ledger" });
325
+ continue;
326
+ }
327
+ if (record.status !== "trashed") {
328
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `record is ${record.status}` });
329
+ continue;
330
+ }
331
+ if (record.targetPath !== entry.targetPath ||
332
+ record.cleanedAt !== entry.cleanedAt ||
333
+ record.receiptPath !== entry.receiptPath ||
334
+ record.cleanupPlanId !== entry.cleanupPlanId) {
335
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "plan entry no longer matches ledger record" });
336
+ continue;
337
+ }
338
+ const targetPath = resolve(entry.targetPath);
339
+ const expectedPlanTrashRoot = resolve(trashRoot, record.cleanupPlanId);
340
+ if (!isPathWithin(trashRoot, targetPath)) {
341
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is outside Artshelf trash" });
342
+ continue;
343
+ }
344
+ if (!isStrictPathWithin(expectedPlanTrashRoot, targetPath)) {
345
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is not a trashed artifact path" });
346
+ continue;
347
+ }
348
+ if (!pathExistsForPurge(entry.targetPath)) {
349
+ if (existingResult?.status === "deleting") {
350
+ results = upsertTrashPurgeResult(results, { id: entry.id, status: "purged", targetPath });
351
+ continue;
352
+ }
353
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is missing" });
354
+ continue;
355
+ }
356
+ try {
357
+ if (resolvesOutsideLedgerTrash(dirname(ledgerPath), trashRoot, expectedPlanTrashRoot, targetPath)) {
358
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target resolves outside Artshelf trash" });
359
+ continue;
360
+ }
361
+ }
362
+ catch (error) {
363
+ const reason = error instanceof Error ? error.message : String(error);
364
+ results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `target cannot be validated: ${reason}` });
365
+ continue;
366
+ }
367
+ candidates.push({ id: entry.id, targetPath });
368
+ }
369
+ writeTrashPurgeReceipt(receiptPath, {
370
+ purgePlanId,
371
+ executedAt,
372
+ status: "started",
373
+ results: [
374
+ ...results,
375
+ ...candidates.map((candidate) => ({ id: candidate.id, status: "pending", targetPath: candidate.targetPath }))
376
+ ]
377
+ });
378
+ for (const candidate of candidates) {
379
+ results = upsertTrashPurgeResult(results, { id: candidate.id, status: "deleting", targetPath: candidate.targetPath });
380
+ writeTrashPurgeReceipt(receiptPath, {
381
+ purgePlanId,
382
+ executedAt,
383
+ status: "started",
384
+ results: [
385
+ ...results,
386
+ ...pendingTrashPurgeResults(candidates, results)
387
+ ]
388
+ });
389
+ try {
390
+ rmSync(candidate.targetPath, { recursive: true, force: true });
391
+ results = upsertTrashPurgeResult(results, { id: candidate.id, status: "purged", targetPath: candidate.targetPath });
392
+ }
393
+ catch (error) {
394
+ const reason = error instanceof Error ? error.message : String(error);
395
+ results = upsertTrashPurgeResult(results, { id: candidate.id, status: "failed", targetPath: candidate.targetPath, reason });
396
+ }
397
+ writeTrashPurgeReceipt(receiptPath, {
398
+ purgePlanId,
399
+ executedAt,
400
+ status: "started",
401
+ results: [
402
+ ...results,
403
+ ...pendingTrashPurgeResults(candidates, results)
404
+ ]
405
+ });
406
+ }
407
+ updateLedgerAfterTrashPurge(ledgerPath, records, { purgePlanId, receiptPath, executedAt, results });
408
+ writeTrashPurgeReceipt(receiptPath, { purgePlanId, executedAt, completedAt: toIso(now()), results });
409
+ registerArtshelfArtifact(ledgerPath, receiptPath, {
410
+ reason: `Artshelf trash purge receipt for plan ${purgePlanId}`,
411
+ ttl: "30d",
412
+ kind: "run-artifact",
413
+ cleanup: "review",
414
+ labels: ["artshelf", "trash-purge-receipt", purgePlanId]
415
+ });
416
+ return { purgePlanId, receiptPath, results };
417
+ }
418
+ function readTrashPurgeReceipt(receiptPath) {
419
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
420
+ return {
421
+ ...(typeof receipt.purgePlanId === "string" ? { purgePlanId: receipt.purgePlanId } : {}),
422
+ ...(typeof receipt.executedAt === "string" ? { executedAt: receipt.executedAt } : {}),
423
+ ...(typeof receipt.completedAt === "string" ? { completedAt: receipt.completedAt } : {}),
424
+ results: Array.isArray(receipt.results) ? receipt.results : []
425
+ };
426
+ }
427
+ function writeTrashPurgeReceipt(receiptPath, receipt) {
428
+ writeJson(receiptPath, receipt);
429
+ }
430
+ function pendingTrashPurgeResults(candidates, results) {
431
+ return candidates
432
+ .filter((pending) => !results.some((result) => result.id === pending.id))
433
+ .map((pending) => ({ id: pending.id, status: "pending", targetPath: pending.targetPath }));
434
+ }
435
+ function upsertTrashPurgeResult(results, next) {
436
+ return [...results.filter((result) => result.id !== next.id), next];
437
+ }
438
+ function noCreatedPlan(plan) {
439
+ return {
440
+ ...plan,
441
+ planId: "not-created",
442
+ planPath: null
443
+ };
444
+ }
445
+ function noCreatedTrashPurgePlan(plan) {
446
+ return {
447
+ ...plan,
448
+ purgePlanId: "not-created",
449
+ planPath: null
450
+ };
451
+ }
452
+ function buildCleanupPlan(ledgerPath) {
453
+ const generatedAt = now();
454
+ const records = readLedger(ledgerPath);
455
+ const due = dueEntries(records, generatedAt);
456
+ const entries = [];
457
+ const skipped = [];
458
+ for (const item of due) {
459
+ if (item.dueStatus === "kept") {
460
+ skipped.push({ id: item.id, path: item.path, reason: "retention has not expired", dueStatus: item.dueStatus });
461
+ continue;
462
+ }
463
+ if (item.dueStatus === "missing-path") {
464
+ skipped.push({ id: item.id, path: item.path, reason: "path is missing", dueStatus: item.dueStatus });
465
+ continue;
466
+ }
467
+ entries.push({ id: item.id, path: item.path, action: item.cleanup, dueStatus: item.dueStatus });
468
+ }
469
+ const planId = makePlanId(generatedAt);
470
+ const planPath = cleanupPlanPath(ledgerPath, planId);
471
+ const plan = {
472
+ planId,
473
+ generatedAt: toIso(generatedAt),
474
+ ledgerPath,
475
+ entries,
476
+ skipped,
477
+ planPath
478
+ };
479
+ return plan;
480
+ }
481
+ function buildTrashPurgePlan(ledgerPath, olderThan) {
482
+ const generatedAt = now();
483
+ const olderThanMs = ttlToMs(olderThan);
484
+ const cutoff = toIso(new Date(generatedAt.getTime() - olderThanMs));
485
+ const records = readLedger(ledgerPath);
486
+ const entries = [];
487
+ const skipped = [];
488
+ for (const record of records) {
489
+ if (record.status !== "trashed")
490
+ continue;
491
+ if (!record.id || !record.targetPath || !record.cleanedAt || !record.receiptPath || !record.cleanupPlanId) {
492
+ skipped.push({
493
+ id: record.id ?? "",
494
+ targetPath: record.targetPath ?? "",
495
+ reason: "trashed record missing cleanup metadata"
496
+ });
497
+ continue;
498
+ }
499
+ const cleanedAt = new Date(record.cleanedAt);
500
+ if (Number.isNaN(cleanedAt.getTime())) {
501
+ skipped.push({
502
+ id: record.id,
503
+ targetPath: record.targetPath,
504
+ reason: "invalid cleanedAt value"
505
+ });
506
+ continue;
507
+ }
508
+ if (cleanedAt.getTime() > generatedAt.getTime() - olderThanMs) {
509
+ skipped.push({ id: record.id, targetPath: record.targetPath, reason: `cleanedAt is newer than ${olderThan}` });
510
+ continue;
511
+ }
512
+ entries.push({
513
+ id: record.id,
514
+ targetPath: record.targetPath,
515
+ cleanedAt: record.cleanedAt,
516
+ receiptPath: record.receiptPath,
517
+ cleanupPlanId: record.cleanupPlanId
518
+ });
519
+ }
520
+ const purgePlanId = makePurgePlanId(generatedAt);
521
+ const planPath = trashPurgePlanPath(ledgerPath, purgePlanId);
522
+ return {
523
+ purgePlanId,
524
+ generatedAt: toIso(generatedAt),
525
+ ledgerPath,
526
+ olderThan,
527
+ cutoff,
528
+ entries,
529
+ skipped,
530
+ planPath
531
+ };
532
+ }
533
+ export function executeCleanupPlan(ledgerPath, planId) {
534
+ if (!planId)
535
+ throw new Error("cleanup --execute requires --plan-id");
536
+ const planPath = cleanupPlanPath(ledgerPath, planId);
537
+ if (!existsSync(planPath))
538
+ throw new Error(`Cleanup plan not found: ${planId}`);
539
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
540
+ const trashRoot = join(dirname(ledgerPath), "trash", planId);
541
+ const records = readLedger(ledgerPath);
542
+ const recordsById = new Map(records.map((record) => [record.id, record]));
543
+ const results = [];
544
+ for (const entry of plan.entries) {
545
+ const record = recordsById.get(entry.id);
546
+ if (!record) {
547
+ results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" });
548
+ continue;
549
+ }
550
+ if (record.status !== "active") {
551
+ results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` });
552
+ continue;
553
+ }
554
+ if (!existsSync(entry.path)) {
555
+ results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" });
556
+ continue;
557
+ }
558
+ if (entry.action === "delete") {
559
+ results.push({ id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1" });
560
+ continue;
561
+ }
562
+ if (entry.action === "review") {
563
+ results.push({ id: entry.id, action: entry.action, status: "review-required", path: entry.path });
564
+ continue;
565
+ }
566
+ mkdirSync(trashRoot, { recursive: true });
567
+ const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
568
+ renameSync(entry.path, target);
569
+ results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
570
+ }
571
+ const receiptPath = receiptPathFor(ledgerPath, planId);
572
+ const executedAt = toIso(now());
573
+ writeJson(receiptPath, { planId, executedAt, results });
574
+ updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
575
+ registerArtshelfArtifact(ledgerPath, receiptPath, {
576
+ reason: `Artshelf cleanup receipt for plan ${planId}`,
577
+ ttl: "30d",
578
+ kind: "run-artifact",
579
+ cleanup: "review",
580
+ labels: ["artshelf", "cleanup-receipt", planId]
581
+ });
582
+ return { planId, receiptPath, results };
583
+ }
584
+ function registerArtshelfArtifact(ledgerPath, path, input) {
585
+ const prepared = prepareRecord({
586
+ path,
587
+ reason: input.reason,
588
+ ttl: input.ttl,
589
+ kind: input.kind,
590
+ cleanup: input.cleanup,
591
+ owner: "artshelf",
592
+ labels: input.labels
593
+ });
594
+ const records = readLedger(ledgerPath);
595
+ const index = records.findIndex((record) => (record.owner === "artshelf" &&
596
+ record.status === "active" &&
597
+ record.path === path &&
598
+ sameLabels(record.labels, input.labels)));
599
+ if (index === -1) {
600
+ appendPreparedRecord(ledgerPath, prepared);
601
+ return;
602
+ }
603
+ const current = records[index];
604
+ if (!current)
605
+ return;
606
+ records[index] = {
607
+ ...current,
608
+ reason: prepared.reason,
609
+ createdAt: prepared.createdAt,
610
+ ...(prepared.retainUntil ? { retainUntil: prepared.retainUntil } : {}),
611
+ retention: prepared.retention,
612
+ kind: prepared.kind,
613
+ cleanup: prepared.cleanup,
614
+ labels: prepared.labels
615
+ };
616
+ writeLedger(ledgerPath, records);
617
+ }
618
+ function sameLabels(left, right) {
619
+ if (left.length !== right.length)
620
+ return false;
621
+ return left.every((label, index) => label === right[index]);
622
+ }
623
+ function matchingExistingCleanupPlan(ledgerPath, plan) {
624
+ const plansDir = join(dirname(ledgerPath), "plans");
625
+ if (!existsSync(plansDir))
626
+ return null;
627
+ const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
628
+ for (const filename of filenames) {
629
+ const planPath = join(plansDir, filename);
630
+ try {
631
+ const candidate = JSON.parse(readFileSync(planPath, "utf8"));
632
+ if (candidate.ledgerPath !== ledgerPath)
633
+ continue;
634
+ if (cleanupPlanEntriesFingerprint(candidate) !== cleanupPlanEntriesFingerprint(plan))
635
+ continue;
636
+ return { ...candidate, planPath };
637
+ }
638
+ catch {
639
+ continue;
640
+ }
641
+ }
642
+ return null;
643
+ }
644
+ function cleanupPlanEntriesFingerprint(plan) {
645
+ return JSON.stringify(plan.entries.map((entry) => ({
646
+ id: entry.id,
647
+ path: entry.path,
648
+ action: entry.action,
649
+ dueStatus: entry.dueStatus
650
+ })));
651
+ }
652
+ function appendRecord(ledgerPath, record) {
653
+ mkdirSync(dirname(ledgerPath), { recursive: true });
654
+ const previous = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : "";
655
+ writeFileSync(ledgerPath, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${JSON.stringify(record)}\n`);
656
+ }
657
+ function writeLedger(ledgerPath, records) {
658
+ mkdirSync(dirname(ledgerPath), { recursive: true });
659
+ const tmpPath = `${ledgerPath}.tmp`;
660
+ writeFileSync(tmpPath, records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
661
+ renameSync(tmpPath, ledgerPath);
662
+ }
663
+ function updateLedgerAfterCleanup(ledgerPath, records, receipt) {
664
+ const resultById = new Map(receipt.results.map((result) => [result.id, result]));
665
+ const updated = records.map((record) => {
666
+ const result = resultById.get(record.id);
667
+ if (!result)
668
+ return record;
669
+ if (result.status === "trashed") {
670
+ return {
671
+ ...record,
672
+ status: "trashed",
673
+ cleanupPlanId: receipt.planId,
674
+ receiptPath: receipt.receiptPath,
675
+ cleanedAt: receipt.executedAt,
676
+ ...(result.target ? { targetPath: result.target } : {})
677
+ };
678
+ }
679
+ if (result.status === "review-required") {
680
+ return {
681
+ ...record,
682
+ status: "review-required",
683
+ cleanupPlanId: receipt.planId,
684
+ receiptPath: receipt.receiptPath,
685
+ cleanedAt: receipt.executedAt
686
+ };
687
+ }
688
+ if (result.status === "refused") {
689
+ return {
690
+ ...record,
691
+ status: "cleanup-refused",
692
+ cleanupPlanId: receipt.planId,
693
+ receiptPath: receipt.receiptPath,
694
+ cleanedAt: receipt.executedAt,
695
+ ...(result.reason ? { cleanupReason: result.reason } : {})
696
+ };
697
+ }
698
+ return record;
699
+ });
700
+ writeLedger(ledgerPath, updated);
701
+ }
702
+ function updateLedgerAfterTrashPurge(ledgerPath, records, receipt) {
703
+ const resultById = new Map(receipt.results.map((result) => [result.id, result]));
704
+ const updated = records.map((record) => {
705
+ const result = resultById.get(record.id);
706
+ if (!result || result.status !== "purged")
707
+ return record;
708
+ return {
709
+ ...record,
710
+ status: "resolved",
711
+ resolvedAt: receipt.executedAt,
712
+ resolutionReason: "trash purge completed",
713
+ purgedAt: receipt.executedAt,
714
+ purgePlanId: receipt.purgePlanId,
715
+ purgeReceiptPath: receipt.receiptPath
716
+ };
717
+ });
718
+ writeLedger(ledgerPath, updated);
719
+ }
720
+ function buildRetention(input, createdAt) {
721
+ if (input.manualReview)
722
+ return { retention: { mode: "manual-review" } };
723
+ if (input.ttl) {
724
+ return { retention: { mode: "ttl", ttl: input.ttl }, retainUntil: toIso(addTtl(createdAt, input.ttl)) };
725
+ }
726
+ if (input.retainUntil) {
727
+ const retainUntil = assertIsoDate(input.retainUntil, "--retain-until");
728
+ return { retention: { mode: "retain-until", retainUntil }, retainUntil };
729
+ }
730
+ throw new Error("Choose exactly one of --ttl, --retain-until, or --manual-review");
731
+ }
732
+ function classifyDue(record, at) {
733
+ if (!existsSync(record.path))
734
+ return "missing-path";
735
+ if (record.retention.mode === "manual-review")
736
+ return "manual-review";
737
+ if (!record.retainUntil)
738
+ return "due";
739
+ return new Date(record.retainUntil).getTime() <= at.getTime() ? "due" : "kept";
740
+ }
741
+ function validRetention(record) {
742
+ if (!record.retention || !("mode" in record.retention))
743
+ return false;
744
+ if (record.retention.mode === "manual-review")
745
+ return !record.retainUntil;
746
+ if (record.retention.mode === "ttl")
747
+ return Boolean(record.retention.ttl && record.retainUntil);
748
+ if (record.retention.mode === "retain-until")
749
+ return Boolean(record.retention.retainUntil && record.retainUntil);
750
+ return false;
751
+ }
752
+ function findGitRoot(cwd) {
753
+ let current = resolve(cwd);
754
+ while (true) {
755
+ if (existsSync(join(current, ".git")))
756
+ return current;
757
+ const parent = dirname(current);
758
+ if (parent === current)
759
+ return null;
760
+ current = parent;
761
+ }
762
+ }
763
+ function assertKind(kind) {
764
+ if (!KINDS.has(kind))
765
+ throw new Error(`Unknown kind: ${kind}`);
766
+ return kind;
767
+ }
768
+ function assertCleanup(cleanup) {
769
+ if (!CLEANUP_ACTIONS.has(cleanup))
770
+ throw new Error(`Unknown cleanup action: ${cleanup}`);
771
+ return cleanup;
772
+ }
773
+ function assertStatus(status) {
774
+ if (!STATUSES.has(status))
775
+ throw new Error(`Unknown status: ${status}`);
776
+ return status;
777
+ }
778
+ function assertResolveStatus(status) {
779
+ const normalized = assertStatus(status);
780
+ if (!RESOLVE_STATUSES.has(normalized)) {
781
+ throw new Error(`resolve currently supports --status resolved`);
782
+ }
783
+ return normalized;
784
+ }
785
+ function makeId(date) {
786
+ return `shf_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
787
+ }
788
+ function makePlanId(date) {
789
+ return `plan_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
790
+ }
791
+ function makePurgePlanId(date) {
792
+ return `purge_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
793
+ }
794
+ function cleanupPlanPath(ledgerPath, planId) {
795
+ return join(dirname(ledgerPath), "plans", `${planId}.json`);
796
+ }
797
+ function trashPurgePlanPath(ledgerPath, purgePlanId) {
798
+ assertSafeGeneratedId(purgePlanId, "trash purge plan id");
799
+ return join(dirname(ledgerPath), "purge-plans", `${purgePlanId}.json`);
800
+ }
801
+ function receiptPathFor(ledgerPath, planId) {
802
+ return join(dirname(ledgerPath), "receipts", `${planId}.json`);
803
+ }
804
+ function trashPurgeReceiptPath(ledgerPath, purgePlanId) {
805
+ assertSafeGeneratedId(purgePlanId, "trash purge plan id");
806
+ return join(dirname(ledgerPath), "purge-receipts", `${purgePlanId}.json`);
807
+ }
808
+ function isPathWithin(parentPath, childPath) {
809
+ const fromParent = relative(resolve(parentPath), resolve(childPath));
810
+ return fromParent === "" || (!fromParent.startsWith("..") && !isAbsolute(fromParent));
811
+ }
812
+ function isStrictPathWithin(parentPath, childPath) {
813
+ const fromParent = relative(resolve(parentPath), resolve(childPath));
814
+ return fromParent !== "" && !fromParent.startsWith("..") && !isAbsolute(fromParent);
815
+ }
816
+ function resolvesOutsideLedgerTrash(ledgerDir, trashRoot, expectedPlanTrashRoot, targetPath) {
817
+ const realLedgerDir = realpathSync(ledgerDir);
818
+ const realTrashRoot = realpathSync(trashRoot);
819
+ const realExpectedPlanTrashRoot = realpathSync(expectedPlanTrashRoot);
820
+ const targetStats = lstatSync(targetPath);
821
+ const realTargetPath = targetStats.isSymbolicLink() ? realpathSync(dirname(targetPath)) : realpathSync(targetPath);
822
+ const targetWithinExpectedRoot = targetStats.isSymbolicLink()
823
+ ? isPathWithin(realExpectedPlanTrashRoot, realTargetPath)
824
+ : isStrictPathWithin(realExpectedPlanTrashRoot, realTargetPath);
825
+ return (!isStrictPathWithin(realLedgerDir, realTrashRoot) ||
826
+ !isStrictPathWithin(realTrashRoot, realExpectedPlanTrashRoot) ||
827
+ !targetWithinExpectedRoot);
828
+ }
829
+ function pathExistsForPurge(path) {
830
+ try {
831
+ lstatSync(path);
832
+ return true;
833
+ }
834
+ catch {
835
+ return false;
836
+ }
837
+ }
838
+ function assertSafeGeneratedId(value, label) {
839
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
840
+ throw new Error(`Invalid ${label}: ${value}`);
841
+ }
842
+ }
843
+ function writeJson(path, value) {
844
+ mkdirSync(dirname(path), { recursive: true });
845
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
846
+ }