@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.
- package/LOOP_ENGINEERING.md +5 -1
- package/README.md +34 -3
- package/THREAT_MODEL.md +1 -1
- package/conformance/README.md +32 -0
- package/package.json +3 -2
- package/src/commands/doctor.js +38 -6
- package/src/commands/update.js +130 -35
- package/src/core/manifest.js +4 -0
package/LOOP_ENGINEERING.md
CHANGED
|
@@ -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
|
|
331
|
-
|
|
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;
|
|
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` |
|
package/conformance/README.md
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
"
|
|
165
|
-
"
|
|
181
|
+
"E_MIGRATION_INCOMPLETE",
|
|
182
|
+
"error",
|
|
166
183
|
entry.legacyRelativePath,
|
|
167
|
-
"Canonical file remains in the legacy root layout;
|
|
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
|
-
"
|
|
219
|
-
"
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
154
|
-
nextManifest.files[entry.relativePath] =
|
|
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
|
-
|
|
162
|
-
nextManifest.files[entry.relativePath] =
|
|
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
|
-
|
|
170
|
-
nextManifest.files[entry.relativePath] =
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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
|
}
|
package/src/core/manifest.js
CHANGED
|
@@ -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
|
}
|