artshelf 0.11.0 → 0.12.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 +32 -0
- package/README.md +2 -0
- package/SPEC.md +146 -1
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/put.js +1 -1
- package/dist/src/commands/reconcile.js +48 -0
- package/dist/src/commands/shared.js +17 -0
- package/dist/src/ledger.js +22 -7
- package/dist/src/provenance.js +142 -0
- package/dist/src/reconcile.js +332 -0
- package/dist/src/shared/help-text.js +26 -0
- package/docs/reference.html +26 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -91,6 +91,38 @@
|
|
|
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
|
+
|
|
114
|
+
## [0.12.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.11.0...artshelf-v0.12.0) (2026-06-15)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
### Features
|
|
118
|
+
|
|
119
|
+
* **ledger:** add path-provenance foundation for NGX-436 ([f0bf797](https://github.com/calvinnwq/artshelf/commit/f0bf797223e5032e326842cc1dd8fcb47130ed3e))
|
|
120
|
+
* **ledger:** add provenance validation distinguishing legacy from malformed rows (NGX-436) ([ce5128a](https://github.com/calvinnwq/artshelf/commit/ce5128a85bcc6353f73c3b47bd9a433918236ee0))
|
|
121
|
+
* **reconcile:** add path-provenance foundation and approval-gated reconcile command ([ad4bcec](https://github.com/calvinnwq/artshelf/commit/ad4bcec7839e004a0f7ca3cc9a8ecebb0caaac0f))
|
|
122
|
+
* **reconcile:** add read-only classification engine for NGX-437 ([3245738](https://github.com/calvinnwq/artshelf/commit/3245738c8d7b3e3dfd95c49fae414596b35c22e1))
|
|
123
|
+
* **reconcile:** add reconcile dry-run plan layer for NGX-437 ([ddc8881](https://github.com/calvinnwq/artshelf/commit/ddc8881713d26f1e43575b3097e49558c22cc2e7))
|
|
124
|
+
* **reconcile:** add reconcile execute layer with audit trail and stale-state refusals (NGX-437) ([50a12d4](https://github.com/calvinnwq/artshelf/commit/50a12d49cdf0552b675bfaa75a0220c886bd64e5))
|
|
125
|
+
* **reconcile:** wire reconcile CLI command with integration tests (NGX-437) ([0ea033b](https://github.com/calvinnwq/artshelf/commit/0ea033b73e96910de87a832e5ada835bc603ff12))
|
|
94
126
|
|
|
95
127
|
## [0.11.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.2...artshelf-v0.11.0) (2026-06-14)
|
|
96
128
|
|
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
|
@@ -526,6 +526,76 @@ Rules:
|
|
|
526
526
|
- Keeps the record visible through `list` and `list --status resolved`.
|
|
527
527
|
- Refuses records that are already `resolved`; the original reason is preserved.
|
|
528
528
|
|
|
529
|
+
### `artshelf reconcile`
|
|
530
|
+
|
|
531
|
+
Approval-gated ledger/registry housekeeping that turns recorded-path drift into a
|
|
532
|
+
reviewed plan and then applies exactly one reviewed plan id. Reconcile is **not**
|
|
533
|
+
cleanup: it never creates, moves, or deletes files. It only rewrites drifted ledger
|
|
534
|
+
paths and resolves rows that can no longer be acted on, mirroring the cleanup
|
|
535
|
+
dry-run/execute boundary.
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
artshelf reconcile --dry-run [--ledger <path>] [--json]
|
|
539
|
+
artshelf reconcile --dry-run --all [--registry <path>] [--json]
|
|
540
|
+
artshelf reconcile --execute --plan-id <id> --ledger <path> [--json]
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Dry-run classifies each drifted record into one finding category:
|
|
544
|
+
|
|
545
|
+
- `remap`: the recorded path is gone, but provenance reconstructs the artifact under
|
|
546
|
+
the current ledger/repo root (for example after a `shelf` -> `artshelf` or
|
|
547
|
+
`.shelf` -> `.artshelf` rename) and the basename plus optional file fingerprint
|
|
548
|
+
still match. The path can be safely rewritten to the reconstructed location.
|
|
549
|
+
- `resolve-missing`: an `active` or `review-required` record's path is gone and no
|
|
550
|
+
safe remap target was found (external path, legacy row, or nothing matches). The
|
|
551
|
+
row can be resolved after review.
|
|
552
|
+
- `resolve-stale-trash`: an already-`trashed` record's trash target is gone. The
|
|
553
|
+
ledger row is resolved ledger-only; the filesystem is never touched.
|
|
554
|
+
- `blocked`: a candidate exists at the reconstructed location but its name or
|
|
555
|
+
fingerprint does not match, or evidence is otherwise ambiguous or unsafe. Blocked
|
|
556
|
+
findings are surfaced for review and never auto-applied.
|
|
557
|
+
|
|
558
|
+
`registry-remap` is reserved in the finding taxonomy for a future registry pass that
|
|
559
|
+
updates a registered ledger whose path moved; the current dry-run classifies drift
|
|
560
|
+
within a single ledger's records and does not yet emit `registry-remap`.
|
|
561
|
+
|
|
562
|
+
Dry-run rules:
|
|
563
|
+
|
|
564
|
+
- Read-only except for reviewed plan artifact creation/reuse. It classifies drift
|
|
565
|
+
and, when actionable entries exist, persists the plan to
|
|
566
|
+
`<ledger-dir>/reconcile-plans/<id>.json` and registers an Artshelf-owned plan
|
|
567
|
+
record (`owner=artshelf`, `kind=run-artifact`, `ttl=14d`, `cleanup=trash`, labels
|
|
568
|
+
including `artshelf`, `reconcile-plan`, and the plan id).
|
|
569
|
+
- A no-op dry-run (only blocked or no findings) reports `planId=not-created`,
|
|
570
|
+
`planPath=null`, and writes no plan file. A later dry-run whose actionable entries
|
|
571
|
+
match an existing plan reuses that plan id and refreshes its plan artifact.
|
|
572
|
+
- `--all` is dry-run only and previews every registered ledger after the registry
|
|
573
|
+
validates. There is no global execute.
|
|
574
|
+
|
|
575
|
+
Execute rules:
|
|
576
|
+
|
|
577
|
+
- Requires `--plan-id` and one explicit `--ledger`. It binds to one reviewed plan id
|
|
578
|
+
and refuses a missing, unknown, or id/ledger-mismatched plan before any mutation.
|
|
579
|
+
There is no `reconcile --execute --all` and no fresh-plan-then-execute.
|
|
580
|
+
- Before applying each entry it re-classifies the live ledger and refuses entries
|
|
581
|
+
whose live state has drifted since review (record gone, status changed, remap
|
|
582
|
+
target vanished, or path reappeared), skipping them instead of mutating stale rows.
|
|
583
|
+
- A `remap` rewrites the record `path` and recomputes its provenance for the new
|
|
584
|
+
location while keeping the row's status; every resolve category archives the row
|
|
585
|
+
ledger-only as `resolved`.
|
|
586
|
+
- Preserves audit provenance on every touched row (`previousPath`, the rewritten
|
|
587
|
+
`path` for a remap, `reconcilePlanId`, `reconcileReceiptPath`, `reconciledAt`, and
|
|
588
|
+
`reconcileReason`), and writes a reconcile receipt to
|
|
589
|
+
`<ledger-dir>/reconcile-receipts/<id>.json` registered as an Artshelf-owned
|
|
590
|
+
artifact (`ttl=30d`, `cleanup=review`, labels including `artshelf`,
|
|
591
|
+
`reconcile-receipt`, and the plan id).
|
|
592
|
+
- Never creates or deletes filesystem artifacts. Reconcile is ledger/registry
|
|
593
|
+
bookkeeping only, and `doctor`, `status`, `review`, and `validate` never perform
|
|
594
|
+
silent reconcile edits.
|
|
595
|
+
|
|
596
|
+
JSON output is deterministic (findings preserve ledger order) so agents can render a
|
|
597
|
+
decision packet and approve a specific plan id.
|
|
598
|
+
|
|
529
599
|
## Ledger Storage
|
|
530
600
|
|
|
531
601
|
V1 supports two scopes:
|
|
@@ -659,6 +729,69 @@ the purge provenance:
|
|
|
659
729
|
}
|
|
660
730
|
```
|
|
661
731
|
|
|
732
|
+
Records touched by `artshelf reconcile --execute` carry the reconcile audit trail so a
|
|
733
|
+
remap or resolve stays traceable to the reviewed plan that produced it:
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
{
|
|
737
|
+
"previousPath": "/old-absolute/path/build/out.txt",
|
|
738
|
+
"reconcilePlanId": "reconcile_20260601_062000_ab12",
|
|
739
|
+
"reconcileReceiptPath": "/absolute/path/.artshelf/reconcile-receipts/reconcile_20260601_062000_ab12.json",
|
|
740
|
+
"reconciledAt": "2026-06-01T06:20:00Z",
|
|
741
|
+
"reconcileReason": "recorded path is missing; reconstructed at the current root"
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
`previousPath` preserves the path the row held before the action; for a `remap` the new
|
|
746
|
+
location is the rewritten `path`, while resolve categories leave `path` and set
|
|
747
|
+
`status=resolved`. These fields are additive and absent on records reconcile never
|
|
748
|
+
touched.
|
|
749
|
+
|
|
750
|
+
### Path provenance
|
|
751
|
+
|
|
752
|
+
New records carry a `provenance` block alongside the absolute `path`. The absolute
|
|
753
|
+
path is still the audit record of where the artifact lived; provenance adds the data
|
|
754
|
+
a future reconcile needs to reason about an artifact that moved because its root was
|
|
755
|
+
renamed (for example `shelf` -> `artshelf` or `.shelf` -> `.artshelf`). Capturing it
|
|
756
|
+
at write time is what lets reconcile remap paths later **without** Artshelf running as
|
|
757
|
+
a daemon, watcher, or shell hook.
|
|
758
|
+
|
|
759
|
+
```json
|
|
760
|
+
{
|
|
761
|
+
"provenance": {
|
|
762
|
+
"root": "repo",
|
|
763
|
+
"rootPath": "/absolute/path/to/repo",
|
|
764
|
+
"relativePath": "build/out.txt",
|
|
765
|
+
"basename": "out.txt",
|
|
766
|
+
"pathKind": "file",
|
|
767
|
+
"fingerprint": { "byteSize": 1024 }
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
- `root` is `repo`, `ledger`, or `external`. Ledger-owned paths (`trash/`, `plans/`,
|
|
773
|
+
`receipts/`) classify as `ledger`; other paths inside the repo classify as `repo`;
|
|
774
|
+
anything else is `external`.
|
|
775
|
+
- `rootPath` and `relativePath` are the matched root and the POSIX path beneath it.
|
|
776
|
+
The relative path is what survives a root rename, so a reconcile can rebuild the
|
|
777
|
+
current absolute path from the current root. `external` paths cannot be rebuilt, so
|
|
778
|
+
both fields are `null`.
|
|
779
|
+
- `basename`, `pathKind`, and the optional file `fingerprint` (byte size only) are
|
|
780
|
+
cheap matching hints for disambiguating rename candidates.
|
|
781
|
+
|
|
782
|
+
Provenance is additive and backward compatible. Records written before provenance
|
|
783
|
+
existed simply omit the field; they are treated as **legacy records with missing
|
|
784
|
+
provenance, not malformed data**, and continue to validate, read, list, find, and get
|
|
785
|
+
normally. `artshelf validate` only inspects provenance when the field is present: a
|
|
786
|
+
present-but-structurally-invalid block (bad `root`, missing reconstruct data on a
|
|
787
|
+
`repo`/`ledger` root, reconstruct data on an `external` root, non-numeric fingerprint)
|
|
788
|
+
is reported as an error, while an absent block is not.
|
|
789
|
+
|
|
790
|
+
Provenance only records evidence. It never moves, deletes, or rewrites artifacts, and
|
|
791
|
+
capturing it does not change any path. Acting on provenance to remap a ledger remains
|
|
792
|
+
an explicit, approval-gated reconcile step — never an automatic side effect of `put`,
|
|
793
|
+
`doctor`, `status`, `review`, or `validate`.
|
|
794
|
+
|
|
662
795
|
## Cleanup Safety Model
|
|
663
796
|
|
|
664
797
|
Cleanup execution is intentionally boring and approval-only. Five boundaries
|
|
@@ -820,6 +953,17 @@ human review.
|
|
|
820
953
|
- CLI can list trashed records (single ledger or `--all`) and purge them through
|
|
821
954
|
an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
|
|
822
955
|
receipt; purge refuses `--all` and never deletes without a reviewed plan id.
|
|
956
|
+
- New records capture path provenance (root class, root-relative path, basename,
|
|
957
|
+
path kind, and an optional byte-size fingerprint); provenance is additive and
|
|
958
|
+
backward compatible, so legacy records without it still validate and read, and
|
|
959
|
+
`validate` reports a malformed provenance block only when the field is present.
|
|
960
|
+
- CLI can reconcile drifted recorded paths through `artshelf reconcile` without
|
|
961
|
+
ever creating, moving, or deleting files: `--dry-run` classifies drift into a
|
|
962
|
+
reviewed plan (`remap`, `resolve-missing`, `resolve-stale-trash`, `blocked`) and
|
|
963
|
+
`--all` previews every registered ledger as dry-run only, while `--execute`
|
|
964
|
+
applies one reviewed plan id against one explicit ledger, refuses `--all`,
|
|
965
|
+
mismatched plans, and entries whose live state drifted since review, and writes
|
|
966
|
+
the reconcile audit trail and receipt.
|
|
823
967
|
- Package includes the deterministic `ArtshelfReviewReport` schema, canonical
|
|
824
968
|
example, and portable renderer script for agent-rendered review reports.
|
|
825
969
|
- All core commands support `--json`.
|
|
@@ -828,7 +972,8 @@ human review.
|
|
|
828
972
|
- Tests cover record/list/find/get/status-filter/due/validate/resolve/registry,
|
|
829
973
|
`artshelf doctor`, the `artshelf status` dashboard, `--all` review, stale-registry,
|
|
830
974
|
dry-run, global-dry-run, execute-plan, cleanup plan-id validation, concurrent
|
|
831
|
-
ledger writes,
|
|
975
|
+
ledger writes, trash list/purge, path provenance validation, and reconcile
|
|
976
|
+
dry-run/execute behavior.
|
|
832
977
|
|
|
833
978
|
## Deferred
|
|
834
979
|
|
|
@@ -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;
|
package/dist/src/commands/put.js
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -103,6 +103,23 @@ export function printPlan(plan, ledgerPath) {
|
|
|
103
103
|
process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
|
|
104
104
|
process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
|
|
105
105
|
}
|
|
106
|
+
export function printReconcilePlan(plan, ledgerPath) {
|
|
107
|
+
process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.blocked.length} blocked\n`);
|
|
108
|
+
for (const entry of plan.entries) {
|
|
109
|
+
const target = entry.proposedPath ? `${entry.currentPath} -> ${entry.proposedPath}` : entry.currentPath;
|
|
110
|
+
process.stdout.write(`${entry.category} ${entry.id} ${entry.field} ${target} :: ${entry.reason}\n`);
|
|
111
|
+
}
|
|
112
|
+
for (const blocked of plan.blocked) {
|
|
113
|
+
process.stdout.write(`blocked ${blocked.id} ${blocked.field} ${blocked.currentPath} :: ${blocked.reason}\n`);
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
|
|
116
|
+
}
|
|
117
|
+
export function printReconcilePlans(results) {
|
|
118
|
+
for (const result of results) {
|
|
119
|
+
process.stdout.write(`plan ${result.plan.planId} [${result.ledger.name}]: ${result.plan.entries.length} entries, ${result.plan.blocked.length} blocked\n`);
|
|
120
|
+
process.stdout.write(`plan: ${result.plan.planPath ?? "not created"}\nledger: ${result.ledger.path}\n`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
106
123
|
export function printTrashListEntries(results) {
|
|
107
124
|
const total = results.reduce((count, result) => count + result.entries.length, 0);
|
|
108
125
|
if (total === 0) {
|
package/dist/src/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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,332 @@
|
|
|
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
|
+
// Apply a reviewed reconcile plan (NGX-437 `reconcile --execute`). This is the only
|
|
68
|
+
// mutating reconcile entrypoint and it is deliberately conservative:
|
|
69
|
+
// * It refuses up front when the plan id is missing, the plan file is absent, or the
|
|
70
|
+
// plan file's declared id/ledger does not match the scoped request (no fresh plan,
|
|
71
|
+
// no `--all`; the command layer enforces those, this binds to one exact plan id).
|
|
72
|
+
// * Before applying any entry it re-classifies the live ledger and only acts when the
|
|
73
|
+
// current finding still matches the reviewed entry, so a plan executed against a
|
|
74
|
+
// drifted ledger refuses the stale entries instead of mutating the wrong rows.
|
|
75
|
+
// Reconcile is ledger/registry housekeeping only: it rewrites paths and resolves rows
|
|
76
|
+
// and writes a receipt; it never creates or deletes filesystem artifacts.
|
|
77
|
+
export function executeReconcilePlan(ledgerPath, planId) {
|
|
78
|
+
if (!planId)
|
|
79
|
+
throw new Error("reconcile --execute requires --plan-id");
|
|
80
|
+
const planPath = reconcilePlanPath(ledgerPath, planId);
|
|
81
|
+
if (!existsSync(planPath))
|
|
82
|
+
throw new Error(`Reconcile plan not found: ${planId}`);
|
|
83
|
+
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
84
|
+
assertReconcilePlanExecutable(plan, planId, ledgerPath);
|
|
85
|
+
const receiptPath = reconcileReceiptPath(ledgerPath, planId);
|
|
86
|
+
return withPathLock(ledgerPath, () => {
|
|
87
|
+
const records = readLedger(ledgerPath);
|
|
88
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
89
|
+
const liveById = new Map(classifyReconcileFindings(ledgerPath).map((finding) => [finding.id, finding]));
|
|
90
|
+
const executedAt = toIso(now());
|
|
91
|
+
const audit = { reconcilePlanId: planId, reconcileReceiptPath: receiptPath, reconciledAt: executedAt };
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const entry of plan.entries) {
|
|
94
|
+
const record = recordsById.get(entry.id);
|
|
95
|
+
const live = liveById.get(entry.id);
|
|
96
|
+
if (!record || !live || !sameReconcileTarget(live, entry)) {
|
|
97
|
+
results.push(skippedResult(entry));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const applied = applyReconcileEntry(record, entry, audit, ledgerPath);
|
|
101
|
+
recordsById.set(entry.id, applied);
|
|
102
|
+
results.push(appliedResult(entry, applied));
|
|
103
|
+
}
|
|
104
|
+
writeReconcileReceipt(receiptPath, { planId, ledgerPath, executedAt, results });
|
|
105
|
+
writeLedger(ledgerPath, records.map((record) => recordsById.get(record.id) ?? record));
|
|
106
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
107
|
+
reason: `Artshelf reconcile receipt for plan ${planId}`,
|
|
108
|
+
ttl: "30d",
|
|
109
|
+
kind: "run-artifact",
|
|
110
|
+
cleanup: "review",
|
|
111
|
+
labels: ["artshelf", "reconcile-receipt", planId]
|
|
112
|
+
});
|
|
113
|
+
return { planId, receiptPath, executedAt, results };
|
|
114
|
+
}, "Artshelf ledger");
|
|
115
|
+
}
|
|
116
|
+
// Produce the mutated record for one applicable entry. A remap rewrites the path and
|
|
117
|
+
// recomputes provenance against the new location (so the row is reconcile-healthy
|
|
118
|
+
// afterwards) while keeping the row's status; every resolve category archives the row
|
|
119
|
+
// ledger-only as `resolved`. previousPath always preserves the pre-action path.
|
|
120
|
+
function applyReconcileEntry(record, entry, audit, ledgerPath) {
|
|
121
|
+
if (entry.category === "remap" && entry.proposedPath) {
|
|
122
|
+
return {
|
|
123
|
+
...record,
|
|
124
|
+
path: entry.proposedPath,
|
|
125
|
+
provenance: computeProvenance(entry.proposedPath, { ledgerPath }),
|
|
126
|
+
previousPath: entry.currentPath,
|
|
127
|
+
...audit,
|
|
128
|
+
reconcileReason: entry.reason
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
...record,
|
|
133
|
+
status: "resolved",
|
|
134
|
+
resolvedAt: audit.reconciledAt,
|
|
135
|
+
resolutionReason: entry.reason,
|
|
136
|
+
previousPath: entry.currentPath,
|
|
137
|
+
...audit,
|
|
138
|
+
reconcileReason: entry.reason
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function appliedResult(entry, applied) {
|
|
142
|
+
return {
|
|
143
|
+
id: entry.id,
|
|
144
|
+
category: entry.category,
|
|
145
|
+
field: entry.field,
|
|
146
|
+
status: applied.status === "resolved" ? "resolved" : "remapped",
|
|
147
|
+
previousPath: entry.currentPath,
|
|
148
|
+
newPath: entry.category === "remap" ? entry.proposedPath : null,
|
|
149
|
+
reason: entry.reason
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function skippedResult(entry) {
|
|
153
|
+
return {
|
|
154
|
+
id: entry.id,
|
|
155
|
+
category: entry.category,
|
|
156
|
+
field: entry.field,
|
|
157
|
+
status: "skipped",
|
|
158
|
+
previousPath: entry.currentPath,
|
|
159
|
+
newPath: null,
|
|
160
|
+
reason: "live ledger state no longer matches the reviewed plan"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Two findings describe the same drift only when every structural field agrees; this
|
|
164
|
+
// is the execute-time safety check that refuses entries whose live state has moved on.
|
|
165
|
+
function sameReconcileTarget(live, entry) {
|
|
166
|
+
return (live.category === entry.category &&
|
|
167
|
+
live.field === entry.field &&
|
|
168
|
+
live.status === entry.status &&
|
|
169
|
+
live.currentPath === entry.currentPath &&
|
|
170
|
+
live.proposedPath === entry.proposedPath);
|
|
171
|
+
}
|
|
172
|
+
// Bind a loaded reconcile plan to the request before any ledger mutation, mirroring
|
|
173
|
+
// cleanup's assertCleanupPlanExecutable: the plan must declare the requested id, belong
|
|
174
|
+
// to the executing ledger, and carry well-formed entries.
|
|
175
|
+
function assertReconcilePlanExecutable(plan, planId, ledgerPath) {
|
|
176
|
+
if (plan.planId !== planId) {
|
|
177
|
+
throw new Error(`Reconcile plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
|
|
178
|
+
}
|
|
179
|
+
if (plan.ledgerPath !== ledgerPath) {
|
|
180
|
+
throw new Error(`Reconcile plan ledger mismatch: plan was created for ${plan.ledgerPath}, executing ${ledgerPath}`);
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(plan.entries)) {
|
|
183
|
+
throw new Error(`Reconcile plan entries are malformed: ${planId}`);
|
|
184
|
+
}
|
|
185
|
+
for (const entry of plan.entries) {
|
|
186
|
+
if (!entry || typeof entry.id !== "string" || typeof entry.currentPath !== "string" || !RECONCILE_CATEGORIES.has(entry.category)) {
|
|
187
|
+
throw new Error(`Reconcile plan entries are malformed: ${planId}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function reconcileReceiptPath(ledgerPath, planId) {
|
|
192
|
+
assertSafeGeneratedId(planId, "reconcile plan id");
|
|
193
|
+
return join(dirname(ledgerPath), "reconcile-receipts", `${planId}.json`);
|
|
194
|
+
}
|
|
195
|
+
function writeReconcileReceipt(receiptPath, value) {
|
|
196
|
+
mkdirSync(dirname(receiptPath), { recursive: true });
|
|
197
|
+
writeFileSync(receiptPath, `${JSON.stringify(value, null, 2)}\n`);
|
|
198
|
+
}
|
|
199
|
+
function classifyRecord(record, roots) {
|
|
200
|
+
// A trashed row's original path is expected to be empty (it was moved to trash),
|
|
201
|
+
// so the only path that matters is the trash target.
|
|
202
|
+
if (record.status === "trashed")
|
|
203
|
+
return classifyTrashTarget(record);
|
|
204
|
+
// Live rows are the ones whose recorded artifact path should still exist. This
|
|
205
|
+
// mirrors validateLedger's "recorded path is missing" warning surface.
|
|
206
|
+
if (record.status === "active" || record.status === "review-required") {
|
|
207
|
+
return classifyActivePath(record, roots);
|
|
208
|
+
}
|
|
209
|
+
// resolved / cleanup-refused rows are terminal for reconcile purposes.
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function classifyActivePath(record, roots) {
|
|
213
|
+
if (!record.path || existsSync(record.path))
|
|
214
|
+
return null;
|
|
215
|
+
const provenance = record.provenance;
|
|
216
|
+
const candidate = reconstructPath(provenance, roots);
|
|
217
|
+
if (provenance && candidate && existsSync(candidate)) {
|
|
218
|
+
if (isSafeMatch(provenance, candidate)) {
|
|
219
|
+
return finding(record, "remap", "path", record.path, candidate, `recorded path is missing; reconstructed at ${candidate}`);
|
|
220
|
+
}
|
|
221
|
+
return finding(record, "blocked", "path", record.path, null, `a candidate exists at ${candidate} but its name or fingerprint does not match the recorded artifact`);
|
|
222
|
+
}
|
|
223
|
+
return finding(record, "resolve-missing", "path", record.path, null, "recorded path is missing and no safe remap target was found");
|
|
224
|
+
}
|
|
225
|
+
function classifyTrashTarget(record) {
|
|
226
|
+
// Missing cleanup metadata on a trashed row is validateLedger's concern, not ours.
|
|
227
|
+
if (!record.targetPath || existsSync(record.targetPath))
|
|
228
|
+
return null;
|
|
229
|
+
return finding(record, "resolve-stale-trash", "targetPath", record.targetPath, null, "trashed target is missing; resolve the ledger row without touching the filesystem");
|
|
230
|
+
}
|
|
231
|
+
// Re-root a provenance-relative path under the current ledger/repo root. Only
|
|
232
|
+
// reconstructable roots (repo/ledger) with a stored relative path can be rebuilt;
|
|
233
|
+
// external paths and legacy rows without provenance return null.
|
|
234
|
+
function reconstructPath(provenance, roots) {
|
|
235
|
+
if (!provenance || provenance.relativePath === null)
|
|
236
|
+
return null;
|
|
237
|
+
if (provenance.root === "repo") {
|
|
238
|
+
return roots.repoRoot ? join(roots.repoRoot, fromPosix(provenance.relativePath)) : null;
|
|
239
|
+
}
|
|
240
|
+
if (provenance.root === "ledger") {
|
|
241
|
+
return join(roots.ledgerRoot, fromPosix(provenance.relativePath));
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// A reconstructed candidate is only trusted when its basename matches and, for
|
|
246
|
+
// files with a captured fingerprint, its byte size matches too. Directories and
|
|
247
|
+
// fingerprint-less rows fall back to name plus existence as the evidence.
|
|
248
|
+
function isSafeMatch(provenance, candidate) {
|
|
249
|
+
if (basename(candidate) !== provenance.basename)
|
|
250
|
+
return false;
|
|
251
|
+
if (provenance.pathKind === "file" && provenance.fingerprint) {
|
|
252
|
+
try {
|
|
253
|
+
return statSync(candidate).size === provenance.fingerprint.byteSize;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
function finding(record, category, field, currentPath, proposedPath, reason) {
|
|
262
|
+
return { id: record.id, category, field, status: record.status, currentPath, proposedPath, reason };
|
|
263
|
+
}
|
|
264
|
+
function fromPosix(path) {
|
|
265
|
+
return sep === "/" ? path : path.split("/").join(sep);
|
|
266
|
+
}
|
|
267
|
+
// Split classified findings into a plan: actionable entries (everything a scoped
|
|
268
|
+
// `--execute` may apply) versus blocked findings (surfaced for review only). The
|
|
269
|
+
// plan id/path are computed up front so a dry-run can persist deterministically.
|
|
270
|
+
function buildReconcilePlan(ledgerPath) {
|
|
271
|
+
const generatedAt = now();
|
|
272
|
+
const findings = classifyReconcileFindings(ledgerPath);
|
|
273
|
+
const entries = findings.filter((finding) => finding.category !== "blocked");
|
|
274
|
+
const blocked = findings.filter((finding) => finding.category === "blocked");
|
|
275
|
+
const planId = makeReconcilePlanId(generatedAt);
|
|
276
|
+
return {
|
|
277
|
+
planId,
|
|
278
|
+
generatedAt: toIso(generatedAt),
|
|
279
|
+
ledgerPath,
|
|
280
|
+
entries,
|
|
281
|
+
blocked,
|
|
282
|
+
planPath: reconcilePlanPath(ledgerPath, planId)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function noCreatedReconcilePlan(plan) {
|
|
286
|
+
return { ...plan, planId: "not-created", planPath: null };
|
|
287
|
+
}
|
|
288
|
+
// Reuse an earlier plan whose actionable entries match this one's, so repeated
|
|
289
|
+
// dry-runs converge on a single stable plan id (mirrors cleanup plan reuse). Only
|
|
290
|
+
// the structural entry fields are fingerprinted; volatile fields (generatedAt) and
|
|
291
|
+
// the review-only blocked list do not affect reuse.
|
|
292
|
+
function matchingExistingReconcilePlan(ledgerPath, plan) {
|
|
293
|
+
const plansDir = join(dirname(ledgerPath), "reconcile-plans");
|
|
294
|
+
if (!existsSync(plansDir))
|
|
295
|
+
return null;
|
|
296
|
+
const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
|
|
297
|
+
for (const filename of filenames) {
|
|
298
|
+
const planPath = join(plansDir, filename);
|
|
299
|
+
try {
|
|
300
|
+
const candidate = JSON.parse(readFileSync(planPath, "utf8"));
|
|
301
|
+
if (candidate.ledgerPath !== ledgerPath)
|
|
302
|
+
continue;
|
|
303
|
+
if (reconcilePlanFingerprint(candidate) !== reconcilePlanFingerprint(plan))
|
|
304
|
+
continue;
|
|
305
|
+
return { ...candidate, planPath };
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function reconcilePlanFingerprint(plan) {
|
|
314
|
+
return JSON.stringify(plan.entries.map((entry) => ({
|
|
315
|
+
id: entry.id,
|
|
316
|
+
category: entry.category,
|
|
317
|
+
field: entry.field,
|
|
318
|
+
currentPath: entry.currentPath,
|
|
319
|
+
proposedPath: entry.proposedPath
|
|
320
|
+
})));
|
|
321
|
+
}
|
|
322
|
+
function writeReconcilePlanFile(planPath, plan) {
|
|
323
|
+
mkdirSync(dirname(planPath), { recursive: true });
|
|
324
|
+
writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
|
|
325
|
+
}
|
|
326
|
+
function makeReconcilePlanId(date) {
|
|
327
|
+
return `reconcile_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
|
|
328
|
+
}
|
|
329
|
+
function reconcilePlanPath(ledgerPath, planId) {
|
|
330
|
+
assertSafeGeneratedId(planId, "reconcile plan id");
|
|
331
|
+
return join(dirname(ledgerPath), "reconcile-plans", `${planId}.json`);
|
|
332
|
+
}
|
|
@@ -52,6 +52,7 @@ const COMMAND_GROUPS = [
|
|
|
52
52
|
group: "Clean",
|
|
53
53
|
commands: [
|
|
54
54
|
{ name: "cleanup", summary: "Plan and execute approved cleanups" },
|
|
55
|
+
{ name: "reconcile", summary: "Reconcile drifted ledger paths via approval-gated plans" },
|
|
55
56
|
{ name: "trash", summary: "Inspect and purge Artshelf trash" },
|
|
56
57
|
{ name: "resolve", summary: "Mark a record manually resolved" }
|
|
57
58
|
]
|
|
@@ -107,6 +108,31 @@ Dry-run writes and registers a plan only when executable cleanup entries exist;
|
|
|
107
108
|
Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
|
|
108
109
|
Execute writes and registers an Artshelf-owned receipt artifact.
|
|
109
110
|
Global --all mode is dry-run only.
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
if (command === "reconcile") {
|
|
114
|
+
return `Usage:
|
|
115
|
+
artshelf reconcile --dry-run [--ledger <path>] [--json]
|
|
116
|
+
artshelf reconcile --dry-run --all [--registry <path>] [--json]
|
|
117
|
+
artshelf reconcile --execute --plan-id <id> --ledger <path> [--json]
|
|
118
|
+
|
|
119
|
+
Reconcile is approval-gated ledger/registry housekeeping, not cleanup: it never
|
|
120
|
+
creates, moves, or deletes files. It rewrites drifted ledger paths and resolves
|
|
121
|
+
rows that can no longer be acted on, always through one reviewed plan id.
|
|
122
|
+
|
|
123
|
+
Dry-run classifies path drift into a reviewed plan:
|
|
124
|
+
remap a safe moved/renamed path is rewritten to its current location
|
|
125
|
+
resolve-missing an active path is gone with no safe target; resolve after review
|
|
126
|
+
resolve-stale-trash a trashed target is gone; resolve the ledger row, files untouched
|
|
127
|
+
blocked ambiguous or unsafe findings surfaced for review, never auto-applied
|
|
128
|
+
|
|
129
|
+
Execute applies one reviewed plan id against one explicit --ledger and refuses
|
|
130
|
+
missing, unknown, or mismatched plan ids and entries whose live ledger state has
|
|
131
|
+
drifted since review. There is no reconcile --execute --all and no fresh-plan-then-execute.
|
|
132
|
+
Dry-run writes and registers a plan only when actionable entries exist; no-op dry-runs report not-created.
|
|
133
|
+
Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
|
|
134
|
+
Execute writes and registers an Artshelf-owned reconcile receipt artifact.
|
|
135
|
+
Global --all mode is dry-run only.
|
|
110
136
|
`;
|
|
111
137
|
}
|
|
112
138
|
if (command === "trash")
|
package/docs/reference.html
CHANGED
|
@@ -203,6 +203,30 @@ artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--j
|
|
|
203
203
|
<p>Mark a handled, missing, or no-longer-needed record as manually resolved. Updates the ledger only; never moves or deletes files.</p>
|
|
204
204
|
</section>
|
|
205
205
|
|
|
206
|
+
<section class="cmd">
|
|
207
|
+
<div class="cmd-head"><h2>artshelf reconcile</h2><span class="cmd-flag approval">approval-gated</span></div>
|
|
208
|
+
<pre><code><span class="c"># classify path drift into a reviewed plan</span>
|
|
209
|
+
artshelf reconcile --dry-run [--all] [--ledger <path>] [--json]
|
|
210
|
+
|
|
211
|
+
<span class="c"># apply exactly one reviewed plan id for one explicit ledger</span>
|
|
212
|
+
artshelf reconcile --execute --plan-id <id> --ledger <path> [--json]</code></pre>
|
|
213
|
+
<p>
|
|
214
|
+
Approval-gated ledger housekeeping for drifted recorded paths, not cleanup: it never
|
|
215
|
+
creates, moves, or deletes files. <code>--dry-run</code> classifies each drifted record as
|
|
216
|
+
<code>remap</code> (a moved path safely rewritten from provenance), <code>resolve-missing</code>,
|
|
217
|
+
<code>resolve-stale-trash</code>, or <code>blocked</code>, and registers a reviewed plan when
|
|
218
|
+
actionable entries exist. <code>--execute</code> applies one reviewed plan id, refuses
|
|
219
|
+
missing/unknown/mismatched plans and entries whose live state drifted, and stamps the
|
|
220
|
+
reconcile audit trail (<code>previousPath</code>, <code>reconcilePlanId</code>,
|
|
221
|
+
<code>reconciledAt</code>) on every touched row.
|
|
222
|
+
</p>
|
|
223
|
+
<div class="callout" data-kind="boundary">
|
|
224
|
+
<span class="callout-label">Hard boundary</span>
|
|
225
|
+
<p>No file deletion, no auto-execute, and no global execute.
|
|
226
|
+
<code>reconcile --execute --all</code> does not exist, and a fresh plan cannot be executed in one command.</p>
|
|
227
|
+
</div>
|
|
228
|
+
</section>
|
|
229
|
+
|
|
206
230
|
<section>
|
|
207
231
|
<h2>Global flags</h2>
|
|
208
232
|
<p>Only these apply to every command.</p>
|
|
@@ -252,7 +276,7 @@ artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--j
|
|
|
252
276
|
<tr><th>option</th><th>meaning</th></tr>
|
|
253
277
|
<tr><td>--ledger <path></td><td>target an explicit JSONL ledger</td></tr>
|
|
254
278
|
<tr><td>--registry <path></td><td>target an explicit ledger registry</td></tr>
|
|
255
|
-
<tr><td>--all</td><td>read every registered ledger on commands that support discovery (<code>list</code>, <code>find</code>, <code>get</code>, <code>due</code>, <code>validate</code>, <code>review</code>, <code>status</code>, <code>cleanup --dry-run</code>, <code>trash list</code>)</td></tr>
|
|
279
|
+
<tr><td>--all</td><td>read every registered ledger on commands that support discovery (<code>list</code>, <code>find</code>, <code>get</code>, <code>due</code>, <code>validate</code>, <code>review</code>, <code>status</code>, <code>cleanup --dry-run</code>, <code>reconcile --dry-run</code>, <code>trash list</code>)</td></tr>
|
|
256
280
|
</table>
|
|
257
281
|
</section>
|
|
258
282
|
|
|
@@ -292,7 +316,7 @@ artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--j
|
|
|
292
316
|
Inside a git repo, Artshelf defaults to <code>.artshelf/ledger.jsonl</code>. Outside a
|
|
293
317
|
repo it defaults to <code>~/.artshelf/ledger.jsonl</code>. A user-level registry at
|
|
294
318
|
<code>~/.artshelf/ledgers.json</code> is the discovery index for <code>--all</code>
|
|
295
|
-
review, status, cleanup dry-run, and trash-list; project records stay in their own
|
|
319
|
+
review, status, cleanup dry-run, reconcile dry-run, and trash-list; project records stay in their own
|
|
296
320
|
repo-local ledgers. Automatic update checks cache their last npm result at
|
|
297
321
|
<code>~/.artshelf/update-check.json</code> by default, with a long TTL
|
|
298
322
|
for update-available results and a shorter TTL for no-update or failed
|