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.
package/CHANGELOG.md CHANGED
@@ -91,6 +91,58 @@
91
91
  entries must be well-formed, so mismatched or malformed plans are refused before
92
92
  moving files or writing a receipt — the plan-id-bound posture trash purge
93
93
  already enforces.
94
+ - Added path provenance to new ledger records: each record now captures a
95
+ `provenance` block (root class, root-relative path, basename, path kind, and an
96
+ optional byte-size fingerprint) so a later reconcile can rebuild a moved
97
+ artifact's path after a root rename without a daemon, watcher, or shell hook.
98
+ Provenance is additive and backward compatible — records written before it
99
+ simply omit the field and still validate, read, list, find, and get as legacy
100
+ rows, while `validate` reports a malformed provenance block only when the field
101
+ is present.
102
+ - Added the approval-gated `artshelf reconcile` command for ledger/registry
103
+ housekeeping that never creates, moves, or deletes files. `--dry-run`
104
+ classifies recorded-path drift into a reviewed plan (`remap`, `resolve-missing`,
105
+ `resolve-stale-trash`, or `blocked`), writing and registering an Artshelf-owned
106
+ plan only when actionable entries exist and reusing a matching plan id
107
+ otherwise, and `--all` previews every registered ledger as dry-run only.
108
+ `--execute` applies exactly one reviewed `--plan-id` against one explicit
109
+ `--ledger`, refuses missing, unknown, or mismatched plans and entries whose live
110
+ state drifted since review, stamps the reconcile audit trail (`previousPath`,
111
+ `reconcilePlanId`, `reconcileReceiptPath`, `reconciledAt`, `reconcileReason`) on
112
+ every touched row, and writes an Artshelf-owned reconcile receipt.
113
+ - Integrated reconcile findings into `review --agent`, `status --agent`, and
114
+ `doctor --agent` triage: missing-path warnings now route to reconcile dry-run
115
+ guidance before approval, reconciled plans escalate to ready-for-approval, and
116
+ the `ArtshelfReviewReport` schema adds the `reconcile` action type (NGX-438).
117
+ - Moved `artshelf put` registry-warning output from stdout to stderr in human
118
+ mode; `--json` output is unchanged (NGX-429).
119
+
120
+ ## [0.13.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.12.0...artshelf-v0.13.0) (2026-06-15)
121
+
122
+
123
+ ### Features
124
+
125
+ * **review:** integrate reconcile findings into agent review packets ([878785e](https://github.com/calvinnwq/artshelf/commit/878785e72c4e65bd8e09572525b05cc020d2f1e1))
126
+ * **review:** integrate reconcile findings into agent triage; move put registry-warning to stderr (NGX-438, NGX-429) ([2573470](https://github.com/calvinnwq/artshelf/commit/25734701b439f617a33609ac98c3fae895199640))
127
+
128
+
129
+ ### Bug Fixes
130
+
131
+ * **review:** include reconcile counts in all-ledger triage ([2eeb2fe](https://github.com/calvinnwq/artshelf/commit/2eeb2fea6eae58bfd652be959f2bb6e28d7cb90f))
132
+ * **review:** keep reconcile approval schema and blocked triage consistent ([0c8925a](https://github.com/calvinnwq/artshelf/commit/0c8925a851023622796f2b8d847fcc89cab3c5f0))
133
+
134
+ ## [0.12.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.11.0...artshelf-v0.12.0) (2026-06-15)
135
+
136
+
137
+ ### Features
138
+
139
+ * **ledger:** add path-provenance foundation for NGX-436 ([f0bf797](https://github.com/calvinnwq/artshelf/commit/f0bf797223e5032e326842cc1dd8fcb47130ed3e))
140
+ * **ledger:** add provenance validation distinguishing legacy from malformed rows (NGX-436) ([ce5128a](https://github.com/calvinnwq/artshelf/commit/ce5128a85bcc6353f73c3b47bd9a433918236ee0))
141
+ * **reconcile:** add path-provenance foundation and approval-gated reconcile command ([ad4bcec](https://github.com/calvinnwq/artshelf/commit/ad4bcec7839e004a0f7ca3cc9a8ecebb0caaac0f))
142
+ * **reconcile:** add read-only classification engine for NGX-437 ([3245738](https://github.com/calvinnwq/artshelf/commit/3245738c8d7b3e3dfd95c49fae414596b35c22e1))
143
+ * **reconcile:** add reconcile dry-run plan layer for NGX-437 ([ddc8881](https://github.com/calvinnwq/artshelf/commit/ddc8881713d26f1e43575b3097e49558c22cc2e7))
144
+ * **reconcile:** add reconcile execute layer with audit trail and stale-state refusals (NGX-437) ([50a12d4](https://github.com/calvinnwq/artshelf/commit/50a12d49cdf0552b675bfaa75a0220c886bd64e5))
145
+ * **reconcile:** wire reconcile CLI command with integration tests (NGX-437) ([0ea033b](https://github.com/calvinnwq/artshelf/commit/0ea033b73e96910de87a832e5ada835bc603ff12))
94
146
 
95
147
  ## [0.11.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.2...artshelf-v0.11.0) (2026-06-14)
96
148
 
package/README.md CHANGED
@@ -141,6 +141,8 @@ artshelf doctor
141
141
  artshelf update [--json]
142
142
  artshelf cleanup --dry-run [--all]
143
143
  artshelf cleanup --execute --plan-id <id> [--ledger <path>] [--json]
144
+ artshelf reconcile --dry-run [--all] [--ledger <path>] [--json]
145
+ artshelf reconcile --execute --plan-id <id> --ledger <path> [--json]
144
146
  artshelf trash list [--all] [--ledger <path>] [--json]
145
147
  artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
146
148
  artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
package/SPEC.md CHANGED
@@ -99,8 +99,9 @@ Defaults:
99
99
  `put` should refuse to record a path that does not exist unless a future flag
100
100
  explicitly supports planned artifacts. After appending the record, `put`
101
101
  registers the ledger in the ledger registry. Registry registration is
102
- best-effort: if it fails, the record remains appended and output includes a
103
- registry warning or `registryError`.
102
+ best-effort: if it fails, the record remains appended and a registry warning is
103
+ printed to stderr in human mode, or surfaced as a `registryError` field in
104
+ `--json` output, so stdout stays machine-clean.
104
105
 
105
106
  ### `artshelf ledgers`
106
107
 
@@ -253,10 +254,13 @@ included with a `not-created` plan instead of writing a plan file.
253
254
 
254
255
  In `--all` mode, review emits an aggregate triage summary on top of the
255
256
  per-ledger detail. JSON includes a `summary` block with affected-ledger, due,
256
- manual-review, missing-path, executable, and skipped counts plus the preview
257
- plan ids; JSON also includes the next safe action. Human output adds a one-line
258
- triage count and states the same next safe action (repair broken ledgers, dry-run
259
- cleanup, inspect missing paths, or nothing to do). Review never writes a plan, so
257
+ manual-review, missing-path, executable, skipped, and reconcile entry/blocked
258
+ counts plus the preview plan ids; JSON also includes the next safe action. The
259
+ per-ledger human detail appends a `reconcile` count when a ledger has reconcile
260
+ drift. Human output adds a one-line triage count with the same reconcile counts
261
+ and states the same next safe action (repair broken ledgers, dry-run cleanup,
262
+ dry-run reconcile for missing-path or reconcile drift, or nothing to do). Review
263
+ never writes a plan, so
260
264
  the next action always points at an explicit follow-up command.
261
265
 
262
266
  `review`, `status`, and `doctor` share three render modes. The default human
@@ -265,9 +269,13 @@ stays the full, backward-compatible public audit report; and `--agent` emits a c
265
269
  deterministic single-line JSON decision packet for agents, taking precedence over
266
270
  `--json` when both are passed. For `review`, the packet sorts records into
267
271
  ready-for-approval, needs-review-first, and blocked groups. Because review is
268
- read-only and never mints a cleanup plan, the only exact approval target it emits
269
- is `resolve missing`; cleanup-eligible records stay needs-review-first and point
270
- at `cleanup --dry-run`, which mints the reviewed plan id to approve.
272
+ read-only and never mints a cleanup plan, the exact approval targets it emits are
273
+ `resolve missing` and `reconcile`; the `reconcile` target appears only when a
274
+ prior reviewed reconcile plan still matches the live drift. Cleanup-eligible
275
+ records and reconcile drift without a reviewed plan stay needs-review-first and
276
+ point at `cleanup --dry-run` or `reconcile --dry-run`, which mint the reviewed
277
+ plan id to approve. Blocked or ambiguous reconcile findings surface in the
278
+ blocked group with no approval target.
271
279
 
272
280
  ### `artshelf doctor`
273
281
 
@@ -526,6 +534,76 @@ Rules:
526
534
  - Keeps the record visible through `list` and `list --status resolved`.
527
535
  - Refuses records that are already `resolved`; the original reason is preserved.
528
536
 
537
+ ### `artshelf reconcile`
538
+
539
+ Approval-gated ledger/registry housekeeping that turns recorded-path drift into a
540
+ reviewed plan and then applies exactly one reviewed plan id. Reconcile is **not**
541
+ cleanup: it never creates, moves, or deletes files. It only rewrites drifted ledger
542
+ paths and resolves rows that can no longer be acted on, mirroring the cleanup
543
+ dry-run/execute boundary.
544
+
545
+ ```bash
546
+ artshelf reconcile --dry-run [--ledger <path>] [--json]
547
+ artshelf reconcile --dry-run --all [--registry <path>] [--json]
548
+ artshelf reconcile --execute --plan-id <id> --ledger <path> [--json]
549
+ ```
550
+
551
+ Dry-run classifies each drifted record into one finding category:
552
+
553
+ - `remap`: the recorded path is gone, but provenance reconstructs the artifact under
554
+ the current ledger/repo root (for example after a `shelf` -> `artshelf` or
555
+ `.shelf` -> `.artshelf` rename) and the basename plus optional file fingerprint
556
+ still match. The path can be safely rewritten to the reconstructed location.
557
+ - `resolve-missing`: an `active` or `review-required` record's path is gone and no
558
+ safe remap target was found (external path, legacy row, or nothing matches). The
559
+ row can be resolved after review.
560
+ - `resolve-stale-trash`: an already-`trashed` record's trash target is gone. The
561
+ ledger row is resolved ledger-only; the filesystem is never touched.
562
+ - `blocked`: a candidate exists at the reconstructed location but its name or
563
+ fingerprint does not match, or evidence is otherwise ambiguous or unsafe. Blocked
564
+ findings are surfaced for review and never auto-applied.
565
+
566
+ `registry-remap` is reserved in the finding taxonomy for a future registry pass that
567
+ updates a registered ledger whose path moved; the current dry-run classifies drift
568
+ within a single ledger's records and does not yet emit `registry-remap`.
569
+
570
+ Dry-run rules:
571
+
572
+ - Read-only except for reviewed plan artifact creation/reuse. It classifies drift
573
+ and, when actionable entries exist, persists the plan to
574
+ `<ledger-dir>/reconcile-plans/<id>.json` and registers an Artshelf-owned plan
575
+ record (`owner=artshelf`, `kind=run-artifact`, `ttl=14d`, `cleanup=trash`, labels
576
+ including `artshelf`, `reconcile-plan`, and the plan id).
577
+ - A no-op dry-run (only blocked or no findings) reports `planId=not-created`,
578
+ `planPath=null`, and writes no plan file. A later dry-run whose actionable entries
579
+ match an existing plan reuses that plan id and refreshes its plan artifact.
580
+ - `--all` is dry-run only and previews every registered ledger after the registry
581
+ validates. There is no global execute.
582
+
583
+ Execute rules:
584
+
585
+ - Requires `--plan-id` and one explicit `--ledger`. It binds to one reviewed plan id
586
+ and refuses a missing, unknown, or id/ledger-mismatched plan before any mutation.
587
+ There is no `reconcile --execute --all` and no fresh-plan-then-execute.
588
+ - Before applying each entry it re-classifies the live ledger and refuses entries
589
+ whose live state has drifted since review (record gone, status changed, remap
590
+ target vanished, or path reappeared), skipping them instead of mutating stale rows.
591
+ - A `remap` rewrites the record `path` and recomputes its provenance for the new
592
+ location while keeping the row's status; every resolve category archives the row
593
+ ledger-only as `resolved`.
594
+ - Preserves audit provenance on every touched row (`previousPath`, the rewritten
595
+ `path` for a remap, `reconcilePlanId`, `reconcileReceiptPath`, `reconciledAt`, and
596
+ `reconcileReason`), and writes a reconcile receipt to
597
+ `<ledger-dir>/reconcile-receipts/<id>.json` registered as an Artshelf-owned
598
+ artifact (`ttl=30d`, `cleanup=review`, labels including `artshelf`,
599
+ `reconcile-receipt`, and the plan id).
600
+ - Never creates or deletes filesystem artifacts. Reconcile is ledger/registry
601
+ bookkeeping only, and `doctor`, `status`, `review`, and `validate` never perform
602
+ silent reconcile edits.
603
+
604
+ JSON output is deterministic (findings preserve ledger order) so agents can render a
605
+ decision packet and approve a specific plan id.
606
+
529
607
  ## Ledger Storage
530
608
 
531
609
  V1 supports two scopes:
@@ -659,6 +737,69 @@ the purge provenance:
659
737
  }
660
738
  ```
661
739
 
740
+ Records touched by `artshelf reconcile --execute` carry the reconcile audit trail so a
741
+ remap or resolve stays traceable to the reviewed plan that produced it:
742
+
743
+ ```json
744
+ {
745
+ "previousPath": "/old-absolute/path/build/out.txt",
746
+ "reconcilePlanId": "reconcile_20260601_062000_ab12",
747
+ "reconcileReceiptPath": "/absolute/path/.artshelf/reconcile-receipts/reconcile_20260601_062000_ab12.json",
748
+ "reconciledAt": "2026-06-01T06:20:00Z",
749
+ "reconcileReason": "recorded path is missing; reconstructed at the current root"
750
+ }
751
+ ```
752
+
753
+ `previousPath` preserves the path the row held before the action; for a `remap` the new
754
+ location is the rewritten `path`, while resolve categories leave `path` and set
755
+ `status=resolved`. These fields are additive and absent on records reconcile never
756
+ touched.
757
+
758
+ ### Path provenance
759
+
760
+ New records carry a `provenance` block alongside the absolute `path`. The absolute
761
+ path is still the audit record of where the artifact lived; provenance adds the data
762
+ a future reconcile needs to reason about an artifact that moved because its root was
763
+ renamed (for example `shelf` -> `artshelf` or `.shelf` -> `.artshelf`). Capturing it
764
+ at write time is what lets reconcile remap paths later **without** Artshelf running as
765
+ a daemon, watcher, or shell hook.
766
+
767
+ ```json
768
+ {
769
+ "provenance": {
770
+ "root": "repo",
771
+ "rootPath": "/absolute/path/to/repo",
772
+ "relativePath": "build/out.txt",
773
+ "basename": "out.txt",
774
+ "pathKind": "file",
775
+ "fingerprint": { "byteSize": 1024 }
776
+ }
777
+ }
778
+ ```
779
+
780
+ - `root` is `repo`, `ledger`, or `external`. Ledger-owned paths (`trash/`, `plans/`,
781
+ `receipts/`) classify as `ledger`; other paths inside the repo classify as `repo`;
782
+ anything else is `external`.
783
+ - `rootPath` and `relativePath` are the matched root and the POSIX path beneath it.
784
+ The relative path is what survives a root rename, so a reconcile can rebuild the
785
+ current absolute path from the current root. `external` paths cannot be rebuilt, so
786
+ both fields are `null`.
787
+ - `basename`, `pathKind`, and the optional file `fingerprint` (byte size only) are
788
+ cheap matching hints for disambiguating rename candidates.
789
+
790
+ Provenance is additive and backward compatible. Records written before provenance
791
+ existed simply omit the field; they are treated as **legacy records with missing
792
+ provenance, not malformed data**, and continue to validate, read, list, find, and get
793
+ normally. `artshelf validate` only inspects provenance when the field is present: a
794
+ present-but-structurally-invalid block (bad `root`, missing reconstruct data on a
795
+ `repo`/`ledger` root, reconstruct data on an `external` root, non-numeric fingerprint)
796
+ is reported as an error, while an absent block is not.
797
+
798
+ Provenance only records evidence. It never moves, deletes, or rewrites artifacts, and
799
+ capturing it does not change any path. Acting on provenance to remap a ledger remains
800
+ an explicit, approval-gated reconcile step — never an automatic side effect of `put`,
801
+ `doctor`, `status`, `review`, or `validate`.
802
+
662
803
  ## Cleanup Safety Model
663
804
 
664
805
  Cleanup execution is intentionally boring and approval-only. Five boundaries
@@ -820,6 +961,17 @@ human review.
820
961
  - CLI can list trashed records (single ledger or `--all`) and purge them through
821
962
  an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
822
963
  receipt; purge refuses `--all` and never deletes without a reviewed plan id.
964
+ - New records capture path provenance (root class, root-relative path, basename,
965
+ path kind, and an optional byte-size fingerprint); provenance is additive and
966
+ backward compatible, so legacy records without it still validate and read, and
967
+ `validate` reports a malformed provenance block only when the field is present.
968
+ - CLI can reconcile drifted recorded paths through `artshelf reconcile` without
969
+ ever creating, moving, or deleting files: `--dry-run` classifies drift into a
970
+ reviewed plan (`remap`, `resolve-missing`, `resolve-stale-trash`, `blocked`) and
971
+ `--all` previews every registered ledger as dry-run only, while `--execute`
972
+ applies one reviewed plan id against one explicit ledger, refuses `--all`,
973
+ mismatched plans, and entries whose live state drifted since review, and writes
974
+ the reconcile audit trail and receipt.
823
975
  - Package includes the deterministic `ArtshelfReviewReport` schema, canonical
824
976
  example, and portable renderer script for agent-rendered review reports.
825
977
  - All core commands support `--json`.
@@ -828,7 +980,8 @@ human review.
828
980
  - Tests cover record/list/find/get/status-filter/due/validate/resolve/registry,
829
981
  `artshelf doctor`, the `artshelf status` dashboard, `--all` review, stale-registry,
830
982
  dry-run, global-dry-run, execute-plan, cleanup plan-id validation, concurrent
831
- ledger writes, and trash list/purge behavior.
983
+ ledger writes, trash list/purge, path provenance validation, and reconcile
984
+ dry-run/execute behavior.
832
985
 
833
986
  ## Deferred
834
987
 
@@ -8,6 +8,7 @@ import { handleGet } from "./get.js";
8
8
  import { handleLedgers } from "./ledgers.js";
9
9
  import { handleList } from "./list.js";
10
10
  import { handlePut } from "./put.js";
11
+ import { handleReconcile } from "./reconcile.js";
11
12
  import { handleResolve } from "./resolve.js";
12
13
  import { handleReview } from "./review.js";
13
14
  import { handleStatus } from "./status.js";
@@ -43,6 +44,9 @@ export async function runCommand(parsed) {
43
44
  case "cleanup":
44
45
  status = handleCleanup(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
45
46
  break;
47
+ case "reconcile":
48
+ status = handleReconcile(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
49
+ break;
46
50
  case "trash":
47
51
  status = handleTrash(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
48
52
  break;
@@ -16,7 +16,7 @@ export function handlePut(parsed, ledgerPath, json) {
16
16
  cleanup: stringFlag(parsed, "cleanup"),
17
17
  owner: stringFlag(parsed, "owner"),
18
18
  labels: arrayFlag(parsed, "label")
19
- });
19
+ }, ledgerPath);
20
20
  const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
21
21
  appendPreparedRecord(ledgerPath, record);
22
22
  let ledger;
@@ -31,6 +31,6 @@ export function handlePut(parsed, ledgerPath, json) {
31
31
  return printJson({ ok: true, record, ledgerPath, registryPath, ...(ledger ? { ledger } : {}), ...(registryError ? { registryError } : {}) });
32
32
  process.stdout.write(`recorded ${record.id}\npath: ${record.path}\nretains until: ${record.retainUntil ?? "manual review"}\nledger: ${ledgerPath}\n`);
33
33
  if (registryError)
34
- process.stdout.write(`registry warning: ${registryError}\n`);
34
+ process.stderr.write(`registry warning: ${registryError}\n`);
35
35
  return 0;
36
36
  }
@@ -0,0 +1,48 @@
1
+ import { createReconcilePlan, executeReconcilePlan } from "../reconcile.js";
2
+ import { normalizeRegistryPath } from "../registry.js";
3
+ import { printJson } from "../renderers/json.js";
4
+ import { boolFlag, requiredStringFlag, stringFlag } from "../shared/flags.js";
5
+ import { printReconcilePlan, printReconcilePlans, printRegisteredLedgerValidation, validateRegisteredLedgersOrThrow } from "./shared.js";
6
+ // `artshelf reconcile` (NGX-437): approval-gated ledger/registry housekeeping that
7
+ // converts path drift into a reviewed plan, then applies one reviewed plan id. This
8
+ // command layer enforces the safety envelope around the reconcile domain functions:
9
+ // dry-run and execute are mutually exclusive, `--all` is dry-run only (no global
10
+ // execute), and execute always binds to one explicit `--ledger` plus `--plan-id`.
11
+ // Reconcile never touches the filesystem; it is ledger bookkeeping, not cleanup.
12
+ export function handleReconcile(parsed, ledgerPath, json) {
13
+ const dryRun = boolFlag(parsed, "dry-run");
14
+ const execute = boolFlag(parsed, "execute");
15
+ if (dryRun && execute)
16
+ throw new Error("reconcile accepts either --dry-run or --execute, not both");
17
+ if (boolFlag(parsed, "all") && execute) {
18
+ throw new Error("reconcile --all is dry-run only; execute requires an explicit --ledger and reviewed --plan-id");
19
+ }
20
+ if (dryRun) {
21
+ if (boolFlag(parsed, "all")) {
22
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
23
+ const { ok, results } = validateRegisteredLedgersOrThrow(registryPath);
24
+ if (!ok)
25
+ return printRegisteredLedgerValidation(registryPath, results, json);
26
+ const plans = results.map(({ ledger }) => ({ ledger, plan: createReconcilePlan(ledger.path) }));
27
+ if (json)
28
+ return printJson({ ok: true, registryPath, plans });
29
+ printReconcilePlans(plans);
30
+ process.stdout.write(`registry: ${registryPath}\n`);
31
+ return 0;
32
+ }
33
+ const plan = createReconcilePlan(ledgerPath);
34
+ if (json)
35
+ return printJson({ ok: true, plan });
36
+ printReconcilePlan(plan, ledgerPath);
37
+ return 0;
38
+ }
39
+ if (execute) {
40
+ const planId = requiredStringFlag(parsed, "plan-id");
41
+ const receipt = executeReconcilePlan(ledgerPath, planId);
42
+ if (json)
43
+ return printJson({ ok: true, receipt });
44
+ process.stdout.write(`receipt ${receipt.planId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
45
+ return 0;
46
+ }
47
+ throw new Error("reconcile requires --dry-run or --execute");
48
+ }
@@ -15,7 +15,7 @@ export function handleReview(parsed, ledgerPath, json) {
15
15
  printCompactJson(buildReviewAgentPacketAll(results, summary, { path: registryPath, exists: existsSync(registryPath) }));
16
16
  return ok ? 0 : 1;
17
17
  }
18
- const nextAction = reviewNextAction(summary, "all");
18
+ const nextAction = reviewNextAction(summary, "all", undefined, registryPath);
19
19
  if (json) {
20
20
  printJson({ ok, registryPath, summary, nextAction, ledgers: results.map(reviewJsonResult) });
21
21
  return ok ? 0 : 1;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dueEntries, previewCleanupPlan, readLedger, validateLedger } from "../ledger.js";
3
3
  import { listRegisteredLedgers } from "../registry.js";
4
+ import { matchingReconcilePlan, previewReconcilePlan } from "../reconcile.js";
4
5
  import { printJson } from "../renderers/json.js";
5
6
  export function registeredLedgersOrThrow(registryPath) {
6
7
  const ledgers = listRegisteredLedgers(registryPath);
@@ -43,15 +44,19 @@ export function reviewLedger(ledger, registered = true) {
43
44
  ledgerExists,
44
45
  validate,
45
46
  due: [],
46
- plan: emptyReviewPlan(ledger.path)
47
+ plan: emptyReviewPlan(ledger.path),
48
+ reconcile: null
47
49
  };
48
50
  }
51
+ const reconcilePlan = previewReconcilePlan(ledger.path);
52
+ const reviewedReconcilePlan = reconcilePlan.entries.length > 0 || reconcilePlan.blocked.length > 0 ? matchingReconcilePlan(ledger.path, reconcilePlan) : null;
49
53
  return {
50
54
  ledger,
51
55
  ledgerExists,
52
56
  validate,
53
57
  due: dueEntries(readLedger(ledger.path)),
54
- plan: previewCleanupPlan(ledger.path)
58
+ plan: previewCleanupPlan(ledger.path),
59
+ reconcile: { plan: reconcilePlan, reviewedPlan: reviewedReconcilePlan }
55
60
  };
56
61
  }
57
62
  export function reviewJsonResult(result) {
@@ -103,6 +108,23 @@ export function printPlan(plan, ledgerPath) {
103
108
  process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
104
109
  process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
105
110
  }
111
+ export function printReconcilePlan(plan, ledgerPath) {
112
+ process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.blocked.length} blocked\n`);
113
+ for (const entry of plan.entries) {
114
+ const target = entry.proposedPath ? `${entry.currentPath} -> ${entry.proposedPath}` : entry.currentPath;
115
+ process.stdout.write(`${entry.category} ${entry.id} ${entry.field} ${target} :: ${entry.reason}\n`);
116
+ }
117
+ for (const blocked of plan.blocked) {
118
+ process.stdout.write(`blocked ${blocked.id} ${blocked.field} ${blocked.currentPath} :: ${blocked.reason}\n`);
119
+ }
120
+ process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
121
+ }
122
+ export function printReconcilePlans(results) {
123
+ for (const result of results) {
124
+ process.stdout.write(`plan ${result.plan.planId} [${result.ledger.name}]: ${result.plan.entries.length} entries, ${result.plan.blocked.length} blocked\n`);
125
+ process.stdout.write(`plan: ${result.plan.planPath ?? "not created"}\nledger: ${result.ledger.path}\n`);
126
+ }
127
+ }
106
128
  export function printTrashListEntries(results) {
107
129
  const total = results.reduce((count, result) => count + result.entries.length, 0);
108
130
  if (total === 0) {
@@ -130,6 +152,8 @@ export function summarizeReview(results) {
130
152
  missingPath: 0,
131
153
  executable: 0,
132
154
  skipped: 0,
155
+ reconcileEntries: 0,
156
+ reconcileBlocked: 0,
133
157
  previewPlanIds: []
134
158
  };
135
159
  for (const result of results) {
@@ -145,14 +169,18 @@ export function summarizeReview(results) {
145
169
  const due = result.due.filter((entry) => entry.dueStatus === "due").length;
146
170
  const manualReview = result.due.filter((entry) => entry.dueStatus === "manual-review").length;
147
171
  const missingPath = result.due.filter((entry) => entry.dueStatus === "missing-path").length;
172
+ const reconcileEntries = result.reconcile?.plan.entries.length ?? 0;
173
+ const reconcileBlocked = result.reconcile?.plan.blocked.length ?? 0;
148
174
  summary.due += due;
149
175
  summary.manualReview += manualReview;
150
176
  summary.missingPath += missingPath;
151
177
  summary.executable += result.plan.entries.length;
152
178
  summary.skipped += result.plan.skipped.length;
179
+ summary.reconcileEntries += reconcileEntries;
180
+ summary.reconcileBlocked += reconcileBlocked;
153
181
  if (result.plan.planId !== "not-created")
154
182
  summary.previewPlanIds.push(result.plan.planId);
155
- if (!result.validate.ok || due + manualReview + missingPath > 0 || result.plan.entries.length > 0) {
183
+ if (!result.validate.ok || due + manualReview + missingPath + reconcileEntries + reconcileBlocked > 0 || result.plan.entries.length > 0) {
156
184
  summary.affected += 1;
157
185
  }
158
186
  }
@@ -3,6 +3,7 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSy
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { withPathLock } from "./locks.js";
6
+ import { computeProvenance, validateProvenance } from "./provenance.js";
6
7
  import { addTtl, assertIsoDate, ageOf, now, ttlToMs, toIso } from "./time.js";
7
8
  const KINDS = new Set([
8
9
  "scratch",
@@ -26,11 +27,11 @@ export function normalizeLedgerPath(path) {
26
27
  return resolve(path ?? defaultLedgerPath());
27
28
  }
28
29
  export function putRecord(ledgerPath, input) {
29
- const record = prepareRecord(input);
30
+ const record = prepareRecord(input, ledgerPath);
30
31
  appendPreparedRecord(ledgerPath, record);
31
32
  return record;
32
33
  }
33
- export function prepareRecord(input) {
34
+ export function prepareRecord(input, ledgerPath) {
34
35
  const artifactPath = resolve(input.path);
35
36
  if (!existsSync(artifactPath)) {
36
37
  throw new Error(`Path does not exist: ${input.path}`);
@@ -57,7 +58,8 @@ export function prepareRecord(input) {
57
58
  cleanup,
58
59
  owner: input.owner ?? "manual",
59
60
  labels: input.labels,
60
- status: "active"
61
+ status: "active",
62
+ provenance: computeProvenance(artifactPath, { ledgerPath })
61
63
  };
62
64
  return record;
63
65
  }
@@ -239,6 +241,13 @@ export function validateLedger(ledgerPath) {
239
241
  if (!record.resolutionReason)
240
242
  errors.push(`${label}: resolved record missing resolutionReason`);
241
243
  }
244
+ // Legacy rows simply omit provenance and are left alone; once a row carries
245
+ // provenance it must be well-formed so future reconcile can trust it.
246
+ if ("provenance" in record) {
247
+ for (const problem of validateProvenance(record.provenance)) {
248
+ errors.push(`${label}: ${problem}`);
249
+ }
250
+ }
242
251
  }
243
252
  return { ok: errors.length === 0, errors, warnings, entries: records.length };
244
253
  }
@@ -589,7 +598,10 @@ export function executeCleanupPlan(ledgerPath, planId) {
589
598
  return { planId, receiptPath, results };
590
599
  });
591
600
  }
592
- function registerArtshelfArtifact(ledgerPath, path, input) {
601
+ // Exported so the reconcile plan layer (src/reconcile.ts) registers its dry-run plan
602
+ // artifacts through the same upsert-by-path-and-labels path that cleanup plans use,
603
+ // keeping plan files tracked and reused under a stable plan id.
604
+ export function registerArtshelfArtifact(ledgerPath, path, input) {
593
605
  const prepared = prepareRecord({
594
606
  path,
595
607
  reason: input.reason,
@@ -598,7 +610,7 @@ function registerArtshelfArtifact(ledgerPath, path, input) {
598
610
  cleanup: input.cleanup,
599
611
  owner: "artshelf",
600
612
  labels: input.labels
601
- });
613
+ }, ledgerPath);
602
614
  withLedgerLock(ledgerPath, () => {
603
615
  const records = readLedger(ledgerPath);
604
616
  const index = records.findIndex((record) => (isMatchingArtshelfArtifact(record, path, input.labels) &&
@@ -684,7 +696,10 @@ function appendRecord(ledgerPath, record) {
684
696
  atomicWriteFileSync(ledgerPath, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${JSON.stringify(record)}\n`);
685
697
  });
686
698
  }
687
- function writeLedger(ledgerPath, records) {
699
+ // Exported so the reconcile execute layer (src/reconcile.ts) persists its mutated
700
+ // records through the canonical JSONL writer + ledger lock instead of duplicating the
701
+ // atomic-write format, keeping the reconcile -> ledger import direction one-way.
702
+ export function writeLedger(ledgerPath, records) {
688
703
  withLedgerLock(ledgerPath, () => {
689
704
  mkdirSync(dirname(ledgerPath), { recursive: true });
690
705
  atomicWriteFileSync(ledgerPath, records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
@@ -867,7 +882,7 @@ function pathExistsForPurge(path) {
867
882
  return false;
868
883
  }
869
884
  }
870
- function assertSafeGeneratedId(value, label) {
885
+ export function assertSafeGeneratedId(value, label) {
871
886
  if (!/^[A-Za-z0-9_-]+$/.test(value)) {
872
887
  throw new Error(`Invalid ${label}: ${value}`);
873
888
  }