@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.
@@ -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
- export async function runUpdate({ target, dryRun, packageRoot, packageVersion }) {
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.relativePath === PROFILE_PATH,
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.relativePath === PROFILE_PATH) {
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
- if (readErrors.length > 0 || schemaErrors.length > 0) {
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].sort((left, right) => left.code.localeCompare(right.code) || left.message.localeCompare(right.message)),
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
- const errors = sortErrors(completion.errors);
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({
@@ -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) {
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  const GUIDE_FILES = Object.freeze({
5
5
  premium: "ENG/premium-sites-studio-eng.md",
6
+ taste: "ENG/taste-frontend-eng.md",
6
7
  clean: "ENG/clean-code-eng.md",
7
8
  test: "ENG/test-code-eng.md",
8
9
  security: "ENG/sec-code-eng.md",
@@ -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 profilePath = ensureWithin(target, PROFILE_PATH);
29
- const profile = (await fileExists(profilePath))
30
- ? profileMetadata(await readBytes(profilePath))
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 schemaHealth = await inspectSchemaHealth(target);
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: `schemas/${schema.name}.schema.json`,
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: `schemas/${schema.name}.schema.json`,
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,
@@ -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
+ }