@cassiomc1/forgeloop 0.1.8 → 0.1.9

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,13 @@ 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 + verify<br/>hidden kit"]
65
+ hidden --> authority["atomic manifest<br/>authority switch"]
66
+ authority --> cleanup["hash-checked<br/>legacy cleanup"]
67
+ cleanup --> recovered["healthy or<br/>recoverable"]
68
+ migration -->|layout v2| retry["update + retry<br/>owned cleanup"]
62
69
  entry --> discovery["discovery +<br/>project profile"]
63
70
  discovery --> contract["current<br/>contract"]
64
71
  contract --> routing["deterministic<br/>route"]
@@ -97,6 +104,7 @@ flowchart TB
97
104
 
98
105
  classDef root fill:#08090C,stroke:#6E6AF5,stroke-width:3px,color:#EDEEF0;
99
106
  classDef entry fill:#101218,stroke:#3EDBB8,stroke-width:2px,color:#EDEEF0;
107
+ classDef migration fill:#0F766E,stroke:#5EEAD4,stroke-width:2px,color:#FFFFFF;
100
108
  classDef routing fill:#4F46E5,stroke:#A5B4FC,stroke-width:2px,color:#FFFFFF;
101
109
  classDef state fill:#373A46,stroke:#A1A1AA,stroke-width:2px,color:#FFFFFF;
102
110
  classDef evidence fill:#3EDBB8,stroke:#99F6E4,stroke-width:2px,color:#08090C;
@@ -107,6 +115,7 @@ flowchart TB
107
115
 
108
116
  class root root;
109
117
  class entry,discovery,delegation entry;
118
+ class migration,plan,hidden,authority,cleanup,recovered,retry migration;
110
119
  class routing,preflight,ready routing;
111
120
  class state,events,lifecycle state;
112
121
  class checks,receipt,audit evidence;
@@ -118,6 +127,9 @@ flowchart TB
118
127
  ```
119
128
 
120
129
  Equivalent reading for text-only environments: adapters load the canonical kit;
130
+ an older target follows validate paths → write and verify hidden files → switch
131
+ manifest authority → hash-checked cleanup, while an interrupted migration is
132
+ diagnosed and retried by `doctor`/`update`;
121
133
  discovery creates the contract and deterministic route; contract, route, and
122
134
  required gates must produce `PREFLIGHT_READY` before the resumable state and
123
135
  append-only event ledger authorize the lifecycle. Verification produces
@@ -145,7 +157,12 @@ project without overwriting local instructions. When the package is available
145
157
  in the npm registry, use the commands below; otherwise use the repository
146
158
  checkout fallback.
147
159
 
160
+ The current published release is `@cassiomc1/forgeloop@0.1.9`.
161
+ Pin this version when a reproducible blind run or release-identity check is
162
+ required:
163
+
148
164
  ```bash
165
+ npx @cassiomc1/forgeloop@0.1.9 --version
149
166
  npx @cassiomc1/forgeloop init
150
167
  npx @cassiomc1/forgeloop doctor
151
168
  npx @cassiomc1/forgeloop update
@@ -327,8 +344,17 @@ node src/cli.js update
327
344
  The release workflow uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers)
328
345
  through GitHub Actions OIDC. Before the first release, register this repository
329
346
  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.
347
+ match `package.json`. After publishing, verify the complete immutable release
348
+ identity before a blind run:
349
+
350
+ ```bash
351
+ RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
352
+ npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
353
+ ```
354
+
355
+ Only `RELEASE_IDENTITY_VALID` is sufficient. The read-only check compares the
356
+ release commit and GitHub tag with npm's version, `gitHead`, tarball URL,
357
+ SHA-1, and SHA-512 integrity; it never publishes or changes a tag.
332
358
 
333
359
  The commands above use the current directory. To install into another existing
334
360
  project directory, pass a relative or absolute `--path`:
@@ -360,7 +386,12 @@ reference.
360
386
  Targets created by an older package layout are migrated by `update`: unchanged
361
387
  managed root files move into the hidden kit, while modified or unowned root
362
388
  files are preserved and reported as conflicts. The migration never follows a
363
- symlink or deletes a file whose managed hash no longer matches.
389
+ symlink or deletes a file whose managed hash no longer matches. The migration
390
+ writes and verifies the complete hidden plan, atomically switches the manifest
391
+ authority, and only then cleans owned legacy files. If a process stops between
392
+ those stages, `doctor` reports `E_MIGRATION_INCOMPLETE` and the next `update`
393
+ retries cleanup only when the recorded legacy hash still matches; modified or
394
+ unowned files remain for manual review.
364
395
 
365
396
  ### Migrate an existing mdfiles installation
366
397
 
package/THREAT_MODEL.md CHANGED
@@ -12,7 +12,7 @@ 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
16
  | 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
17
  | 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
18
  | 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` |
@@ -4,6 +4,10 @@ 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
+
7
11
  Run a scenario in a disposable target using the Standard profile first:
8
12
 
9
13
  ```bash
@@ -43,6 +47,34 @@ diagnostic records belong under [`conformance/runs/`](./runs/); they must not
43
47
  contain secrets, credentials, hidden reasoning, or unnecessary conversation
44
48
  history.
45
49
 
50
+ ## Release identity evidence
51
+
52
+ Every live-run report records the exact published package used by the target.
53
+ Include all of these fields before sending the blind prompt:
54
+
55
+ ```text
56
+ package: @cassiomc1/forgeloop@X.Y.Z
57
+ npm gitHead: <40-character commit SHA>
58
+ release commit: <40-character commit SHA>
59
+ GitHub tag: vX.Y.Z -> <40-character commit SHA>
60
+ tarball URL: https://registry.npmjs.org/...
61
+ tarball SHA-1: <40-character hex digest>
62
+ npm SHA-512 integrity: sha512-<base64 digest>
63
+ release identity: RELEASE_IDENTITY_VALID
64
+ ```
65
+
66
+ Run the repository's read-only verifier against the exact release commit:
67
+
68
+ ```bash
69
+ RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
70
+ npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
71
+ ```
72
+
73
+ Do not interpret a local package version, a green build, or a tarball URL by
74
+ itself as publication proof. If the verifier cannot establish every identity
75
+ field, record `RELEASE_IDENTITY_NOT_VERIFIED` or
76
+ `RELEASE_IDENTITY_INVALID` and do not start the blind run.
77
+
46
78
  The complete-website scenario deliberately fails when implementation starts
47
79
  before the contract, route, and required gates exist.
48
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cassiomc1/forgeloop",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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
  }
@@ -43,6 +43,10 @@ async function removeEmptyLegacyDirectory(target, relativePath, dryRun) {
43
43
 
44
44
  function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
45
45
  cleanupFiles.add(relativePath);
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,7 +54,37 @@ 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 }) {
70
+ for (const relativePath of cleanupFiles) {
71
+ await assertSafePath(target, relativePath);
72
+ if (typeof hooks?.beforeCleanup === "function") {
73
+ await hooks.beforeCleanup(relativePath);
74
+ }
75
+ const legacyPath = ensureWithin(target, relativePath);
76
+ if (!dryRun && await fileExists(legacyPath)) await unlink(legacyPath);
77
+ }
78
+
79
+ for (const relativePath of cleanupDirectories) {
80
+ if (typeof hooks?.beforeCleanupDirectory === "function") {
81
+ await hooks.beforeCleanupDirectory(relativePath);
82
+ }
83
+ await removeEmptyLegacyDirectory(target, relativePath, dryRun);
84
+ }
85
+ }
86
+
87
+ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks = {} }) {
54
88
  const nextManifest = createManifest(packageVersion);
55
89
  const actions = [];
56
90
  const conflicts = [];
@@ -86,6 +120,11 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
86
120
 
87
121
  const currentBytes = await readBytes(destination);
88
122
  const currentHash = sha256(currentBytes);
123
+ if (legacyRecord && currentHash === sourceHash) {
124
+ actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
125
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false);
126
+ continue;
127
+ }
89
128
  if (legacyRecord && currentHash === legacyRecord.sha256) {
90
129
  if (currentHash === sourceHash) {
91
130
  actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
@@ -114,32 +153,67 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
114
153
  if (destinationExists) {
115
154
  const currentBytes = await readBytes(destination);
116
155
  const currentHash = sha256(currentBytes);
117
- if (!destinationRecord && currentHash !== sourceHash && entry.sourcePath !== PROFILE_PATH) {
156
+ const hiddenMatchesSource = currentHash === sourceHash;
157
+ const legacyBytes = legacyExists ? await readBytes(legacyDestination) : null;
158
+ const legacyHash = legacyBytes ? sha256(legacyBytes) : null;
159
+ const unchangedManagedLegacy = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
160
+
161
+ if (entry.sourcePath === PROFILE_PATH
162
+ && legacyExists
163
+ && legacyRecord
164
+ && currentHash === legacyHash) {
165
+ actions.push({ action: "skip", path: entry.relativePath, reason: "profile-move-resumed" });
166
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
167
+ nextManifest.files[entry.relativePath] = manifestRecord(currentHash, true, legacyHash);
168
+ continue;
169
+ }
170
+
171
+ if (!hiddenMatchesSource && !destinationRecord && entry.sourcePath !== PROFILE_PATH) {
118
172
  conflicts.push(migrationConflict(
119
173
  "E_HIDDEN_KIT_MIGRATION_CONFLICT",
120
174
  entry.relativePath,
121
175
  "Existing hidden kit file is unmanaged and was not overwritten.",
122
176
  ));
123
177
  addAction(actions, dryRun, "preserve-conflict", entry.relativePath, { reason: "hidden-unmanaged" });
178
+ } else if (hiddenMatchesSource && legacyExists && unchangedManagedLegacy) {
179
+ actions.push({ action: "skip", path: entry.relativePath, reason: "hidden-ready" });
180
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
181
+ nextManifest.files[entry.relativePath] = manifestRecord(
182
+ sourceHash,
183
+ entry.sourcePath === PROFILE_PATH,
184
+ legacyHash,
185
+ );
186
+ continue;
187
+ } else if (hiddenMatchesSource && legacyExists) {
188
+ const conflict = entry.sourcePath === PROFILE_PATH
189
+ ? migrationConflict(
190
+ "E_PROFILE_MIGRATION_CONFLICT",
191
+ entry.legacyRelativePath,
192
+ "A legacy project profile remains beside the hidden kit without a matching managed hash; both copies were preserved.",
193
+ )
194
+ : migrationConflict(
195
+ "E_LEGACY_FILE_MIGRATION_CONFLICT",
196
+ entry.legacyRelativePath,
197
+ "A legacy file remains beside the hidden kit without a matching managed hash; it was preserved.",
198
+ );
199
+ conflicts.push(conflict);
200
+ addAction(actions, dryRun, "preserve-conflict", entry.legacyRelativePath, { reason: "legacy-residual" });
124
201
  } else {
125
202
  actions.push({ action: "skip", path: entry.relativePath, reason: "already-present" });
126
203
  }
127
- nextManifest.files[entry.relativePath] = {
128
- sha256: currentHash,
129
- preserve: entry.sourcePath === PROFILE_PATH
204
+ nextManifest.files[entry.relativePath] = manifestRecord(
205
+ currentHash,
206
+ entry.sourcePath === PROFILE_PATH
130
207
  || !destinationRecord
131
208
  || Boolean(destinationRecord.preserve),
132
- };
209
+ );
133
210
  continue;
134
211
  }
135
212
 
136
213
  if (!legacyExists) {
137
214
  addAction(actions, dryRun, "create", entry.relativePath);
138
215
  writes.push({ destination, bytes: entry.bytes });
139
- nextManifest.files[entry.relativePath] = {
140
- sha256: sourceHash,
141
- preserve: entry.sourcePath === PROFILE_PATH,
142
- };
216
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
143
217
  continue;
144
218
  }
145
219
 
@@ -150,24 +224,24 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
150
224
  if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
151
225
  addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
152
226
  writes.push({ destination, bytes: legacyBytes });
153
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
154
- nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
227
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
228
+ nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
155
229
  continue;
156
230
  }
157
231
 
158
232
  if (legacyRecord?.preserve && unchangedManaged) {
159
233
  addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
160
234
  writes.push({ destination, bytes: legacyBytes });
161
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
162
- nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
235
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
236
+ nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
163
237
  continue;
164
238
  }
165
239
 
166
240
  if (unchangedManaged) {
167
241
  addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
168
242
  writes.push({ destination, bytes: entry.bytes });
169
- if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
170
- nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
243
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
244
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false, legacyHash);
171
245
  continue;
172
246
  }
173
247
 
@@ -190,35 +264,28 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
190
264
  reason: legacyRecord ? "managed-modified" : "unmanaged",
191
265
  });
192
266
  writes.push({ destination, bytes: entry.bytes });
193
- nextManifest.files[entry.relativePath] = {
194
- sha256: sourceHash,
195
- preserve: entry.sourcePath === PROFILE_PATH,
196
- };
267
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
197
268
  }
198
269
 
199
- // Apply all hidden writes and verify their bytes before removing any legacy file.
270
+ // Apply all hidden writes and verify their bytes before changing manifest authority.
200
271
  for (const plan of writes) {
201
272
  await writeFileAtomic(plan.destination, plan.bytes, { dryRun });
202
273
  if (!dryRun) await verifyWrite(plan.destination, plan.bytes);
203
274
  }
275
+ await notifyStage(hooks, "HIDDEN_WRITTEN", { writes: writes.length });
276
+ await notifyStage(hooks, "HIDDEN_VERIFIED", { writes: writes.length });
204
277
 
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.
278
+ // Atomic manifest replacement is the authority switch. Cleanup follows it and
279
+ // remains recoverable because managed legacy hashes are retained in the record.
217
280
  await writeManifest(target, nextManifest, { dryRun });
281
+ await notifyStage(hooks, "MANIFEST_SWITCHED", { cleanupFiles: [...cleanupFiles] });
282
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks });
283
+ await notifyStage(hooks, "LEGACY_CLEANED", { cleanupFiles: [...cleanupFiles] });
284
+ await notifyStage(hooks, "COMPLETE", { cleanupFiles: [...cleanupFiles] });
218
285
  return { actions, conflicts, manifest: nextManifest };
219
286
  }
220
287
 
221
- export async function runUpdate({ target, dryRun, packageRoot, packageVersion }) {
288
+ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, hooks = {} }) {
222
289
  const currentManifest = await readManifest(target);
223
290
  if (!currentManifest) {
224
291
  throw new Error("No .forgeloop/manifest.json found; run forgeloop init first.");
@@ -226,13 +293,15 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
226
293
 
227
294
  const entries = await readTemplateEntries(packageRoot);
228
295
  if ((currentManifest.layoutVersion ?? 1) < LAYOUT_VERSION) {
229
- return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries });
296
+ return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks });
230
297
  }
231
298
 
232
299
  const nextManifest = structuredClone(currentManifest);
233
300
  const actions = [];
234
301
  const conflicts = [];
235
302
  const plans = [];
303
+ const cleanupFiles = new Set();
304
+ const cleanupDirectories = new Set();
236
305
  const pruneActions = [];
237
306
  const shippedPaths = new Set(entries.map((entry) => entry.relativePath));
238
307
 
@@ -249,10 +318,34 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
249
318
  for (const entry of entries) {
250
319
  const destination = ensureWithin(target, entry.relativePath);
251
320
  await assertSafePath(target, entry.relativePath);
321
+ const hasLegacyAlternative = entry.legacyRelativePath !== entry.relativePath;
322
+ const legacyDestination = hasLegacyAlternative
323
+ ? ensureWithin(target, entry.legacyRelativePath)
324
+ : null;
325
+ if (hasLegacyAlternative) await assertSafePath(target, entry.legacyRelativePath);
252
326
  const sourceHash = sha256(entry.bytes);
253
327
  const record = currentManifest.files[entry.relativePath];
254
328
  const exists = await fileExists(destination);
255
329
 
330
+ if (hasLegacyAlternative && record?.legacySha256) {
331
+ addLegacyCleanupDirectory(cleanupDirectories, entry.legacyRelativePath);
332
+ }
333
+ if (hasLegacyAlternative && await fileExists(legacyDestination)) {
334
+ const legacyHash = sha256(await readBytes(legacyDestination));
335
+ if (record?.legacySha256 && legacyHash === record.legacySha256) {
336
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
337
+ } else {
338
+ conflicts.push({
339
+ code: "E_LEGACY_FILE_MIGRATION_CONFLICT",
340
+ path: entry.legacyRelativePath,
341
+ message: record?.legacySha256
342
+ ? "Legacy file changed after the migration authority switch; it was preserved."
343
+ : "Legacy root file remains without ownership proof; it was preserved.",
344
+ });
345
+ actions.push({ action: "preserve-conflict", path: entry.legacyRelativePath, reason: "legacy-residual" });
346
+ }
347
+ }
348
+
256
349
  if (!exists) {
257
350
  plans.push({
258
351
  action: dryRun ? "would-create" : "created",
@@ -261,6 +354,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
261
354
  record: {
262
355
  sha256: sourceHash,
263
356
  preserve: entry.sourcePath === PROFILE_PATH,
357
+ ...(record?.legacySha256 ? { legacySha256: record.legacySha256 } : {}),
264
358
  },
265
359
  });
266
360
  continue;
@@ -318,5 +412,6 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
318
412
  nextManifest.packageName = PACKAGE_NAME;
319
413
  nextManifest.packageVersion = packageVersion;
320
414
  await writeManifest(target, nextManifest, { dryRun });
415
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks });
321
416
  return { actions, conflicts, manifest: nextManifest };
322
417
  }
@@ -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
  }