@cassiomc1/forgeloop 0.1.8 → 0.1.10

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.
@@ -323,7 +323,11 @@ observed in the target. New targets receive canonical documents under
323
323
  `.forgeloop/kit/`; root `AGENTS.md`, `CLAUDE.md`, Cursor, and Copilot files are
324
324
  minimal native shims. `forgeloop update` migrates unchanged managed legacy root
325
325
  files, preserves modified or unowned files, and reports conflicts without
326
- following symlinks or escaping the selected target.
326
+ following symlinks or escaping the selected target. Migration validates the
327
+ complete plan, writes and verifies hidden destinations, atomically switches the
328
+ manifest authority, and then cleans only legacy files whose recorded ownership
329
+ hash still matches. `doctor` reports `E_MIGRATION_INCOMPLETE` for an interrupted
330
+ authority switch or cleanup; a later `update` may safely resume that cleanup.
327
331
 
328
332
  ## Completion validation and chronology
329
333
 
package/README.md CHANGED
@@ -59,6 +59,19 @@ flowchart TB
59
59
  root["FORGELOOP"]
60
60
 
61
61
  root --> entry["native adapters<br/>+ .forgeloop/kit/"]
62
+ entry --> migration{"target<br/>layout"}
63
+ migration -->|legacy v1| plan["validate paths<br/>+ build plan"]
64
+ plan --> hidden["write hidden<br/>kit"]
65
+ hidden --> verified["verify hidden<br/>bytes"]
66
+ verified --> authority["atomic manifest<br/>authority switch"]
67
+ authority --> cleanup["hash-checked<br/>legacy cleanup"]
68
+ cleanup --> recovered["healthy or<br/>recoverable"]
69
+ hidden -. interruption .-> incomplete["doctor: E_MIGRATION_INCOMPLETE"]
70
+ verified -. interruption .-> incomplete
71
+ authority -. interruption .-> incomplete
72
+ cleanup -. interruption .-> incomplete
73
+ migration -->|layout v2| retry["update + retry<br/>owned cleanup"]
74
+ incomplete --> retry
62
75
  entry --> discovery["discovery +<br/>project profile"]
63
76
  discovery --> contract["current<br/>contract"]
64
77
  contract --> routing["deterministic<br/>route"]
@@ -97,6 +110,7 @@ flowchart TB
97
110
 
98
111
  classDef root fill:#08090C,stroke:#6E6AF5,stroke-width:3px,color:#EDEEF0;
99
112
  classDef entry fill:#101218,stroke:#3EDBB8,stroke-width:2px,color:#EDEEF0;
113
+ classDef migration fill:#0F766E,stroke:#5EEAD4,stroke-width:2px,color:#FFFFFF;
100
114
  classDef routing fill:#4F46E5,stroke:#A5B4FC,stroke-width:2px,color:#FFFFFF;
101
115
  classDef state fill:#373A46,stroke:#A1A1AA,stroke-width:2px,color:#FFFFFF;
102
116
  classDef evidence fill:#3EDBB8,stroke:#99F6E4,stroke-width:2px,color:#08090C;
@@ -107,6 +121,7 @@ flowchart TB
107
121
 
108
122
  class root root;
109
123
  class entry,discovery,delegation entry;
124
+ class migration,plan,hidden,verified,authority,cleanup,recovered,retry,incomplete migration;
110
125
  class routing,preflight,ready routing;
111
126
  class state,events,lifecycle state;
112
127
  class checks,receipt,audit evidence;
@@ -118,6 +133,11 @@ flowchart TB
118
133
  ```
119
134
 
120
135
  Equivalent reading for text-only environments: adapters load the canonical kit;
136
+ an older target follows validate paths → write hidden files → verify their bytes
137
+ → switch manifest authority atomically → hash-checked cleanup. An interruption
138
+ after hidden writes, after verification, after the authority switch, or during
139
+ cleanup is diagnosed by `doctor` as `E_MIGRATION_INCOMPLETE` and retried by
140
+ `update`; modified or unmanaged residual files remain preserved;
121
141
  discovery creates the contract and deterministic route; contract, route, and
122
142
  required gates must produce `PREFLIGHT_READY` before the resumable state and
123
143
  append-only event ledger authorize the lifecycle. Verification produces
@@ -138,6 +158,37 @@ Antigravity, OpenCode, Hermes, Pi, Command Code, and Freebuff use the shared
138
158
  documents; see [`AGENT_COMPATIBILITY.md`](./AGENT_COMPATIBILITY.md) for the
139
159
  official sources and precedence notes.
140
160
 
161
+ ### Migration recovery and release freeze
162
+
163
+ Legacy layout migration keeps `.forgeloop/kit/` as the canonical layout and
164
+ does not make `layoutVersion: 2` authoritative until every planned hidden file
165
+ has been written and byte-verified. Cleanup runs only after the manifest switch
166
+ and only for legacy files whose recorded ownership hash still matches when
167
+ ForgeLoop revalidates it immediately before deletion. A modified, unmanaged,
168
+ or `preserve=true` file is retained for manual review.
169
+
170
+ The interruption vocabulary is test-only and is not a runtime state machine:
171
+
172
+ ```text
173
+ VALIDATED → HIDDEN_WRITTEN → HIDDEN_VERIFIED → MANIFEST_SWITCHED
174
+ → LEGACY_CLEANED → COMPLETE
175
+ ```
176
+
177
+ The regression suite injects failures at these boundaries and verifies that
178
+ `doctor` explains the incomplete migration before a later `update` recovers
179
+ owned cleanup. The frozen published installation under
180
+ [`tests/fixtures/legacy-0.1.6/`](./tests/fixtures/legacy-0.1.6/) is derived
181
+ from the real npm tarball, includes provenance and digests, and is copied into
182
+ tests locally; CI does not download npm packages.
183
+
184
+ The current published baseline for a reproducible blind run is
185
+ `@cassiomc1/forgeloop@0.1.9`. The supplied hardening brief's `0.1.8` reference
186
+ is historical; never move `v0.1.8` or `v0.1.9`, and use the read-only release
187
+ identity verifier before a live run. The repository candidate is `0.1.10`, but
188
+ it is not published: its completion-validation and cleanup TOCTOU fixes are not
189
+ part of the frozen `0.1.9` tarball. Publish, tag, and repeat identity
190
+ verification before a blind run that needs those executable changes.
191
+
141
192
  ### Use with npm
142
193
 
143
194
  The npm CLI targets Node.js 20 or newer and installs the kit into an existing
@@ -145,7 +196,12 @@ project without overwriting local instructions. When the package is available
145
196
  in the npm registry, use the commands below; otherwise use the repository
146
197
  checkout fallback.
147
198
 
199
+ The current published release is `@cassiomc1/forgeloop@0.1.9`.
200
+ Pin this version when a reproducible blind run or release-identity check is
201
+ required:
202
+
148
203
  ```bash
204
+ npx @cassiomc1/forgeloop@0.1.9 --version
149
205
  npx @cassiomc1/forgeloop init
150
206
  npx @cassiomc1/forgeloop doctor
151
207
  npx @cassiomc1/forgeloop update
@@ -327,8 +383,17 @@ node src/cli.js update
327
383
  The release workflow uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers)
328
384
  through GitHub Actions OIDC. Before the first release, register this repository
329
385
  and workflow as the package's trusted publisher in npm; each `vX.Y.Z` tag must
330
- match `package.json`. After publishing, verify the package version and its npm
331
- provenance record.
386
+ match `package.json`. After publishing, verify the complete immutable release
387
+ identity before a blind run:
388
+
389
+ ```bash
390
+ RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
391
+ npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
392
+ ```
393
+
394
+ Only `RELEASE_IDENTITY_VALID` is sufficient. The read-only check compares the
395
+ release commit and GitHub tag with npm's version, `gitHead`, tarball URL,
396
+ SHA-1, and SHA-512 integrity; it never publishes or changes a tag.
332
397
 
333
398
  The commands above use the current directory. To install into another existing
334
399
  project directory, pass a relative or absolute `--path`:
@@ -360,7 +425,12 @@ reference.
360
425
  Targets created by an older package layout are migrated by `update`: unchanged
361
426
  managed root files move into the hidden kit, while modified or unowned root
362
427
  files are preserved and reported as conflicts. The migration never follows a
363
- symlink or deletes a file whose managed hash no longer matches.
428
+ symlink or deletes a file whose managed hash no longer matches. The migration
429
+ writes and verifies the complete hidden plan, atomically switches the manifest
430
+ authority, and only then cleans owned legacy files. If a process stops between
431
+ those stages, `doctor` reports `E_MIGRATION_INCOMPLETE` and the next `update`
432
+ retries cleanup only when the recorded legacy hash still matches; modified or
433
+ unowned files remain for manual review.
364
434
 
365
435
  ### Migrate an existing mdfiles installation
366
436
 
package/THREAT_MODEL.md CHANGED
@@ -12,7 +12,8 @@ remaining trust boundaries and their executable evidence.
12
12
  | --- | --- | --- | --- | --- | --- |
13
13
  | Path traversal | Writes or reads outside the selected target | Target path and every managed relative path | `ensureWithin`, safe-path checks, realpath containment, Windows-drive rejection | A separately privileged process can change the filesystem after validation | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/path-traversal.json` |
14
14
  | Symlink escape | Redirects a managed path to another directory | Existing target parents and artifact paths | Reject symlinked targets, parents, and destinations before access | The check is not a filesystem lock | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/symlink-target.json` |
15
- | Malicious manifest | Causes unsafe update or false ownership claims | `.forgeloop/manifest.json` | Schema, hash, safe-path, and preserve-flag validation; conflicts remain visible | A trusted operator can still deliberately edit the manifest | `tests/cli.test.js`, `tests/core.test.js` |
15
+ | Malicious manifest | Causes unsafe update or false ownership claims | `.forgeloop/manifest.json` | Schema, managed/legacy hash, safe-path, and preserve-flag validation; incomplete migration remains visible | A trusted operator can still deliberately edit the manifest | `tests/cli.test.js`, `tests/core.test.js`, `tests/hidden-layout.test.js` |
16
+ | Interrupted or concurrent migration cleanup | Leaves a new hidden kit, an old manifest, residual legacy files, or mutates a legacy file after planning | Hidden kit, manifest authority, and legacy root cleanup | Validate all paths, write and verify the hidden plan, atomically switch authority, then revalidate safe paths and recorded ownership hashes immediately before unlink; mismatches are preserved and `doctor` reports `E_MIGRATION_INCOMPLETE` | Hash-before-unlink narrows but cannot eliminate the final OS-level race without filesystem locking; a separately privileged process remains outside the trust boundary | `tests/hidden-layout.test.js`, `README.md` |
16
17
  | Malicious work-state | Resumes stale, secret-bearing, or invalid work | `.forgeloop/work-state.json` | Schema/semantic checks, version checks, transition checks, contract/HEAD/artifact freshness, secret scan, size/depth bounds | Freshness cannot prove that an external process did not alter a file immediately afterward | `tests/work-state.test.js`, `tests/checkpoint-freshness.test.js`, `tests/security-limits.test.js` |
17
18
  | Forged preparation | Makes an agent's prose claim look like a completed preflight | Contract, route, gate, and preflight artifacts | Canonical SHA-256 fingerprints, guide-declared gate requirements, stale-artifact checks, and `E_*` failures | The CLI cannot stop a separate process from writing project files before preflight | `tests/protocol-artifacts.test.js`, `tests/preflight.test.js` |
18
19
  | Chronology rewrite | Hides execution before route, gates, or verification | `.forgeloop/events.ndjson` | Append-only local ledger, sequence numbers, hash chaining, and chronology validation without prompts or hidden reasoning | A privileged process can still replace the ledger after validation | `tests/lifecycle.test.js` |
@@ -23,6 +24,7 @@ remaining trust boundaries and their executable evidence.
23
24
  | Artifact content exposure | Sensitive material enters portable artifacts or diagnostics | State, receipt, delegation, evidence, and repository text | Nested key/value detection, shaped marker patterns, scanner coverage, no content echo in errors | Content scanners cannot prove that an unknown encoding is harmless | `tests/security-limits.test.js`, `tests/test_scan_secrets.py`, `scripts/scan_secrets.py` |
24
25
  | Stale checkpoint replay | Repeats work against a changed contract or material file | Resume boundary between a saved state and current target | Contract fingerprint, repository fingerprint, required-artifact hashes, protocol version, and conservative revalidation | A contract author must provide the current contract file for comparison | `tests/checkpoint-freshness.test.js`, `tests/cli.test.js` |
25
26
  | Unsafe publication claim | Local output is mistaken for push, PR, merge, or deployment | Receipt publication fields and evidence | Publication starts false; each true claim requires matching evidence; no CLI publication action | Remote provider state is outside local verification | `tests/receipt-semantics.test.js`, `README.md` |
27
+ | Release identity drift | A blind run uses a package whose code, tag, commit, or tarball digest differs from the stated release | npm metadata, GitHub tag, and release commit | Read-only verifier requires package version, `gitHead`, tag, tarball URL, SHA-1, and SHA-512 to agree before the prompt starts | Registry and remote state require a fresh check for every release; a documentation commit is not a new npm package | `tests/release-identity.test.js`, `scripts/verify_release_identity.mjs`, `conformance/README.md` |
26
28
  | Schema confusion | A malformed or altered schema is treated as available | Shipped and target-local `schemas/` directory | Health checks parse every schema and classify valid, missing, invalid, or unsupported-version | Schema health does not replace review of semantic relationships | `tests/schema-health.test.js`, `src/core/schema-validation.js` |
27
29
  | Protocol-version confusion | Old/future artifacts are silently reinterpreted | Cross-artifact protocol boundary | Explicit v1 checks and conformance errors for unsupported or mixed versions | A future protocol needs a deliberate migration implementation | `tests/conformance.test.js`, `tests/fixtures/protocol/invalid/` |
28
30
  | Dependency-cycle denial of service | Coordination cannot make progress or spends unbounded work | Delegation dependency graph | Deterministic DFS cycle detection, unknown-reference rejection, and bounded JSON | A caller can still submit many valid tasks; host-level quotas remain external | `tests/delegation.test.js`, `tests/delegation-set.test.js` |
@@ -32,6 +34,9 @@ remaining trust boundaries and their executable evidence.
32
34
 
33
35
  - Safe paths are checked before reading or writing; no protocol field is a
34
36
  shell command.
37
+ - A migration never treats `layoutVersion: 2` as authoritative before the
38
+ hidden kit is complete and verified; cleanup follows the authority switch and
39
+ is limited by recorded ownership hashes.
35
40
  - A `NOT_VERIFIED` or `BLOCKED` evidence record is never upgraded to observed
36
41
  success by a formatter.
37
42
  - Local checks do not imply remote publication, merge, deployment, or provider
@@ -4,6 +4,30 @@ These scenarios are adapter-facing contracts. They describe requests and the
4
4
  artifacts a live agent must produce; they do not invoke a model runtime and are
5
5
  not part of the deterministic `npm test` execution path.
6
6
 
7
+ The current published baseline for new runs is
8
+ `@cassiomc1/forgeloop@0.1.9`. Pin that version when preparing a reproducible
9
+ blind run; historical reports retain the exact package version they used.
10
+
11
+ The frozen baseline was verified on 2026-08-13 with this identity:
12
+
13
+ ```text
14
+ package: @cassiomc1/forgeloop@0.1.9
15
+ npm gitHead: 0e48240894671ecda2458b15671020b0a7255e70
16
+ release commit: 0e48240894671ecda2458b15671020b0a7255e70
17
+ GitHub tag: v0.1.9 -> 0e48240894671ecda2458b15671020b0a7255e70
18
+ tarball URL: https://registry.npmjs.org/@cassiomc1/forgeloop/-/forgeloop-0.1.9.tgz
19
+ tarball SHA-1: fef7587ebd878e845e9746b4d049a6fe6ef333c5
20
+ npm SHA-512 integrity: sha512-sCvPEDOYT1svNnA8+zw57dJ97N6sNCUH8eDbM6f3cS6eXLCIFFXRPkCAASA+ZPu5mAKbcNaU1TaFKjojbL3DEA==
21
+ release identity: RELEASE_IDENTITY_VALID
22
+ ```
23
+
24
+ The repository may contain documentation or executable commits after this
25
+ frozen package. Those commits do not change the package used by the blind run;
26
+ the repository's `0.1.10` candidate includes completion-validation and cleanup
27
+ TOCTOU fixes that are not in `0.1.9`. If a blind run needs those executable
28
+ changes, publish and tag the candidate, then repeat the complete identity check
29
+ before starting the run.
30
+
7
31
  Run a scenario in a disposable target using the Standard profile first:
8
32
 
9
33
  ```bash
@@ -43,9 +67,52 @@ diagnostic records belong under [`conformance/runs/`](./runs/); they must not
43
67
  contain secrets, credentials, hidden reasoning, or unnecessary conversation
44
68
  history.
45
69
 
70
+ ## Release identity evidence
71
+
72
+ Every live-run report records the exact published package used by the target.
73
+ Include all of these fields before sending the blind prompt:
74
+
75
+ ```text
76
+ package: @cassiomc1/forgeloop@X.Y.Z
77
+ npm gitHead: <40-character commit SHA>
78
+ release commit: <40-character commit SHA>
79
+ GitHub tag: vX.Y.Z -> <40-character commit SHA>
80
+ tarball URL: https://registry.npmjs.org/...
81
+ tarball SHA-1: <40-character hex digest>
82
+ npm SHA-512 integrity: sha512-<base64 digest>
83
+ release identity: RELEASE_IDENTITY_VALID
84
+ ```
85
+
86
+ Run the repository's read-only verifier against the exact release commit:
87
+
88
+ ```bash
89
+ RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
90
+ npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
91
+ ```
92
+
93
+ Do not interpret a local package version, a green build, or a tarball URL by
94
+ itself as publication proof. If the verifier cannot establish every identity
95
+ field, record `RELEASE_IDENTITY_NOT_VERIFIED` or
96
+ `RELEASE_IDENTITY_INVALID` and do not start the blind run.
97
+
46
98
  The complete-website scenario deliberately fails when implementation starts
47
99
  before the contract, route, and required gates exist.
48
100
 
101
+ ## Migration compatibility evidence
102
+
103
+ The real published `0.1.6` fixture at
104
+ [`tests/fixtures/legacy-0.1.6/`](../tests/fixtures/legacy-0.1.6/) is frozen
105
+ with package, tarball, SHA-1, SHA-512, `gitHead`, and extraction-date metadata.
106
+ The migration tests run from those local bytes and cover interruptions after
107
+ hidden writes, after hidden verification, after the manifest authority switch,
108
+ and during legacy cleanup. An interrupted target must be diagnosed as
109
+ `E_MIGRATION_INCOMPLETE`; a later `update` may retry only hash-owned cleanup.
110
+ User-modified, unmanaged, and `preserve=true` files remain untouched even when
111
+ that leaves a root residual. ForgeLoop revalidates the recorded ownership hash
112
+ immediately before deleting each managed legacy file. This narrows the race
113
+ window but does not provide OS-level filesystem locking against a separately
114
+ privileged concurrent process.
115
+
49
116
  ## Autonomous blind-run isolation
50
117
 
51
118
  External workflows may help with local planning, review, tests, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cassiomc1/forgeloop",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Portable, verifiable instruction kit for AI agents and developers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,6 +47,7 @@
47
47
  ],
48
48
  "scripts": {
49
49
  "test": "node scripts/run-tests.js",
50
- "pack:check": "node --test tests/package.test.js"
50
+ "pack:check": "node --test tests/package.test.js",
51
+ "release:identity": "node scripts/verify_release_identity.mjs"
51
52
  }
52
53
  }
@@ -147,6 +147,23 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
147
147
  }
148
148
  const hasLegacyAlternative = layoutVersion >= LAYOUT_VERSION
149
149
  && entry.legacyRelativePath !== entry.relativePath;
150
+ if (layoutVersion < LAYOUT_VERSION && entry.legacyRelativePath !== entry.relativePath) {
151
+ const hiddenDestination = ensureWithin(target, entry.relativePath);
152
+ try {
153
+ await assertSafePath(target, entry.relativePath);
154
+ if (await fileExists(hiddenDestination)) {
155
+ findings.push(finding(
156
+ "E_MIGRATION_INCOMPLETE",
157
+ "error",
158
+ entry.relativePath,
159
+ "A hidden migration destination exists while the legacy manifest is still authoritative; rerun forgeloop update to recover the transaction.",
160
+ "Run forgeloop update and inspect the result before changing either copy.",
161
+ ));
162
+ }
163
+ } catch (error) {
164
+ findings.push(finding("unsafe-path", "error", entry.relativePath, error.message));
165
+ }
166
+ }
150
167
  if (hasLegacyAlternative) {
151
168
  try {
152
169
  await assertSafePath(target, entry.legacyRelativePath);
@@ -161,10 +178,11 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
161
178
  : null;
162
179
  if (legacyDestination && await fileExists(legacyDestination)) {
163
180
  findings.push(finding(
164
- "legacy-root-file",
165
- "warning",
181
+ "E_MIGRATION_INCOMPLETE",
182
+ "error",
166
183
  entry.legacyRelativePath,
167
- "Canonical file remains in the legacy root layout; run forgeloop update to migrate it safely.",
184
+ "Canonical file remains in the legacy root layout; migration is incomplete and the legacy copy was preserved.",
185
+ "Run forgeloop update; it removes the root copy only when the recorded ownership hash still matches.",
168
186
  ));
169
187
  continue;
170
188
  }
@@ -215,10 +233,24 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
215
233
  if (await fileExists(legacyPath)) {
216
234
  if (entry.sourcePath !== "PROJECT_PROFILE.md") {
217
235
  findings.push(finding(
218
- "legacy-root-file",
219
- "warning",
236
+ "E_MIGRATION_INCOMPLETE",
237
+ "error",
238
+ entry.legacyRelativePath,
239
+ record.legacySha256
240
+ ? "A legacy root copy remains after the manifest authority switch; cleanup is incomplete."
241
+ : "A legacy root copy remains without an ownership proof; it was preserved.",
242
+ "Run forgeloop update; modified or unowned legacy files require manual review and are never deleted silently.",
243
+ ));
244
+ }
245
+ if (entry.sourcePath === "PROJECT_PROFILE.md") {
246
+ findings.push(finding(
247
+ "E_MIGRATION_INCOMPLETE",
248
+ "error",
220
249
  entry.legacyRelativePath,
221
- "A legacy root copy remains alongside the canonical hidden kit; review it after migration.",
250
+ record.legacySha256
251
+ ? "The legacy project profile remains after the manifest authority switch; cleanup is incomplete."
252
+ : "A legacy project profile remains alongside the hidden canonical profile.",
253
+ "Run forgeloop update, then remove the root profile only after verifying ownership and preserved bytes.",
222
254
  ));
223
255
  }
224
256
  }
@@ -41,8 +41,12 @@ async function removeEmptyLegacyDirectory(target, relativePath, dryRun) {
41
41
  }
42
42
  }
43
43
 
44
- function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
45
- cleanupFiles.add(relativePath);
44
+ function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath, expectedLegacySha256) {
45
+ cleanupFiles.set(relativePath, expectedLegacySha256);
46
+ addLegacyCleanupDirectory(cleanupDirectories, relativePath);
47
+ }
48
+
49
+ function addLegacyCleanupDirectory(cleanupDirectories, relativePath) {
46
50
  for (const directory of LEGACY_CLEANUP_DIRECTORIES) {
47
51
  if (relativePath === directory || relativePath.startsWith(`${directory}/`)) {
48
52
  cleanupDirectories.add(directory);
@@ -50,12 +54,53 @@ function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
50
54
  }
51
55
  }
52
56
 
53
- async function migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries }) {
57
+ function manifestRecord(sha256Value, preserve, legacySha256Value = null) {
58
+ return {
59
+ sha256: sha256Value,
60
+ preserve,
61
+ ...(legacySha256Value ? { legacySha256: legacySha256Value } : {}),
62
+ };
63
+ }
64
+
65
+ async function notifyStage(hooks, stage, context) {
66
+ if (typeof hooks?.afterStage === "function") await hooks.afterStage(stage, context);
67
+ }
68
+
69
+ async function cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts }) {
70
+ for (const [relativePath, expectedLegacySha256] of cleanupFiles) {
71
+ if (typeof hooks?.beforeCleanup === "function") {
72
+ await hooks.beforeCleanup(relativePath);
73
+ }
74
+ await assertSafePath(target, relativePath);
75
+ const legacyPath = ensureWithin(target, relativePath);
76
+ if (dryRun || !(await fileExists(legacyPath))) continue;
77
+ const currentLegacySha256 = sha256(await readBytes(legacyPath));
78
+ if (currentLegacySha256 !== expectedLegacySha256) {
79
+ conflicts.push(migrationConflict(
80
+ "E_LEGACY_FILE_MIGRATION_CONFLICT",
81
+ relativePath,
82
+ "Legacy file changed before cleanup; it was preserved.",
83
+ ));
84
+ actions.push({ action: "preserve-conflict", path: relativePath, reason: "changed-before-cleanup" });
85
+ continue;
86
+ }
87
+ await unlink(legacyPath);
88
+ }
89
+
90
+ for (const relativePath of cleanupDirectories) {
91
+ if (typeof hooks?.beforeCleanupDirectory === "function") {
92
+ await hooks.beforeCleanupDirectory(relativePath);
93
+ }
94
+ await removeEmptyLegacyDirectory(target, relativePath, dryRun);
95
+ }
96
+ }
97
+
98
+ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks = {} }) {
54
99
  const nextManifest = createManifest(packageVersion);
55
100
  const actions = [];
56
101
  const conflicts = [];
57
102
  const writes = [];
58
- const cleanupFiles = new Set();
103
+ const cleanupFiles = new Map();
59
104
  const cleanupDirectories = new Set();
60
105
 
61
106
  // Validate every path before creating the migration plan or touching data.
@@ -86,6 +131,11 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
86
131
 
87
132
  const currentBytes = await readBytes(destination);
88
133
  const currentHash = sha256(currentBytes);
134
+ if (legacyRecord && currentHash === sourceHash) {
135
+ actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
136
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false);
137
+ continue;
138
+ }
89
139
  if (legacyRecord && currentHash === legacyRecord.sha256) {
90
140
  if (currentHash === sourceHash) {
91
141
  actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
@@ -114,32 +164,67 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
114
164
  if (destinationExists) {
115
165
  const currentBytes = await readBytes(destination);
116
166
  const currentHash = sha256(currentBytes);
117
- if (!destinationRecord && currentHash !== sourceHash && entry.sourcePath !== PROFILE_PATH) {
167
+ const hiddenMatchesSource = currentHash === sourceHash;
168
+ const legacyBytes = legacyExists ? await readBytes(legacyDestination) : null;
169
+ const legacyHash = legacyBytes ? sha256(legacyBytes) : null;
170
+ const unchangedManagedLegacy = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
171
+
172
+ if (entry.sourcePath === PROFILE_PATH
173
+ && legacyExists
174
+ && legacyRecord
175
+ && currentHash === legacyHash) {
176
+ actions.push({ action: "skip", path: entry.relativePath, reason: "profile-move-resumed" });
177
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
178
+ nextManifest.files[entry.relativePath] = manifestRecord(currentHash, true, legacyHash);
179
+ continue;
180
+ }
181
+
182
+ if (!hiddenMatchesSource && !destinationRecord && entry.sourcePath !== PROFILE_PATH) {
118
183
  conflicts.push(migrationConflict(
119
184
  "E_HIDDEN_KIT_MIGRATION_CONFLICT",
120
185
  entry.relativePath,
121
186
  "Existing hidden kit file is unmanaged and was not overwritten.",
122
187
  ));
123
188
  addAction(actions, dryRun, "preserve-conflict", entry.relativePath, { reason: "hidden-unmanaged" });
189
+ } else if (hiddenMatchesSource && legacyExists && unchangedManagedLegacy) {
190
+ actions.push({ action: "skip", path: entry.relativePath, reason: "hidden-ready" });
191
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
192
+ nextManifest.files[entry.relativePath] = manifestRecord(
193
+ sourceHash,
194
+ entry.sourcePath === PROFILE_PATH,
195
+ legacyHash,
196
+ );
197
+ continue;
198
+ } else if (hiddenMatchesSource && legacyExists) {
199
+ const conflict = entry.sourcePath === PROFILE_PATH
200
+ ? migrationConflict(
201
+ "E_PROFILE_MIGRATION_CONFLICT",
202
+ entry.legacyRelativePath,
203
+ "A legacy project profile remains beside the hidden kit without a matching managed hash; both copies were preserved.",
204
+ )
205
+ : migrationConflict(
206
+ "E_LEGACY_FILE_MIGRATION_CONFLICT",
207
+ entry.legacyRelativePath,
208
+ "A legacy file remains beside the hidden kit without a matching managed hash; it was preserved.",
209
+ );
210
+ conflicts.push(conflict);
211
+ addAction(actions, dryRun, "preserve-conflict", entry.legacyRelativePath, { reason: "legacy-residual" });
124
212
  } else {
125
213
  actions.push({ action: "skip", path: entry.relativePath, reason: "already-present" });
126
214
  }
127
- nextManifest.files[entry.relativePath] = {
128
- sha256: currentHash,
129
- preserve: entry.sourcePath === PROFILE_PATH
215
+ nextManifest.files[entry.relativePath] = manifestRecord(
216
+ currentHash,
217
+ entry.sourcePath === PROFILE_PATH
130
218
  || !destinationRecord
131
219
  || Boolean(destinationRecord.preserve),
132
- };
220
+ );
133
221
  continue;
134
222
  }
135
223
 
136
224
  if (!legacyExists) {
137
225
  addAction(actions, dryRun, "create", entry.relativePath);
138
226
  writes.push({ destination, bytes: entry.bytes });
139
- nextManifest.files[entry.relativePath] = {
140
- sha256: sourceHash,
141
- preserve: entry.sourcePath === PROFILE_PATH,
142
- };
227
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
143
228
  continue;
144
229
  }
145
230
 
@@ -150,24 +235,24 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
150
235
  if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
151
236
  addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
152
237
  writes.push({ destination, bytes: legacyBytes });
153
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
154
- nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
238
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
239
+ nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
155
240
  continue;
156
241
  }
157
242
 
158
243
  if (legacyRecord?.preserve && unchangedManaged) {
159
244
  addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
160
245
  writes.push({ destination, bytes: legacyBytes });
161
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
162
- nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
246
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
247
+ nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
163
248
  continue;
164
249
  }
165
250
 
166
251
  if (unchangedManaged) {
167
252
  addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
168
253
  writes.push({ destination, bytes: entry.bytes });
169
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
170
- nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
254
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
255
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false, legacyHash);
171
256
  continue;
172
257
  }
173
258
 
@@ -190,35 +275,28 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
190
275
  reason: legacyRecord ? "managed-modified" : "unmanaged",
191
276
  });
192
277
  writes.push({ destination, bytes: entry.bytes });
193
- nextManifest.files[entry.relativePath] = {
194
- sha256: sourceHash,
195
- preserve: entry.sourcePath === PROFILE_PATH,
196
- };
278
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
197
279
  }
198
280
 
199
- // Apply all hidden writes and verify their bytes before removing any legacy file.
281
+ // Apply all hidden writes and verify their bytes before changing manifest authority.
200
282
  for (const plan of writes) {
201
283
  await writeFileAtomic(plan.destination, plan.bytes, { dryRun });
202
284
  if (!dryRun) await verifyWrite(plan.destination, plan.bytes);
203
285
  }
286
+ await notifyStage(hooks, "HIDDEN_WRITTEN", { writes: writes.length });
287
+ await notifyStage(hooks, "HIDDEN_VERIFIED", { writes: writes.length });
204
288
 
205
- for (const relativePath of cleanupFiles) {
206
- await assertSafePath(target, relativePath);
207
- const legacyPath = ensureWithin(target, relativePath);
208
- if (!dryRun && await fileExists(legacyPath)) await unlink(legacyPath);
209
- }
210
-
211
- for (const relativePath of cleanupDirectories) {
212
- await removeEmptyLegacyDirectory(target, relativePath, dryRun);
213
- }
214
-
215
- // The manifest is written last so layoutVersion 2 is never authoritative before
216
- // hidden destinations and adapter decisions have been applied.
289
+ // Atomic manifest replacement is the authority switch. Cleanup follows it and
290
+ // remains recoverable because managed legacy hashes are retained in the record.
217
291
  await writeManifest(target, nextManifest, { dryRun });
292
+ await notifyStage(hooks, "MANIFEST_SWITCHED", { cleanupFiles: [...cleanupFiles.keys()] });
293
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
294
+ await notifyStage(hooks, "LEGACY_CLEANED", { cleanupFiles: [...cleanupFiles.keys()] });
295
+ await notifyStage(hooks, "COMPLETE", { cleanupFiles: [...cleanupFiles.keys()] });
218
296
  return { actions, conflicts, manifest: nextManifest };
219
297
  }
220
298
 
221
- export async function runUpdate({ target, dryRun, packageRoot, packageVersion }) {
299
+ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, hooks = {} }) {
222
300
  const currentManifest = await readManifest(target);
223
301
  if (!currentManifest) {
224
302
  throw new Error("No .forgeloop/manifest.json found; run forgeloop init first.");
@@ -226,13 +304,15 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
226
304
 
227
305
  const entries = await readTemplateEntries(packageRoot);
228
306
  if ((currentManifest.layoutVersion ?? 1) < LAYOUT_VERSION) {
229
- return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries });
307
+ return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks });
230
308
  }
231
309
 
232
310
  const nextManifest = structuredClone(currentManifest);
233
311
  const actions = [];
234
312
  const conflicts = [];
235
313
  const plans = [];
314
+ const cleanupFiles = new Map();
315
+ const cleanupDirectories = new Set();
236
316
  const pruneActions = [];
237
317
  const shippedPaths = new Set(entries.map((entry) => entry.relativePath));
238
318
 
@@ -249,10 +329,34 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
249
329
  for (const entry of entries) {
250
330
  const destination = ensureWithin(target, entry.relativePath);
251
331
  await assertSafePath(target, entry.relativePath);
332
+ const hasLegacyAlternative = entry.legacyRelativePath !== entry.relativePath;
333
+ const legacyDestination = hasLegacyAlternative
334
+ ? ensureWithin(target, entry.legacyRelativePath)
335
+ : null;
336
+ if (hasLegacyAlternative) await assertSafePath(target, entry.legacyRelativePath);
252
337
  const sourceHash = sha256(entry.bytes);
253
338
  const record = currentManifest.files[entry.relativePath];
254
339
  const exists = await fileExists(destination);
255
340
 
341
+ if (hasLegacyAlternative && record?.legacySha256) {
342
+ addLegacyCleanupDirectory(cleanupDirectories, entry.legacyRelativePath);
343
+ }
344
+ if (hasLegacyAlternative && await fileExists(legacyDestination)) {
345
+ const legacyHash = sha256(await readBytes(legacyDestination));
346
+ if (record?.legacySha256 && legacyHash === record.legacySha256) {
347
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, record.legacySha256);
348
+ } else {
349
+ conflicts.push({
350
+ code: "E_LEGACY_FILE_MIGRATION_CONFLICT",
351
+ path: entry.legacyRelativePath,
352
+ message: record?.legacySha256
353
+ ? "Legacy file changed after the migration authority switch; it was preserved."
354
+ : "Legacy root file remains without ownership proof; it was preserved.",
355
+ });
356
+ actions.push({ action: "preserve-conflict", path: entry.legacyRelativePath, reason: "legacy-residual" });
357
+ }
358
+ }
359
+
256
360
  if (!exists) {
257
361
  plans.push({
258
362
  action: dryRun ? "would-create" : "created",
@@ -261,6 +365,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
261
365
  record: {
262
366
  sha256: sourceHash,
263
367
  preserve: entry.sourcePath === PROFILE_PATH,
368
+ ...(record?.legacySha256 ? { legacySha256: record.legacySha256 } : {}),
264
369
  },
265
370
  });
266
371
  continue;
@@ -318,5 +423,6 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
318
423
  nextManifest.packageName = PACKAGE_NAME;
319
424
  nextManifest.packageVersion = packageVersion;
320
425
  await writeManifest(target, nextManifest, { dryRun });
426
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
321
427
  return { actions, conflicts, manifest: nextManifest };
322
428
  }
@@ -92,24 +92,35 @@ export function assertCheckList(value, label = "checks") {
92
92
  return value;
93
93
  }
94
94
 
95
- export function requiredChecksSatisfied(checks, requiredIds, { allowInferred = false } = {}) {
95
+ function requiredChecksSatisfiedBy(checks, requiredValues, selector, { allowInferred = false } = {}) {
96
96
  assertCheckList(checks);
97
- if (!Array.isArray(requiredIds)) throw checkError("E_CHECK_INVALID", "requiredIds must be an array");
98
- const byId = new Map(checks.map((check) => [check.id, check]));
97
+ if (!Array.isArray(requiredValues)) throw checkError("E_CHECK_INVALID", "required values must be an array");
99
98
  const errors = [];
100
- for (const id of requiredIds) {
101
- const check = byId.get(id);
99
+ for (const value of requiredValues) {
100
+ const candidates = checks.filter((check) => selector(check) === value);
101
+ const check = candidates.find((candidate) => candidate.status === "passed"
102
+ && (allowInferred || candidate.evidenceKind === "OBSERVED"))
103
+ ?? candidates.find((candidate) => candidate.status === "passed")
104
+ ?? candidates[0];
102
105
  if (!check) {
103
- errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${id}`, [id]));
106
+ errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${value}`, [value]));
104
107
  continue;
105
108
  }
106
109
  if (check.status !== "passed") {
107
- errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${id}`, [id]));
110
+ errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${value}`, [value]));
108
111
  continue;
109
112
  }
110
113
  if (!allowInferred && check.evidenceKind !== "OBSERVED") {
111
- errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${id}`, [id]));
114
+ errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${value}`, [value]));
112
115
  }
113
116
  }
114
117
  return errors;
115
118
  }
119
+
120
+ export function requiredChecksSatisfied(checks, requiredIds, options = {}) {
121
+ return requiredChecksSatisfiedBy(checks, requiredIds, (check) => check.id, options);
122
+ }
123
+
124
+ export function requiredChecksSatisfiedForRequirements(checks, requiredRequirements, options = {}) {
125
+ return requiredChecksSatisfiedBy(checks, requiredRequirements, (check) => check.requirement, options);
126
+ }
@@ -1,5 +1,5 @@
1
1
  import { ARTIFACT_PATHS, canonicalFingerprint } from "./artifacts.js";
2
- import { assertCheckList, requiredChecksSatisfied } from "./checks.js";
2
+ import { assertCheckList, requiredChecksSatisfiedForRequirements } from "./checks.js";
3
3
  import { assertCoverageList, coverageForRequirements } from "./coverage.js";
4
4
  import { assertEvidenceList } from "./evidence.js";
5
5
 
@@ -86,7 +86,7 @@ export function completionRelationshipErrors({
86
86
  addAssertion(errors, () => assertCheckList(receipt.checks, "receipt.checks"), "E_CHECK_INVALID", [ARTIFACT_PATHS.receipt]);
87
87
  addAssertion(errors, () => assertEvidenceList(receipt.evidence ?? [], "receipt.evidence"), "E_EVIDENCE_INVALID", [ARTIFACT_PATHS.receipt]);
88
88
  if (requireRequiredChecks) {
89
- for (const error of requiredChecksSatisfied(receipt.checks, requiredEvidence)) {
89
+ for (const error of requiredChecksSatisfiedForRequirements(receipt.checks, requiredEvidence)) {
90
90
  errors.push(issue(error.code, error.message, [ARTIFACT_PATHS.receipt]));
91
91
  }
92
92
  }
@@ -49,6 +49,10 @@ function validateManifest(manifest) {
49
49
  if (typeof record.preserve !== "boolean") {
50
50
  throw new Error(`Invalid manifest preserve flag for ${relativePath}`);
51
51
  }
52
+ if (record.legacySha256 !== undefined
53
+ && (typeof record.legacySha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.legacySha256))) {
54
+ throw new Error(`Invalid manifest legacy hash for ${relativePath}`);
55
+ }
52
56
  }
53
57
  return manifest;
54
58
  }