@cassiomc1/forgeloop 0.1.6 → 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/AGENT_COMPATIBILITY.md +28 -16
- package/ENG/taste-frontend-eng.md +139 -0
- package/EXECUTION_STATE.md +12 -5
- package/GUIDE_ROUTER.md +24 -1
- package/LOOP_ENGINEERING.md +47 -0
- package/LOOP_SYSTEM_DESIGN.md +50 -49
- package/PROJECT_PROFILE.md +27 -12
- package/QUALITY_SCORECARD.md +7 -0
- package/README.md +174 -77
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/THREAT_MODEL.md +1 -1
- package/conformance/README.md +39 -1
- package/conformance/blind-premium-website/EXPECTED_ROUTE.json +1 -1
- package/conformance/complete-website/EXPECTED_ROUTE.json +1 -1
- package/conformance/runs/2026-08-13-codex-sixth-live.md +412 -0
- package/package.json +3 -2
- package/src/cli.js +2 -1
- package/src/commands/doctor.js +142 -13
- package/src/commands/init.js +12 -5
- package/src/commands/update.js +311 -3
- package/src/commands/validate-protocol.js +19 -2
- package/src/core/audit.js +16 -1
- package/src/core/events.js +8 -0
- package/src/core/guide-metadata.js +1 -0
- package/src/core/inspect.js +16 -7
- package/src/core/manifest.js +10 -0
- package/src/core/native-adapters.js +74 -0
- package/src/core/next-action.js +42 -1
- package/src/core/preflight.js +220 -46
- package/src/core/profile.js +12 -4
- package/src/core/protocol.js +8 -0
- package/src/core/report.js +4 -1
- package/src/core/resumability.js +61 -0
- package/src/core/route-artifact.js +9 -2
- package/src/core/router.js +2 -1
- package/src/core/target-layout.js +38 -0
- package/src/core/templates.js +14 -2
package/src/commands/update.js
CHANGED
|
@@ -1,25 +1,307 @@
|
|
|
1
|
+
import { rmdir, unlink } from "node:fs/promises";
|
|
2
|
+
|
|
1
3
|
import { assertSafePath, fileExists, ensureWithin, readBytes, writeFileAtomic } from "../core/filesystem.js";
|
|
2
4
|
import {
|
|
5
|
+
createManifest,
|
|
3
6
|
PACKAGE_NAME,
|
|
4
7
|
readManifest,
|
|
5
8
|
sha256,
|
|
6
9
|
writeManifest,
|
|
7
10
|
} from "../core/manifest.js";
|
|
8
11
|
import { readTemplateEntries } from "../core/templates.js";
|
|
12
|
+
import { isNativeAdapterPath, LAYOUT_VERSION } from "../core/target-layout.js";
|
|
9
13
|
|
|
10
14
|
const PROFILE_PATH = "PROJECT_PROFILE.md";
|
|
15
|
+
const LEGACY_CLEANUP_DIRECTORIES = Object.freeze(["ENG", "schemas"]);
|
|
16
|
+
|
|
17
|
+
function migrationConflict(code, path, message) {
|
|
18
|
+
return { code, path, message };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function addAction(actions, dryRun, action, path, details = {}) {
|
|
22
|
+
actions.push({ action: dryRun ? `would-${action}` : action, path, ...details });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function verifyWrite(filePath, expectedBytes) {
|
|
26
|
+
const actualBytes = await readBytes(filePath);
|
|
27
|
+
if (!actualBytes.equals(expectedBytes)) {
|
|
28
|
+
const error = new Error(`Migration write verification failed for ${filePath}`);
|
|
29
|
+
error.code = "E_MIGRATION_WRITE_VERIFY";
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function removeEmptyLegacyDirectory(target, relativePath, dryRun) {
|
|
35
|
+
await assertSafePath(target, relativePath);
|
|
36
|
+
if (dryRun) return;
|
|
37
|
+
try {
|
|
38
|
+
await rmdir(ensureWithin(target, relativePath));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
|
|
45
|
+
cleanupFiles.add(relativePath);
|
|
46
|
+
addLegacyCleanupDirectory(cleanupDirectories, relativePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addLegacyCleanupDirectory(cleanupDirectories, relativePath) {
|
|
50
|
+
for (const directory of LEGACY_CLEANUP_DIRECTORIES) {
|
|
51
|
+
if (relativePath === directory || relativePath.startsWith(`${directory}/`)) {
|
|
52
|
+
cleanupDirectories.add(directory);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
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 = {} }) {
|
|
88
|
+
const nextManifest = createManifest(packageVersion);
|
|
89
|
+
const actions = [];
|
|
90
|
+
const conflicts = [];
|
|
91
|
+
const writes = [];
|
|
92
|
+
const cleanupFiles = new Set();
|
|
93
|
+
const cleanupDirectories = new Set();
|
|
94
|
+
|
|
95
|
+
// Validate every path before creating the migration plan or touching data.
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
await assertSafePath(target, entry.relativePath);
|
|
98
|
+
if (entry.legacyRelativePath !== entry.relativePath) {
|
|
99
|
+
await assertSafePath(target, entry.legacyRelativePath);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const destination = ensureWithin(target, entry.relativePath);
|
|
105
|
+
const legacyDestination = ensureWithin(target, entry.legacyRelativePath);
|
|
106
|
+
const sourceHash = sha256(entry.bytes);
|
|
107
|
+
const destinationExists = await fileExists(destination);
|
|
108
|
+
const legacyExists = entry.legacyRelativePath !== entry.relativePath
|
|
109
|
+
&& await fileExists(legacyDestination);
|
|
110
|
+
const legacyRecord = currentManifest.files[entry.legacyRelativePath];
|
|
111
|
+
const destinationRecord = currentManifest.files[entry.relativePath];
|
|
112
|
+
|
|
113
|
+
if (isNativeAdapterPath(entry.relativePath)) {
|
|
114
|
+
if (!destinationExists) {
|
|
115
|
+
addAction(actions, dryRun, "create", entry.relativePath);
|
|
116
|
+
writes.push({ destination, bytes: entry.bytes });
|
|
117
|
+
nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const currentBytes = await readBytes(destination);
|
|
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
|
+
}
|
|
128
|
+
if (legacyRecord && currentHash === legacyRecord.sha256) {
|
|
129
|
+
if (currentHash === sourceHash) {
|
|
130
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
|
|
131
|
+
} else {
|
|
132
|
+
addAction(actions, dryRun, "update-adapter", entry.relativePath);
|
|
133
|
+
writes.push({ destination, bytes: entry.bytes });
|
|
134
|
+
}
|
|
135
|
+
nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
conflicts.push(migrationConflict(
|
|
140
|
+
"E_NATIVE_ADAPTER_MIGRATION_CONFLICT",
|
|
141
|
+
entry.relativePath,
|
|
142
|
+
legacyRecord
|
|
143
|
+
? "Managed native adapter was modified; it was preserved and was not silently overwritten."
|
|
144
|
+
: "Native adapter is not owned by the legacy manifest; it was preserved and was not silently adopted.",
|
|
145
|
+
));
|
|
146
|
+
addAction(actions, dryRun, "preserve-conflict", entry.relativePath, {
|
|
147
|
+
reason: legacyRecord ? "managed-modified" : "unmanaged",
|
|
148
|
+
});
|
|
149
|
+
if (legacyRecord) nextManifest.files[entry.relativePath] = { ...legacyRecord };
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (destinationExists) {
|
|
154
|
+
const currentBytes = await readBytes(destination);
|
|
155
|
+
const currentHash = sha256(currentBytes);
|
|
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) {
|
|
172
|
+
conflicts.push(migrationConflict(
|
|
173
|
+
"E_HIDDEN_KIT_MIGRATION_CONFLICT",
|
|
174
|
+
entry.relativePath,
|
|
175
|
+
"Existing hidden kit file is unmanaged and was not overwritten.",
|
|
176
|
+
));
|
|
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" });
|
|
201
|
+
} else {
|
|
202
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "already-present" });
|
|
203
|
+
}
|
|
204
|
+
nextManifest.files[entry.relativePath] = manifestRecord(
|
|
205
|
+
currentHash,
|
|
206
|
+
entry.sourcePath === PROFILE_PATH
|
|
207
|
+
|| !destinationRecord
|
|
208
|
+
|| Boolean(destinationRecord.preserve),
|
|
209
|
+
);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!legacyExists) {
|
|
214
|
+
addAction(actions, dryRun, "create", entry.relativePath);
|
|
215
|
+
writes.push({ destination, bytes: entry.bytes });
|
|
216
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
11
219
|
|
|
12
|
-
|
|
220
|
+
const legacyBytes = await readBytes(legacyDestination);
|
|
221
|
+
const legacyHash = sha256(legacyBytes);
|
|
222
|
+
const unchangedManaged = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
|
|
223
|
+
|
|
224
|
+
if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
|
|
225
|
+
addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
|
|
226
|
+
writes.push({ destination, bytes: legacyBytes });
|
|
227
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
|
|
228
|
+
nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (legacyRecord?.preserve && unchangedManaged) {
|
|
233
|
+
addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
|
|
234
|
+
writes.push({ destination, bytes: legacyBytes });
|
|
235
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
|
|
236
|
+
nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (unchangedManaged) {
|
|
241
|
+
addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
|
|
242
|
+
writes.push({ destination, bytes: entry.bytes });
|
|
243
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
|
|
244
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false, legacyHash);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const conflict = entry.sourcePath === PROFILE_PATH
|
|
249
|
+
? migrationConflict(
|
|
250
|
+
"E_PROFILE_MIGRATION_CONFLICT",
|
|
251
|
+
entry.legacyRelativePath,
|
|
252
|
+
"Project profile is not owned by the legacy manifest; it was preserved and was not overwritten or deleted.",
|
|
253
|
+
)
|
|
254
|
+
: migrationConflict(
|
|
255
|
+
"E_LEGACY_FILE_MIGRATION_CONFLICT",
|
|
256
|
+
entry.legacyRelativePath,
|
|
257
|
+
legacyRecord
|
|
258
|
+
? "Managed legacy file was modified; it was preserved while the hidden canonical file was installed."
|
|
259
|
+
: "Unmanaged legacy file was preserved while the hidden canonical file was installed.",
|
|
260
|
+
);
|
|
261
|
+
conflicts.push(conflict);
|
|
262
|
+
addAction(actions, dryRun, "preserve-conflict", entry.legacyRelativePath, {
|
|
263
|
+
to: entry.relativePath,
|
|
264
|
+
reason: legacyRecord ? "managed-modified" : "unmanaged",
|
|
265
|
+
});
|
|
266
|
+
writes.push({ destination, bytes: entry.bytes });
|
|
267
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Apply all hidden writes and verify their bytes before changing manifest authority.
|
|
271
|
+
for (const plan of writes) {
|
|
272
|
+
await writeFileAtomic(plan.destination, plan.bytes, { dryRun });
|
|
273
|
+
if (!dryRun) await verifyWrite(plan.destination, plan.bytes);
|
|
274
|
+
}
|
|
275
|
+
await notifyStage(hooks, "HIDDEN_WRITTEN", { writes: writes.length });
|
|
276
|
+
await notifyStage(hooks, "HIDDEN_VERIFIED", { writes: writes.length });
|
|
277
|
+
|
|
278
|
+
// Atomic manifest replacement is the authority switch. Cleanup follows it and
|
|
279
|
+
// remains recoverable because managed legacy hashes are retained in the record.
|
|
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] });
|
|
285
|
+
return { actions, conflicts, manifest: nextManifest };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function runUpdate({ target, dryRun, packageRoot, packageVersion, hooks = {} }) {
|
|
13
289
|
const currentManifest = await readManifest(target);
|
|
14
290
|
if (!currentManifest) {
|
|
15
291
|
throw new Error("No .forgeloop/manifest.json found; run forgeloop init first.");
|
|
16
292
|
}
|
|
17
293
|
|
|
18
294
|
const entries = await readTemplateEntries(packageRoot);
|
|
295
|
+
if ((currentManifest.layoutVersion ?? 1) < LAYOUT_VERSION) {
|
|
296
|
+
return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks });
|
|
297
|
+
}
|
|
298
|
+
|
|
19
299
|
const nextManifest = structuredClone(currentManifest);
|
|
20
300
|
const actions = [];
|
|
21
301
|
const conflicts = [];
|
|
22
302
|
const plans = [];
|
|
303
|
+
const cleanupFiles = new Set();
|
|
304
|
+
const cleanupDirectories = new Set();
|
|
23
305
|
const pruneActions = [];
|
|
24
306
|
const shippedPaths = new Set(entries.map((entry) => entry.relativePath));
|
|
25
307
|
|
|
@@ -36,10 +318,34 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
36
318
|
for (const entry of entries) {
|
|
37
319
|
const destination = ensureWithin(target, entry.relativePath);
|
|
38
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);
|
|
39
326
|
const sourceHash = sha256(entry.bytes);
|
|
40
327
|
const record = currentManifest.files[entry.relativePath];
|
|
41
328
|
const exists = await fileExists(destination);
|
|
42
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
|
+
|
|
43
349
|
if (!exists) {
|
|
44
350
|
plans.push({
|
|
45
351
|
action: dryRun ? "would-create" : "created",
|
|
@@ -47,7 +353,8 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
47
353
|
entry,
|
|
48
354
|
record: {
|
|
49
355
|
sha256: sourceHash,
|
|
50
|
-
preserve: entry.
|
|
356
|
+
preserve: entry.sourcePath === PROFILE_PATH,
|
|
357
|
+
...(record?.legacySha256 ? { legacySha256: record.legacySha256 } : {}),
|
|
51
358
|
},
|
|
52
359
|
});
|
|
53
360
|
continue;
|
|
@@ -58,7 +365,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
58
365
|
continue;
|
|
59
366
|
}
|
|
60
367
|
|
|
61
|
-
if (record.preserve || entry.
|
|
368
|
+
if (record.preserve || entry.sourcePath === PROFILE_PATH) {
|
|
62
369
|
actions.push({ action: "skip", path: entry.relativePath, reason: "preserved" });
|
|
63
370
|
continue;
|
|
64
371
|
}
|
|
@@ -105,5 +412,6 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
105
412
|
nextManifest.packageName = PACKAGE_NAME;
|
|
106
413
|
nextManifest.packageVersion = packageVersion;
|
|
107
414
|
await writeManifest(target, nextManifest, { dryRun });
|
|
415
|
+
await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks });
|
|
108
416
|
return { actions, conflicts, manifest: nextManifest };
|
|
109
417
|
}
|
|
@@ -6,6 +6,8 @@ import { assertRouteInvariants } from "../core/router.js";
|
|
|
6
6
|
import { assertWorkStateSemantics, classifyLoadedWorkState } from "../core/work-state.js";
|
|
7
7
|
import { validateReceipt } from "../core/receipt.js";
|
|
8
8
|
import { validateTaskBrief, validateDelegatedResult } from "../core/delegation.js";
|
|
9
|
+
import { ARTIFACT_PATHS, readJsonArtifact } from "../core/artifacts.js";
|
|
10
|
+
import { evaluatePreflight, validateReadyProtocolConsistency } from "../core/preflight.js";
|
|
9
11
|
|
|
10
12
|
async function readArtifact(target, relativePath, label) {
|
|
11
13
|
if (!relativePath) return null;
|
|
@@ -102,11 +104,26 @@ export async function runValidateProtocol({
|
|
|
102
104
|
taskBriefs,
|
|
103
105
|
delegatedResults,
|
|
104
106
|
});
|
|
105
|
-
|
|
107
|
+
let readyConsistencyErrors = [];
|
|
108
|
+
try {
|
|
109
|
+
const persistedPreflight = await readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot);
|
|
110
|
+
if (persistedPreflight.value.status === "READY") {
|
|
111
|
+
readyConsistencyErrors = await validateReadyProtocolConsistency({
|
|
112
|
+
target,
|
|
113
|
+
packageRoot,
|
|
114
|
+
persisted: persistedPreflight.value,
|
|
115
|
+
current: await evaluatePreflight({ target, packageRoot }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// A missing or invalid preflight is already outside the optional protocol set.
|
|
120
|
+
}
|
|
121
|
+
if (readErrors.length > 0 || schemaErrors.length > 0 || readyConsistencyErrors.length > 0) {
|
|
106
122
|
return {
|
|
107
123
|
...result,
|
|
108
124
|
status: "INVALID",
|
|
109
|
-
errors: [...result.errors, ...readErrors, ...schemaErrors
|
|
125
|
+
errors: [...result.errors, ...readErrors, ...schemaErrors, ...readyConsistencyErrors]
|
|
126
|
+
.sort((left, right) => left.code.localeCompare(right.code) || left.message.localeCompare(right.message)),
|
|
110
127
|
};
|
|
111
128
|
}
|
|
112
129
|
return result;
|
package/src/core/audit.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readManifest } from "./manifest.js";
|
|
|
4
4
|
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
5
5
|
import { readJsonArtifact } from "./artifacts.js";
|
|
6
6
|
import { currentChangedPaths } from "./repository.js";
|
|
7
|
+
import { validateReadyProtocolConsistency } from "./preflight.js";
|
|
7
8
|
|
|
8
9
|
function sortErrors(errors) {
|
|
9
10
|
return [...errors].sort((left, right) => left.code.localeCompare(right.code)
|
|
@@ -42,7 +43,21 @@ export async function evaluateAudit({ target, packageRoot, strict = false } = {}
|
|
|
42
43
|
} catch (error) {
|
|
43
44
|
manifestError = error.message;
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
let readyConsistencyErrors = [];
|
|
47
|
+
try {
|
|
48
|
+
const persistedPreflight = await readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot);
|
|
49
|
+
if (persistedPreflight.value.status === "READY") {
|
|
50
|
+
readyConsistencyErrors = await validateReadyProtocolConsistency({
|
|
51
|
+
target,
|
|
52
|
+
packageRoot,
|
|
53
|
+
persisted: persistedPreflight.value,
|
|
54
|
+
current: completion.preflight,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Completion already reports missing or invalid preflight artifacts.
|
|
59
|
+
}
|
|
60
|
+
const errors = sortErrors([...completion.errors, ...readyConsistencyErrors]);
|
|
46
61
|
const changedPaths = await compareChangedPaths(target, packageRoot);
|
|
47
62
|
if (changedPaths.status === "MISMATCH") {
|
|
48
63
|
errors.push({
|
package/src/core/events.js
CHANGED
|
@@ -19,6 +19,14 @@ export const LIFECYCLE_MILESTONES = Object.freeze([
|
|
|
19
19
|
"VERIFICATION_RECORDED",
|
|
20
20
|
"COMPLETION_VALIDATED",
|
|
21
21
|
]);
|
|
22
|
+
export const ACTIVATION_EVENT_MATRIX = Object.freeze([
|
|
23
|
+
Object.freeze({ stage: "task received", event: "TASK_RECEIVED", requiredFor: "new activation" }),
|
|
24
|
+
Object.freeze({ stage: "contract validated", event: "CONTRACT_VALIDATED", requiredFor: "preflight readiness" }),
|
|
25
|
+
Object.freeze({ stage: "route validated", event: "ROUTE_VALIDATED", requiredFor: "preflight readiness" }),
|
|
26
|
+
Object.freeze({ stage: "gate satisfied", event: "GATE_SATISFIED", requiredFor: "each satisfied gate" }),
|
|
27
|
+
Object.freeze({ stage: "preflight blocked", event: "PREFLIGHT_BLOCKED", requiredFor: "blocked activation" }),
|
|
28
|
+
Object.freeze({ stage: "preflight ready", event: "PREFLIGHT_READY", requiredFor: "resumable readiness" }),
|
|
29
|
+
]);
|
|
22
30
|
const REPEATABLE_MILESTONES = new Set(["VERIFICATION_RECORDED"]);
|
|
23
31
|
|
|
24
32
|
function eventHash(event) {
|
package/src/core/inspect.js
CHANGED
|
@@ -6,8 +6,9 @@ import { inspectSchemaHealth } from "./schema-validation.js";
|
|
|
6
6
|
import { readAndClassifyWorkState, WORK_STATE_PATH } from "./work-state.js";
|
|
7
7
|
import { createEvidence } from "./evidence.js";
|
|
8
8
|
import { runDoctor } from "../commands/doctor.js";
|
|
9
|
+
import { findProfilePath } from "./profile.js";
|
|
10
|
+
import { FORGELOOP_KIT_DIR } from "./target-layout.js";
|
|
9
11
|
|
|
10
|
-
const PROFILE_PATH = "PROJECT_PROFILE.md";
|
|
11
12
|
function profileMetadata(bytes) {
|
|
12
13
|
const text = bytes.toString("utf8");
|
|
13
14
|
return {
|
|
@@ -25,14 +26,21 @@ export async function inspectTarget({ target, packageRoot, contractFile = null }
|
|
|
25
26
|
manifestError = error.message;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const profileRelativePath = await findProfilePath(target);
|
|
30
|
+
const profilePath = profileRelativePath ? ensureWithin(target, profileRelativePath) : null;
|
|
31
|
+
const profile = (profilePath && await fileExists(profilePath))
|
|
32
|
+
? { ...profileMetadata(await readBytes(profilePath)), path: profileRelativePath }
|
|
31
33
|
: { mode: null, status: null };
|
|
32
34
|
const statePath = ensureWithin(target, WORK_STATE_PATH);
|
|
33
35
|
const statePresent = await fileExists(statePath);
|
|
34
36
|
const state = await readAndClassifyWorkState({ target, packageRoot, contractFile });
|
|
35
|
-
const
|
|
37
|
+
const schemaRoot = manifest?.layoutVersion >= 2
|
|
38
|
+
? ensureWithin(target, FORGELOOP_KIT_DIR)
|
|
39
|
+
: target;
|
|
40
|
+
const schemaHealth = await inspectSchemaHealth(schemaRoot);
|
|
41
|
+
const schemaPathPrefix = manifest?.layoutVersion >= 2
|
|
42
|
+
? `${FORGELOOP_KIT_DIR}/schemas`
|
|
43
|
+
: "schemas";
|
|
36
44
|
const doctor = await runDoctor({ target, packageRoot });
|
|
37
45
|
const agents = await Promise.all(AGENT_SUPPORT.map(async (record) => ({
|
|
38
46
|
id: record.id,
|
|
@@ -50,12 +58,12 @@ export async function inspectTarget({ target, packageRoot, contractFile = null }
|
|
|
50
58
|
findings.push({
|
|
51
59
|
code: `schema-${schema.status}`,
|
|
52
60
|
severity: "error",
|
|
53
|
-
path:
|
|
61
|
+
path: `${schemaPathPrefix}/${schema.name}.schema.json`,
|
|
54
62
|
message: schema.error ?? `Schema is ${schema.status}.`,
|
|
55
63
|
remediation: "Restore the shipped schema and rerun inspect.",
|
|
56
64
|
evidence: createEvidence({
|
|
57
65
|
kind: schema.status === "missing" ? "NOT_VERIFIED" : "OBSERVED",
|
|
58
|
-
source:
|
|
66
|
+
source: `${schemaPathPrefix}/${schema.name}.schema.json`,
|
|
59
67
|
result: schema.status,
|
|
60
68
|
}),
|
|
61
69
|
});
|
|
@@ -88,6 +96,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null }
|
|
|
88
96
|
present: manifest !== null,
|
|
89
97
|
status: manifestError ? "invalid" : manifest ? "ready" : "missing",
|
|
90
98
|
packageVersion: manifest?.packageVersion ?? null,
|
|
99
|
+
layoutVersion: manifest?.layoutVersion ?? 1,
|
|
91
100
|
error: manifestError,
|
|
92
101
|
},
|
|
93
102
|
profile,
|
package/src/core/manifest.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
|
|
4
4
|
import { assertSafePath, ensureWithin, fileExists, writeFileAtomic } from "./filesystem.js";
|
|
5
5
|
import { assertJsonBytes, assertJsonLimits } from "./json-safety.js";
|
|
6
|
+
import { LAYOUT_VERSION, LEGACY_LAYOUT_VERSION } from "./target-layout.js";
|
|
6
7
|
|
|
7
8
|
export const MANIFEST_SCHEMA_VERSION = 1;
|
|
8
9
|
export const MANIFEST_PATH = ".forgeloop/manifest.json";
|
|
@@ -15,6 +16,7 @@ export function sha256(bytes) {
|
|
|
15
16
|
export function createManifest(packageVersion) {
|
|
16
17
|
return {
|
|
17
18
|
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
19
|
+
layoutVersion: LAYOUT_VERSION,
|
|
18
20
|
packageName: PACKAGE_NAME,
|
|
19
21
|
packageVersion,
|
|
20
22
|
files: {},
|
|
@@ -28,6 +30,10 @@ function validateManifest(manifest) {
|
|
|
28
30
|
if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
29
31
|
throw new Error(`Unsupported manifest schema: ${manifest.schemaVersion}`);
|
|
30
32
|
}
|
|
33
|
+
if (manifest.layoutVersion !== undefined
|
|
34
|
+
&& ![LEGACY_LAYOUT_VERSION, LAYOUT_VERSION].includes(manifest.layoutVersion)) {
|
|
35
|
+
throw new Error(`Unsupported manifest layout: ${manifest.layoutVersion}`);
|
|
36
|
+
}
|
|
31
37
|
if (typeof manifest.packageVersion !== "string" || !manifest.packageVersion) {
|
|
32
38
|
throw new Error("Manifest packageVersion is required");
|
|
33
39
|
}
|
|
@@ -43,6 +49,10 @@ function validateManifest(manifest) {
|
|
|
43
49
|
if (typeof record.preserve !== "boolean") {
|
|
44
50
|
throw new Error(`Invalid manifest preserve flag for ${relativePath}`);
|
|
45
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
|
+
}
|
|
46
56
|
}
|
|
47
57
|
return manifest;
|
|
48
58
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
4
|
+
import { isKitPath } from "./target-layout.js";
|
|
5
|
+
|
|
6
|
+
const LEGACY_REFERENCE_PATTERN = /(?:\.\.\/|\.\/)+(?:LOOP_ENGINEERING|PROJECT_PROFILE|GUIDE_ROUTER)\.md\b/g;
|
|
7
|
+
|
|
8
|
+
export function nativeShimPrefix(relativePath) {
|
|
9
|
+
if (relativePath.startsWith(".cursor/")) return "../../.forgeloop/kit";
|
|
10
|
+
if (relativePath.startsWith(".github/")) return "../.forgeloop/kit";
|
|
11
|
+
return ".forgeloop/kit";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nativeShimReferences(relativePath) {
|
|
15
|
+
const prefix = nativeShimPrefix(relativePath);
|
|
16
|
+
return [
|
|
17
|
+
`${prefix}/LOOP_ENGINEERING.md`,
|
|
18
|
+
`${prefix}/AGENT_COMPATIBILITY.md`,
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function nativeShim(relativePath) {
|
|
23
|
+
const kitPrefix = nativeShimPrefix(relativePath);
|
|
24
|
+
return `# ForgeLoop native adapter\n\nRead and follow the canonical ForgeLoop protocol in ${kitPrefix}/LOOP_ENGINEERING.md and ${kitPrefix}/AGENT_COMPATIBILITY.md.\nThe canonical guides and schemas are under ${kitPrefix}/; keep this adapter concise and preserve any host-specific instructions.\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveNativeReference(relativePath, reference) {
|
|
28
|
+
return path.posix.normalize(path.posix.join(path.posix.dirname(relativePath), reference));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function inspectNativeAdapter(relativePath, bytes) {
|
|
32
|
+
const text = Buffer.from(bytes).toString("utf8");
|
|
33
|
+
const expected = nativeShimReferences(relativePath);
|
|
34
|
+
const legacyReferences = text.match(LEGACY_REFERENCE_PATTERN) ?? [];
|
|
35
|
+
const missingReferences = expected.filter((reference) => !text.includes(reference));
|
|
36
|
+
const resolvedReferences = expected.map((reference) => ({
|
|
37
|
+
reference,
|
|
38
|
+
path: resolveNativeReference(relativePath, reference),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
text,
|
|
43
|
+
expected,
|
|
44
|
+
legacyReferences,
|
|
45
|
+
missingReferences,
|
|
46
|
+
resolvedReferences,
|
|
47
|
+
hasForgeLoopMarker: /forgeloop/i.test(text),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function validateNativeAdapterTargets({ target, relativePath, bytes }) {
|
|
52
|
+
const inspection = inspectNativeAdapter(relativePath, bytes);
|
|
53
|
+
const invalidReferences = inspection.resolvedReferences.filter(({ path: resolvedPath }) => !isKitPath(resolvedPath));
|
|
54
|
+
const missingTargets = [];
|
|
55
|
+
|
|
56
|
+
for (const { path: resolvedPath } of inspection.resolvedReferences) {
|
|
57
|
+
if (!isKitPath(resolvedPath)) continue;
|
|
58
|
+
try {
|
|
59
|
+
await assertSafePath(target, resolvedPath);
|
|
60
|
+
if (!(await fileExists(ensureWithin(target, resolvedPath)))) missingTargets.push(resolvedPath);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
invalidReferences.push({ path: resolvedPath, error: error.message });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
...inspection,
|
|
68
|
+
invalidReferences,
|
|
69
|
+
missingTargets,
|
|
70
|
+
stale: inspection.legacyReferences.length > 0
|
|
71
|
+
|| inspection.missingReferences.length > 0
|
|
72
|
+
|| invalidReferences.length > 0,
|
|
73
|
+
};
|
|
74
|
+
}
|