artshelf 0.11.0 → 0.13.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,142 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ // Capture reconcile-safe provenance for an absolute artifact path. The matched root
4
+ // plus the relative path against it is what survives a `shelf` -> `artshelf` or
5
+ // `.shelf` -> `.artshelf` rename: a future reconcile can rebuild the current path
6
+ // from the current root without Artshelf watching the filesystem. This reads the
7
+ // filesystem to classify the node and fingerprint files; it never mutates anything.
8
+ export function computeProvenance(targetPath, context) {
9
+ const absolute = resolve(targetPath);
10
+ const ledgerRoot = resolveLedgerRoot(context.ledgerPath);
11
+ const repoRoot = findRepoRoot(ledgerRoot);
12
+ const node = classifyNode(absolute);
13
+ // Ledger-owned paths are the most specific root, so they win over the repo root:
14
+ // trash/, plans/, and receipts/ all live under the ledger directory.
15
+ if (isWithin(ledgerRoot, absolute)) {
16
+ return reconstructable("ledger", ledgerRoot, absolute, node);
17
+ }
18
+ if (repoRoot && isWithin(repoRoot, absolute)) {
19
+ return reconstructable("repo", repoRoot, absolute, node);
20
+ }
21
+ return {
22
+ root: "external",
23
+ rootPath: null,
24
+ relativePath: null,
25
+ basename: basename(absolute),
26
+ pathKind: node.kind,
27
+ ...(node.fingerprint ? { fingerprint: node.fingerprint } : {})
28
+ };
29
+ }
30
+ const ROOT_KINDS = new Set(["repo", "ledger", "external"]);
31
+ const NODE_KINDS = new Set(["file", "directory", "other"]);
32
+ // Validate a provenance value carried on a record. Returns a list of problems
33
+ // (empty means well-formed). This is the line between a legacy row (no provenance
34
+ // field at all, which callers skip) and a malformed one: once provenance is present
35
+ // it must conform to the PathProvenance contract, including the rule that only
36
+ // `external` roots drop the reconstruct data (rootPath/relativePath).
37
+ export function validateProvenance(provenance) {
38
+ if (typeof provenance !== "object" || provenance === null) {
39
+ return ["provenance must be an object"];
40
+ }
41
+ const value = provenance;
42
+ const problems = [];
43
+ if (typeof value.root !== "string" || !ROOT_KINDS.has(value.root)) {
44
+ problems.push(`provenance.root is invalid: ${String(value.root)}`);
45
+ }
46
+ if (typeof value.basename !== "string" || value.basename.length === 0) {
47
+ problems.push("provenance.basename must be a non-empty string");
48
+ }
49
+ if (typeof value.pathKind !== "string" || !NODE_KINDS.has(value.pathKind)) {
50
+ problems.push(`provenance.pathKind is invalid: ${String(value.pathKind)}`);
51
+ }
52
+ if (value.rootPath !== null && typeof value.rootPath !== "string") {
53
+ problems.push("provenance.rootPath must be a string or null");
54
+ }
55
+ if (value.relativePath !== null && typeof value.relativePath !== "string") {
56
+ problems.push("provenance.relativePath must be a string or null");
57
+ }
58
+ // Reconstruct-data consistency: external paths cannot be rebuilt, so they carry
59
+ // null rootPath/relativePath; repo/ledger paths must carry both to be remappable.
60
+ if (value.root === "external") {
61
+ if (value.rootPath !== null || value.relativePath !== null) {
62
+ problems.push("provenance with external root must have null rootPath and relativePath");
63
+ }
64
+ }
65
+ else if (value.root === "repo" || value.root === "ledger") {
66
+ if (typeof value.rootPath !== "string" || typeof value.relativePath !== "string") {
67
+ problems.push(`provenance with ${value.root} root requires rootPath and relativePath`);
68
+ }
69
+ }
70
+ if (value.fingerprint !== undefined) {
71
+ const fingerprint = value.fingerprint;
72
+ if (typeof fingerprint !== "object" || fingerprint === null || typeof fingerprint.byteSize !== "number") {
73
+ problems.push("provenance.fingerprint must have a numeric byteSize");
74
+ }
75
+ }
76
+ return problems;
77
+ }
78
+ // The current ledger root: the directory that owns trash/, plans/, and receipts/.
79
+ // Provenance with a `ledger` root stores paths relative to this, so a reconcile can
80
+ // re-root them under the current ledger directory after a `.shelf` -> `.artshelf` move.
81
+ export function resolveLedgerRoot(ledgerPath) {
82
+ return resolve(dirname(ledgerPath));
83
+ }
84
+ // The current repo root for a ledger, using the same resolution as capture time:
85
+ // the enclosing git checkout, or the parent of a dotted ledger directory. Returns
86
+ // null when no repo root can be determined (e.g. a user-global ledger).
87
+ export function resolveRepoRoot(ledgerPath) {
88
+ return findRepoRoot(resolveLedgerRoot(ledgerPath));
89
+ }
90
+ function reconstructable(root, rootPath, absolute, node) {
91
+ return {
92
+ root,
93
+ rootPath,
94
+ relativePath: toPosix(relative(rootPath, absolute)),
95
+ basename: basename(absolute),
96
+ pathKind: node.kind,
97
+ ...(node.fingerprint ? { fingerprint: node.fingerprint } : {})
98
+ };
99
+ }
100
+ function findRepoRoot(ledgerRoot) {
101
+ const gitRoot = findGitRoot(ledgerRoot);
102
+ if (gitRoot)
103
+ return gitRoot;
104
+ // No git checkout: a dotted ledger directory (.artshelf / .shelf) sits directly
105
+ // inside its repo/folder, so the parent is the best repo-root candidate.
106
+ if (basename(ledgerRoot).startsWith(".")) {
107
+ const parent = dirname(ledgerRoot);
108
+ return parent === ledgerRoot ? null : parent;
109
+ }
110
+ return null;
111
+ }
112
+ function findGitRoot(start) {
113
+ let current = resolve(start);
114
+ while (true) {
115
+ if (existsSync(join(current, ".git")))
116
+ return current;
117
+ const parent = dirname(current);
118
+ if (parent === current)
119
+ return null;
120
+ current = parent;
121
+ }
122
+ }
123
+ function classifyNode(absolute) {
124
+ try {
125
+ const stats = statSync(absolute);
126
+ if (stats.isFile())
127
+ return { kind: "file", fingerprint: { byteSize: stats.size } };
128
+ if (stats.isDirectory())
129
+ return { kind: "directory" };
130
+ return { kind: "other" };
131
+ }
132
+ catch {
133
+ return { kind: "other" };
134
+ }
135
+ }
136
+ function isWithin(parent, child) {
137
+ const fromParent = relative(parent, child);
138
+ return fromParent === "" || (!fromParent.startsWith("..") && !isAbsolute(fromParent));
139
+ }
140
+ function toPosix(path) {
141
+ return sep === "/" ? path : path.split(sep).join("/");
142
+ }
@@ -0,0 +1,335 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, sep } from "node:path";
4
+ import { assertSafeGeneratedId, readLedger, registerArtshelfArtifact, writeLedger } from "./ledger.js";
5
+ import { withPathLock } from "./locks.js";
6
+ import { computeProvenance, resolveLedgerRoot, resolveRepoRoot } from "./provenance.js";
7
+ import { now, toIso } from "./time.js";
8
+ const RECONCILE_CATEGORIES = new Set([
9
+ "remap",
10
+ "resolve-missing",
11
+ "resolve-stale-trash",
12
+ "registry-remap",
13
+ "blocked"
14
+ ]);
15
+ // Classify path drift in a ledger into reconcile findings (NGX-437). This is the
16
+ // read-only engine the dry-run/execute workflow builds on: it never mutates the
17
+ // ledger or the filesystem, it only reads records and probes whether recorded paths
18
+ // still exist (and whether a renamed root can reconstruct them via provenance).
19
+ // Findings are returned in ledger order so downstream JSON output is deterministic.
20
+ export function classifyReconcileFindings(ledgerPath) {
21
+ const records = readLedger(ledgerPath);
22
+ const roots = {
23
+ ledgerRoot: resolveLedgerRoot(ledgerPath),
24
+ repoRoot: resolveRepoRoot(ledgerPath)
25
+ };
26
+ const findings = [];
27
+ for (const record of records) {
28
+ const finding = classifyRecord(record, roots);
29
+ if (finding)
30
+ findings.push(finding);
31
+ }
32
+ return findings;
33
+ }
34
+ // Build the reconcile plan without persisting anything (NGX-437 dry-run preview).
35
+ // This is fully read-only: it classifies drift and returns the plan a `--dry-run`
36
+ // would create, but never writes a plan file or touches the ledger. An empty plan
37
+ // (no actionable entries) collapses to the not-created shape so callers can render
38
+ // "nothing to reconcile" the same way cleanup does.
39
+ export function previewReconcilePlan(ledgerPath) {
40
+ const plan = buildReconcilePlan(ledgerPath);
41
+ return plan.entries.length === 0 ? noCreatedReconcilePlan(plan) : plan;
42
+ }
43
+ // Create (or reuse) a reviewed reconcile plan (NGX-437 dry-run). This is the only
44
+ // part of dry-run that writes: it persists the plan JSON and registers it as an
45
+ // artshelf-owned artifact so the plan file is tracked and a later `--execute` can
46
+ // bind to an exact reviewed plan id. When an earlier plan already covers the same
47
+ // findings it is reused verbatim (stable plan id), and when nothing is actionable
48
+ // no plan artifact is created at all, keeping dry-run side-effect-free in that case.
49
+ export function createReconcilePlan(ledgerPath) {
50
+ const plan = buildReconcilePlan(ledgerPath);
51
+ if (plan.entries.length === 0)
52
+ return noCreatedReconcilePlan(plan);
53
+ const existing = matchingExistingReconcilePlan(ledgerPath, plan);
54
+ const reviewed = existing ? { ...plan, planId: existing.planId, planPath: existing.planPath } : plan;
55
+ if (!reviewed.planPath)
56
+ throw new Error("reconcile plan path was not created");
57
+ writeReconcilePlanFile(reviewed.planPath, reviewed);
58
+ registerArtshelfArtifact(ledgerPath, reviewed.planPath, {
59
+ reason: `Artshelf reconcile dry-run plan ${reviewed.planId}`,
60
+ ttl: "14d",
61
+ kind: "run-artifact",
62
+ cleanup: "trash",
63
+ labels: ["artshelf", "reconcile-plan", reviewed.planId]
64
+ });
65
+ return reviewed;
66
+ }
67
+ export function matchingReconcilePlan(ledgerPath, plan) {
68
+ return matchingExistingReconcilePlan(ledgerPath, plan);
69
+ }
70
+ // Apply a reviewed reconcile plan (NGX-437 `reconcile --execute`). This is the only
71
+ // mutating reconcile entrypoint and it is deliberately conservative:
72
+ // * It refuses up front when the plan id is missing, the plan file is absent, or the
73
+ // plan file's declared id/ledger does not match the scoped request (no fresh plan,
74
+ // no `--all`; the command layer enforces those, this binds to one exact plan id).
75
+ // * Before applying any entry it re-classifies the live ledger and only acts when the
76
+ // current finding still matches the reviewed entry, so a plan executed against a
77
+ // drifted ledger refuses the stale entries instead of mutating the wrong rows.
78
+ // Reconcile is ledger/registry housekeeping only: it rewrites paths and resolves rows
79
+ // and writes a receipt; it never creates or deletes filesystem artifacts.
80
+ export function executeReconcilePlan(ledgerPath, planId) {
81
+ if (!planId)
82
+ throw new Error("reconcile --execute requires --plan-id");
83
+ const planPath = reconcilePlanPath(ledgerPath, planId);
84
+ if (!existsSync(planPath))
85
+ throw new Error(`Reconcile plan not found: ${planId}`);
86
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
87
+ assertReconcilePlanExecutable(plan, planId, ledgerPath);
88
+ const receiptPath = reconcileReceiptPath(ledgerPath, planId);
89
+ return withPathLock(ledgerPath, () => {
90
+ const records = readLedger(ledgerPath);
91
+ const recordsById = new Map(records.map((record) => [record.id, record]));
92
+ const liveById = new Map(classifyReconcileFindings(ledgerPath).map((finding) => [finding.id, finding]));
93
+ const executedAt = toIso(now());
94
+ const audit = { reconcilePlanId: planId, reconcileReceiptPath: receiptPath, reconciledAt: executedAt };
95
+ const results = [];
96
+ for (const entry of plan.entries) {
97
+ const record = recordsById.get(entry.id);
98
+ const live = liveById.get(entry.id);
99
+ if (!record || !live || !sameReconcileTarget(live, entry)) {
100
+ results.push(skippedResult(entry));
101
+ continue;
102
+ }
103
+ const applied = applyReconcileEntry(record, entry, audit, ledgerPath);
104
+ recordsById.set(entry.id, applied);
105
+ results.push(appliedResult(entry, applied));
106
+ }
107
+ writeReconcileReceipt(receiptPath, { planId, ledgerPath, executedAt, results });
108
+ writeLedger(ledgerPath, records.map((record) => recordsById.get(record.id) ?? record));
109
+ registerArtshelfArtifact(ledgerPath, receiptPath, {
110
+ reason: `Artshelf reconcile receipt for plan ${planId}`,
111
+ ttl: "30d",
112
+ kind: "run-artifact",
113
+ cleanup: "review",
114
+ labels: ["artshelf", "reconcile-receipt", planId]
115
+ });
116
+ return { planId, receiptPath, executedAt, results };
117
+ }, "Artshelf ledger");
118
+ }
119
+ // Produce the mutated record for one applicable entry. A remap rewrites the path and
120
+ // recomputes provenance against the new location (so the row is reconcile-healthy
121
+ // afterwards) while keeping the row's status; every resolve category archives the row
122
+ // ledger-only as `resolved`. previousPath always preserves the pre-action path.
123
+ function applyReconcileEntry(record, entry, audit, ledgerPath) {
124
+ if (entry.category === "remap" && entry.proposedPath) {
125
+ return {
126
+ ...record,
127
+ path: entry.proposedPath,
128
+ provenance: computeProvenance(entry.proposedPath, { ledgerPath }),
129
+ previousPath: entry.currentPath,
130
+ ...audit,
131
+ reconcileReason: entry.reason
132
+ };
133
+ }
134
+ return {
135
+ ...record,
136
+ status: "resolved",
137
+ resolvedAt: audit.reconciledAt,
138
+ resolutionReason: entry.reason,
139
+ previousPath: entry.currentPath,
140
+ ...audit,
141
+ reconcileReason: entry.reason
142
+ };
143
+ }
144
+ function appliedResult(entry, applied) {
145
+ return {
146
+ id: entry.id,
147
+ category: entry.category,
148
+ field: entry.field,
149
+ status: applied.status === "resolved" ? "resolved" : "remapped",
150
+ previousPath: entry.currentPath,
151
+ newPath: entry.category === "remap" ? entry.proposedPath : null,
152
+ reason: entry.reason
153
+ };
154
+ }
155
+ function skippedResult(entry) {
156
+ return {
157
+ id: entry.id,
158
+ category: entry.category,
159
+ field: entry.field,
160
+ status: "skipped",
161
+ previousPath: entry.currentPath,
162
+ newPath: null,
163
+ reason: "live ledger state no longer matches the reviewed plan"
164
+ };
165
+ }
166
+ // Two findings describe the same drift only when every structural field agrees; this
167
+ // is the execute-time safety check that refuses entries whose live state has moved on.
168
+ function sameReconcileTarget(live, entry) {
169
+ return (live.category === entry.category &&
170
+ live.field === entry.field &&
171
+ live.status === entry.status &&
172
+ live.currentPath === entry.currentPath &&
173
+ live.proposedPath === entry.proposedPath);
174
+ }
175
+ // Bind a loaded reconcile plan to the request before any ledger mutation, mirroring
176
+ // cleanup's assertCleanupPlanExecutable: the plan must declare the requested id, belong
177
+ // to the executing ledger, and carry well-formed entries.
178
+ function assertReconcilePlanExecutable(plan, planId, ledgerPath) {
179
+ if (plan.planId !== planId) {
180
+ throw new Error(`Reconcile plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
181
+ }
182
+ if (plan.ledgerPath !== ledgerPath) {
183
+ throw new Error(`Reconcile plan ledger mismatch: plan was created for ${plan.ledgerPath}, executing ${ledgerPath}`);
184
+ }
185
+ if (!Array.isArray(plan.entries)) {
186
+ throw new Error(`Reconcile plan entries are malformed: ${planId}`);
187
+ }
188
+ for (const entry of plan.entries) {
189
+ if (!entry || typeof entry.id !== "string" || typeof entry.currentPath !== "string" || !RECONCILE_CATEGORIES.has(entry.category)) {
190
+ throw new Error(`Reconcile plan entries are malformed: ${planId}`);
191
+ }
192
+ }
193
+ }
194
+ function reconcileReceiptPath(ledgerPath, planId) {
195
+ assertSafeGeneratedId(planId, "reconcile plan id");
196
+ return join(dirname(ledgerPath), "reconcile-receipts", `${planId}.json`);
197
+ }
198
+ function writeReconcileReceipt(receiptPath, value) {
199
+ mkdirSync(dirname(receiptPath), { recursive: true });
200
+ writeFileSync(receiptPath, `${JSON.stringify(value, null, 2)}\n`);
201
+ }
202
+ function classifyRecord(record, roots) {
203
+ // A trashed row's original path is expected to be empty (it was moved to trash),
204
+ // so the only path that matters is the trash target.
205
+ if (record.status === "trashed")
206
+ return classifyTrashTarget(record);
207
+ // Live rows are the ones whose recorded artifact path should still exist. This
208
+ // mirrors validateLedger's "recorded path is missing" warning surface.
209
+ if (record.status === "active" || record.status === "review-required") {
210
+ return classifyActivePath(record, roots);
211
+ }
212
+ // resolved / cleanup-refused rows are terminal for reconcile purposes.
213
+ return null;
214
+ }
215
+ function classifyActivePath(record, roots) {
216
+ if (!record.path || existsSync(record.path))
217
+ return null;
218
+ const provenance = record.provenance;
219
+ const candidate = reconstructPath(provenance, roots);
220
+ if (provenance && candidate && existsSync(candidate)) {
221
+ if (isSafeMatch(provenance, candidate)) {
222
+ return finding(record, "remap", "path", record.path, candidate, `recorded path is missing; reconstructed at ${candidate}`);
223
+ }
224
+ return finding(record, "blocked", "path", record.path, null, `a candidate exists at ${candidate} but its name or fingerprint does not match the recorded artifact`);
225
+ }
226
+ return finding(record, "resolve-missing", "path", record.path, null, "recorded path is missing and no safe remap target was found");
227
+ }
228
+ function classifyTrashTarget(record) {
229
+ // Missing cleanup metadata on a trashed row is validateLedger's concern, not ours.
230
+ if (!record.targetPath || existsSync(record.targetPath))
231
+ return null;
232
+ return finding(record, "resolve-stale-trash", "targetPath", record.targetPath, null, "trashed target is missing; resolve the ledger row without touching the filesystem");
233
+ }
234
+ // Re-root a provenance-relative path under the current ledger/repo root. Only
235
+ // reconstructable roots (repo/ledger) with a stored relative path can be rebuilt;
236
+ // external paths and legacy rows without provenance return null.
237
+ function reconstructPath(provenance, roots) {
238
+ if (!provenance || provenance.relativePath === null)
239
+ return null;
240
+ if (provenance.root === "repo") {
241
+ return roots.repoRoot ? join(roots.repoRoot, fromPosix(provenance.relativePath)) : null;
242
+ }
243
+ if (provenance.root === "ledger") {
244
+ return join(roots.ledgerRoot, fromPosix(provenance.relativePath));
245
+ }
246
+ return null;
247
+ }
248
+ // A reconstructed candidate is only trusted when its basename matches and, for
249
+ // files with a captured fingerprint, its byte size matches too. Directories and
250
+ // fingerprint-less rows fall back to name plus existence as the evidence.
251
+ function isSafeMatch(provenance, candidate) {
252
+ if (basename(candidate) !== provenance.basename)
253
+ return false;
254
+ if (provenance.pathKind === "file" && provenance.fingerprint) {
255
+ try {
256
+ return statSync(candidate).size === provenance.fingerprint.byteSize;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ return true;
263
+ }
264
+ function finding(record, category, field, currentPath, proposedPath, reason) {
265
+ return { id: record.id, category, field, status: record.status, currentPath, proposedPath, reason };
266
+ }
267
+ function fromPosix(path) {
268
+ return sep === "/" ? path : path.split("/").join(sep);
269
+ }
270
+ // Split classified findings into a plan: actionable entries (everything a scoped
271
+ // `--execute` may apply) versus blocked findings (surfaced for review only). The
272
+ // plan id/path are computed up front so a dry-run can persist deterministically.
273
+ function buildReconcilePlan(ledgerPath) {
274
+ const generatedAt = now();
275
+ const findings = classifyReconcileFindings(ledgerPath);
276
+ const entries = findings.filter((finding) => finding.category !== "blocked");
277
+ const blocked = findings.filter((finding) => finding.category === "blocked");
278
+ const planId = makeReconcilePlanId(generatedAt);
279
+ return {
280
+ planId,
281
+ generatedAt: toIso(generatedAt),
282
+ ledgerPath,
283
+ entries,
284
+ blocked,
285
+ planPath: reconcilePlanPath(ledgerPath, planId)
286
+ };
287
+ }
288
+ function noCreatedReconcilePlan(plan) {
289
+ return { ...plan, planId: "not-created", planPath: null };
290
+ }
291
+ // Reuse an earlier plan whose actionable entries match this one's, so repeated
292
+ // dry-runs converge on a single stable plan id (mirrors cleanup plan reuse). Only
293
+ // the structural entry fields are fingerprinted; volatile fields (generatedAt) and
294
+ // the review-only blocked list do not affect reuse.
295
+ function matchingExistingReconcilePlan(ledgerPath, plan) {
296
+ const plansDir = join(dirname(ledgerPath), "reconcile-plans");
297
+ if (!existsSync(plansDir))
298
+ return null;
299
+ const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
300
+ for (const filename of filenames) {
301
+ const planPath = join(plansDir, filename);
302
+ try {
303
+ const candidate = JSON.parse(readFileSync(planPath, "utf8"));
304
+ if (candidate.ledgerPath !== ledgerPath)
305
+ continue;
306
+ if (reconcilePlanFingerprint(candidate) !== reconcilePlanFingerprint(plan))
307
+ continue;
308
+ return { ...candidate, planPath };
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+ function reconcilePlanFingerprint(plan) {
317
+ return JSON.stringify(plan.entries.map((entry) => ({
318
+ id: entry.id,
319
+ category: entry.category,
320
+ field: entry.field,
321
+ currentPath: entry.currentPath,
322
+ proposedPath: entry.proposedPath
323
+ })));
324
+ }
325
+ function writeReconcilePlanFile(planPath, plan) {
326
+ mkdirSync(dirname(planPath), { recursive: true });
327
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
328
+ }
329
+ function makeReconcilePlanId(date) {
330
+ return `reconcile_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
331
+ }
332
+ function reconcilePlanPath(ledgerPath, planId) {
333
+ assertSafeGeneratedId(planId, "reconcile plan id");
334
+ return join(dirname(ledgerPath), "reconcile-plans", `${planId}.json`);
335
+ }
@@ -3,12 +3,12 @@ const DOCTOR_ATTENTION_CATEGORIES = ["stale", "invalid", "warnings"];
3
3
  function doctorAttention(summary) {
4
4
  return DOCTOR_ATTENTION_CATEGORIES.filter((key) => summary[key] > 0);
5
5
  }
6
- function doctorNextAction(blockers, summary) {
6
+ function doctorNextAction(blockers, summary, registryPath) {
7
7
  if (blockers.length > 0) {
8
8
  return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
9
9
  }
10
10
  if (summary.warnings > 0) {
11
- return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf validate --all\` to inspect; nothing is auto-executed`;
11
+ return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf reconcile --dry-run --all --registry ${registryPath}\` to prepare reconcile-ready approvals, then run \`artshelf review --all --registry ${registryPath}\`; nothing is auto-executed`;
12
12
  }
13
13
  return "artshelf is healthy on this machine — cleanup safety enforced; no action needed";
14
14
  }
@@ -39,7 +39,7 @@ export function buildDoctorAgentPacket(report) {
39
39
  attention: doctorAttention(report.summary),
40
40
  blockers,
41
41
  cleanupSafety: report.cleanupSafety,
42
- nextAction: doctorNextAction(blockers, report.summary),
42
+ nextAction: doctorNextAction(blockers, report.summary, report.registryPath),
43
43
  verification: `artshelf doctor --agent --registry ${report.registryPath}`
44
44
  };
45
45
  }
@@ -7,7 +7,7 @@ const REVIEW_SAFETY = {
7
7
  noResolveRan: true,
8
8
  noDeleteRan: true
9
9
  };
10
- export function reviewNextAction(summary, scope, ledgerPath) {
10
+ export function reviewNextAction(summary, scope, ledgerPath, registryPath) {
11
11
  const broken = summary.invalid + summary.stale;
12
12
  const review = statusCommand(scope, "review", ledgerPath);
13
13
  if (broken > 0) {
@@ -18,25 +18,30 @@ export function reviewNextAction(summary, scope, ledgerPath) {
18
18
  const dryRun = scope === "all" ? "artshelf cleanup --dry-run --all" : `artshelf cleanup --dry-run${ledgerPath ? ` --ledger ${ledgerPath}` : ""}`;
19
19
  return `run \`${dryRun}\` to generate plans, then \`artshelf cleanup --execute --plan-id <id> --ledger <path>\` for each reviewed plan`;
20
20
  }
21
- if (summary.missingPath > 0) {
22
- return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
21
+ if (summary.missingPath > 0 || summary.reconcileEntries > 0 || summary.reconcileBlocked > 0) {
22
+ const reconcile = scope === "all" ? `artshelf reconcile --dry-run --all${registryPath ? ` --registry ${registryPath}` : ""}` : `artshelf reconcile --dry-run --ledger ${ledgerPath}`;
23
+ return `run \`${reconcile} --json\` and then \`${review}\` to surface reconcile-ready approvals; nothing is auto-executable`;
23
24
  }
24
25
  return "nothing to do — no broken ledgers and no due, manual-review, missing-path, or executable cleanup entries";
25
26
  }
26
27
  export function printReviewAll(results, summary, nextAction, registryPath) {
27
- const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
28
+ const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath + summary.reconcileEntries + summary.reconcileBlocked > 0;
28
29
  process.stdout.write(`${attentionGlyph(needsAttention)} artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
29
30
  process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
30
31
  printReview(results);
31
- process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
32
+ process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped} · reconcile ${summary.reconcileEntries} · blocked ${summary.reconcileBlocked}\n`);
32
33
  process.stdout.write(`next: ${nextAction}\n`);
33
34
  }
34
35
  export function printReview(results) {
35
36
  for (const result of results) {
36
37
  const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
37
- const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0;
38
+ const reconcileEntries = result.reconcile?.plan.entries.length ?? 0;
39
+ const reconcileBlocked = result.reconcile?.plan.blocked.length ?? 0;
40
+ const reconcileDrift = reconcileEntries + reconcileBlocked;
41
+ const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0 || reconcileDrift > 0;
42
+ const reconcileDetail = reconcileDrift > 0 ? `; reconcile: ${reconcileEntries} entries, ${reconcileBlocked} blocked` : "";
38
43
  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`);
39
- process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
44
+ process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped${reconcileDetail}\n`);
40
45
  process.stdout.write(`ledger: ${result.ledger.path}\n`);
41
46
  }
42
47
  }
@@ -60,7 +65,15 @@ function buildReviewDecisions(results, scope) {
60
65
  });
61
66
  continue;
62
67
  }
63
- const missingPath = due.filter((entry) => entry.dueStatus === "missing-path");
68
+ const handledReconcileIds = new Set([
69
+ ...(result.reconcile?.plan.entries.map((entry) => entry.id) ?? []),
70
+ ...(result.reconcile?.plan.blocked.map((entry) => entry.id) ?? [])
71
+ ]);
72
+ const reconcileActions = buildReconcileDecisions(result, scope);
73
+ readyForApproval.push(...reconcileActions.readyForApproval);
74
+ needsReviewFirst.push(...reconcileActions.needsReviewFirst);
75
+ blocked.push(...reconcileActions.blocked);
76
+ const missingPath = due.filter((entry) => entry.dueStatus === "missing-path" && !handledReconcileIds.has(entry.id));
64
77
  const trashSafe = due.filter((entry) => entry.dueStatus === "due" && entry.cleanup === "trash");
65
78
  const inspectItems = due.filter((entry) => entry.dueStatus === "manual-review" ||
66
79
  (entry.dueStatus === "due" && (entry.cleanup === "review" || entry.cleanup === "delete")));
@@ -103,6 +116,57 @@ function buildReviewDecisions(results, scope) {
103
116
  }
104
117
  return { readyForApproval, needsReviewFirst, blocked };
105
118
  }
119
+ function buildReconcileDecisions(result, _scope) {
120
+ if (!result.reconcile)
121
+ return { readyForApproval: [], needsReviewFirst: [], blocked: [] };
122
+ const readyForApproval = [];
123
+ const needsReviewFirst = [];
124
+ const blocked = [];
125
+ const hasReviewedPlan = Boolean(result.reconcile.reviewedPlan && result.reconcile.reviewedPlan.planId !== "not-created");
126
+ const reviewedPlanId = result.reconcile.reviewedPlan?.planId ?? null;
127
+ const byCategory = {
128
+ remap: [],
129
+ "resolve-missing": [],
130
+ "resolve-stale-trash": [],
131
+ "registry-remap": [],
132
+ blocked: []
133
+ };
134
+ for (const finding of result.reconcile.plan.entries.concat(result.reconcile.plan.blocked)) {
135
+ byCategory[finding.category].push(finding);
136
+ }
137
+ const reconcileActionCategories = ["remap", "resolve-missing", "resolve-stale-trash", "registry-remap"];
138
+ for (const category of reconcileActionCategories) {
139
+ const entries = byCategory[category];
140
+ if (entries.length === 0)
141
+ continue;
142
+ const ids = entries.map((entry) => entry.id).sort();
143
+ const label = `Review ${entries.length} reconcile ${category} finding(s) in ${result.ledger.name}`;
144
+ const reason = `recorded paths are ${category === "remap" ? "safe to remap" : "stale and require manual review before execution"}`;
145
+ const decision = {
146
+ label,
147
+ itemIds: ids,
148
+ actionType: "reconcile",
149
+ approvalTarget: hasReviewedPlan ? `approve artshelf reconcile ledger ${result.ledger.path} plan ${reviewedPlanId}` : null,
150
+ reason,
151
+ nextStep: hasReviewedPlan
152
+ ? `run \`artshelf reconcile --execute --plan-id ${reviewedPlanId} --ledger ${result.ledger.path}\``
153
+ : `run \`artshelf reconcile --dry-run --ledger ${result.ledger.path} --json\`, then approve with \`approve artshelf reconcile ledger ${result.ledger.path} plan <plan-id>\``
154
+ };
155
+ (hasReviewedPlan ? readyForApproval : needsReviewFirst).push(decision);
156
+ }
157
+ if (byCategory.blocked.length > 0) {
158
+ const entries = byCategory.blocked;
159
+ blocked.push({
160
+ label: `Review ${entries.length} blocked reconcile finding(s) in ${result.ledger.name}`,
161
+ itemIds: entries.map((entry) => entry.id).sort(),
162
+ actionType: "reconcile",
163
+ approvalTarget: null,
164
+ reason: "path drift is ambiguous or unsafe and needs manual investigation",
165
+ nextStep: `run \`artshelf reconcile --dry-run --ledger ${result.ledger.path} --json\`, then handle each item manually`
166
+ });
167
+ }
168
+ return { readyForApproval, needsReviewFirst, blocked };
169
+ }
106
170
  function reviewCounts(summary) {
107
171
  return {
108
172
  due: summary.due,
@@ -131,7 +195,7 @@ export function buildReviewAgentPacketAll(results, summary, registry) {
131
195
  needsReviewFirst: groups.needsReviewFirst,
132
196
  blocked: groups.blocked,
133
197
  safety: REVIEW_SAFETY,
134
- nextAction: reviewNextAction(summary, "all"),
198
+ nextAction: reviewNextAction(summary, "all", undefined, registry.path),
135
199
  verification: `artshelf review --all --agent --registry ${registry.path}`
136
200
  };
137
201
  }
@@ -14,7 +14,7 @@ export function statusCommand(scope, command, ledgerPath) {
14
14
  return `artshelf ${command} --all`;
15
15
  return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
16
16
  }
17
- function statusNextAction(blockers, counts, scope, ledgerPath) {
17
+ function statusNextAction(blockers, counts, scope, ledgerPath, registryPath) {
18
18
  if (blockers.length > 0) {
19
19
  const verify = statusCommand(scope, "status", ledgerPath);
20
20
  return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
@@ -27,7 +27,8 @@ function statusNextAction(blockers, counts, scope, ledgerPath) {
27
27
  return `run \`${review}\` to inspect manual-review records; nothing is auto-executed`;
28
28
  }
29
29
  if (counts.missingPath > 0) {
30
- return "inspect missing-path records and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
30
+ const reconcile = scope === "all" ? `artshelf reconcile --dry-run --all${registryPath ? ` --registry ${registryPath}` : ""}` : `artshelf reconcile --dry-run --ledger ${ledgerPath}`;
31
+ return `run \`${reconcile} --json\` and then \`${review}\` to surface reconcile-ready approvals; nothing is auto-executable`;
31
32
  }
32
33
  return "nothing due — no broken ledgers and no due, manual-review, missing-path, or pending cleanup entries";
33
34
  }
@@ -58,7 +59,7 @@ export function buildStatusAgentPacketAll(report) {
58
59
  counts,
59
60
  attention: statusAttention(counts),
60
61
  blockers,
61
- nextAction: statusNextAction(blockers, counts, "all"),
62
+ nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath),
62
63
  verification: `artshelf status --all --agent --registry ${report.registryPath}`
63
64
  };
64
65
  }