@cassiomc1/forgeloop 0.1.6 → 0.1.8

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,13 +1,222 @@
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
+ for (const directory of LEGACY_CLEANUP_DIRECTORIES) {
47
+ if (relativePath === directory || relativePath.startsWith(`${directory}/`)) {
48
+ cleanupDirectories.add(directory);
49
+ }
50
+ }
51
+ }
52
+
53
+ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries }) {
54
+ const nextManifest = createManifest(packageVersion);
55
+ const actions = [];
56
+ const conflicts = [];
57
+ const writes = [];
58
+ const cleanupFiles = new Set();
59
+ const cleanupDirectories = new Set();
60
+
61
+ // Validate every path before creating the migration plan or touching data.
62
+ for (const entry of entries) {
63
+ await assertSafePath(target, entry.relativePath);
64
+ if (entry.legacyRelativePath !== entry.relativePath) {
65
+ await assertSafePath(target, entry.legacyRelativePath);
66
+ }
67
+ }
68
+
69
+ for (const entry of entries) {
70
+ const destination = ensureWithin(target, entry.relativePath);
71
+ const legacyDestination = ensureWithin(target, entry.legacyRelativePath);
72
+ const sourceHash = sha256(entry.bytes);
73
+ const destinationExists = await fileExists(destination);
74
+ const legacyExists = entry.legacyRelativePath !== entry.relativePath
75
+ && await fileExists(legacyDestination);
76
+ const legacyRecord = currentManifest.files[entry.legacyRelativePath];
77
+ const destinationRecord = currentManifest.files[entry.relativePath];
78
+
79
+ if (isNativeAdapterPath(entry.relativePath)) {
80
+ if (!destinationExists) {
81
+ addAction(actions, dryRun, "create", entry.relativePath);
82
+ writes.push({ destination, bytes: entry.bytes });
83
+ nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
84
+ continue;
85
+ }
86
+
87
+ const currentBytes = await readBytes(destination);
88
+ const currentHash = sha256(currentBytes);
89
+ if (legacyRecord && currentHash === legacyRecord.sha256) {
90
+ if (currentHash === sourceHash) {
91
+ actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
92
+ } else {
93
+ addAction(actions, dryRun, "update-adapter", entry.relativePath);
94
+ writes.push({ destination, bytes: entry.bytes });
95
+ }
96
+ nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
97
+ continue;
98
+ }
99
+
100
+ conflicts.push(migrationConflict(
101
+ "E_NATIVE_ADAPTER_MIGRATION_CONFLICT",
102
+ entry.relativePath,
103
+ legacyRecord
104
+ ? "Managed native adapter was modified; it was preserved and was not silently overwritten."
105
+ : "Native adapter is not owned by the legacy manifest; it was preserved and was not silently adopted.",
106
+ ));
107
+ addAction(actions, dryRun, "preserve-conflict", entry.relativePath, {
108
+ reason: legacyRecord ? "managed-modified" : "unmanaged",
109
+ });
110
+ if (legacyRecord) nextManifest.files[entry.relativePath] = { ...legacyRecord };
111
+ continue;
112
+ }
113
+
114
+ if (destinationExists) {
115
+ const currentBytes = await readBytes(destination);
116
+ const currentHash = sha256(currentBytes);
117
+ if (!destinationRecord && currentHash !== sourceHash && entry.sourcePath !== PROFILE_PATH) {
118
+ conflicts.push(migrationConflict(
119
+ "E_HIDDEN_KIT_MIGRATION_CONFLICT",
120
+ entry.relativePath,
121
+ "Existing hidden kit file is unmanaged and was not overwritten.",
122
+ ));
123
+ addAction(actions, dryRun, "preserve-conflict", entry.relativePath, { reason: "hidden-unmanaged" });
124
+ } else {
125
+ actions.push({ action: "skip", path: entry.relativePath, reason: "already-present" });
126
+ }
127
+ nextManifest.files[entry.relativePath] = {
128
+ sha256: currentHash,
129
+ preserve: entry.sourcePath === PROFILE_PATH
130
+ || !destinationRecord
131
+ || Boolean(destinationRecord.preserve),
132
+ };
133
+ continue;
134
+ }
135
+
136
+ if (!legacyExists) {
137
+ addAction(actions, dryRun, "create", entry.relativePath);
138
+ writes.push({ destination, bytes: entry.bytes });
139
+ nextManifest.files[entry.relativePath] = {
140
+ sha256: sourceHash,
141
+ preserve: entry.sourcePath === PROFILE_PATH,
142
+ };
143
+ continue;
144
+ }
145
+
146
+ const legacyBytes = await readBytes(legacyDestination);
147
+ const legacyHash = sha256(legacyBytes);
148
+ const unchangedManaged = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
149
+
150
+ if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
151
+ addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
152
+ writes.push({ destination, bytes: legacyBytes });
153
+ if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
154
+ nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
155
+ continue;
156
+ }
157
+
158
+ if (legacyRecord?.preserve && unchangedManaged) {
159
+ addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
160
+ writes.push({ destination, bytes: legacyBytes });
161
+ if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
162
+ nextManifest.files[entry.relativePath] = { sha256: legacyHash, preserve: true };
163
+ continue;
164
+ }
165
+
166
+ if (unchangedManaged) {
167
+ addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
168
+ writes.push({ destination, bytes: entry.bytes });
169
+ if (!dryRun) addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
170
+ nextManifest.files[entry.relativePath] = { sha256: sourceHash, preserve: false };
171
+ continue;
172
+ }
173
+
174
+ const conflict = entry.sourcePath === PROFILE_PATH
175
+ ? migrationConflict(
176
+ "E_PROFILE_MIGRATION_CONFLICT",
177
+ entry.legacyRelativePath,
178
+ "Project profile is not owned by the legacy manifest; it was preserved and was not overwritten or deleted.",
179
+ )
180
+ : migrationConflict(
181
+ "E_LEGACY_FILE_MIGRATION_CONFLICT",
182
+ entry.legacyRelativePath,
183
+ legacyRecord
184
+ ? "Managed legacy file was modified; it was preserved while the hidden canonical file was installed."
185
+ : "Unmanaged legacy file was preserved while the hidden canonical file was installed.",
186
+ );
187
+ conflicts.push(conflict);
188
+ addAction(actions, dryRun, "preserve-conflict", entry.legacyRelativePath, {
189
+ to: entry.relativePath,
190
+ reason: legacyRecord ? "managed-modified" : "unmanaged",
191
+ });
192
+ writes.push({ destination, bytes: entry.bytes });
193
+ nextManifest.files[entry.relativePath] = {
194
+ sha256: sourceHash,
195
+ preserve: entry.sourcePath === PROFILE_PATH,
196
+ };
197
+ }
198
+
199
+ // Apply all hidden writes and verify their bytes before removing any legacy file.
200
+ for (const plan of writes) {
201
+ await writeFileAtomic(plan.destination, plan.bytes, { dryRun });
202
+ if (!dryRun) await verifyWrite(plan.destination, plan.bytes);
203
+ }
204
+
205
+ for (const relativePath of cleanupFiles) {
206
+ await assertSafePath(target, relativePath);
207
+ const legacyPath = ensureWithin(target, relativePath);
208
+ if (!dryRun && await fileExists(legacyPath)) await unlink(legacyPath);
209
+ }
210
+
211
+ for (const relativePath of cleanupDirectories) {
212
+ await removeEmptyLegacyDirectory(target, relativePath, dryRun);
213
+ }
214
+
215
+ // The manifest is written last so layoutVersion 2 is never authoritative before
216
+ // hidden destinations and adapter decisions have been applied.
217
+ await writeManifest(target, nextManifest, { dryRun });
218
+ return { actions, conflicts, manifest: nextManifest };
219
+ }
11
220
 
12
221
  export async function runUpdate({ target, dryRun, packageRoot, packageVersion }) {
13
222
  const currentManifest = await readManifest(target);
@@ -16,6 +225,10 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
16
225
  }
17
226
 
18
227
  const entries = await readTemplateEntries(packageRoot);
228
+ if ((currentManifest.layoutVersion ?? 1) < LAYOUT_VERSION) {
229
+ return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries });
230
+ }
231
+
19
232
  const nextManifest = structuredClone(currentManifest);
20
233
  const actions = [];
21
234
  const conflicts = [];
@@ -47,7 +260,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
47
260
  entry,
48
261
  record: {
49
262
  sha256: sourceHash,
50
- preserve: entry.relativePath === PROFILE_PATH,
263
+ preserve: entry.sourcePath === PROFILE_PATH,
51
264
  },
52
265
  });
53
266
  continue;
@@ -58,7 +271,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
58
271
  continue;
59
272
  }
60
273
 
61
- if (record.preserve || entry.relativePath === PROFILE_PATH) {
274
+ if (record.preserve || entry.sourcePath === PROFILE_PATH) {
62
275
  actions.push({ action: "skip", path: entry.relativePath, reason: "preserved" });
63
276
  continue;
64
277
  }
@@ -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
  }
@@ -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
+ }
@@ -3,7 +3,7 @@ import { completionIdentityErrors, evaluateCompletion } from "./completion.js";
3
3
  import { requiredEvidenceForTarget } from "./completion-artifacts.js";
4
4
  import { coverageForRequirements } from "./coverage.js";
5
5
  import { readContract } from "./contract.js";
6
- import { evaluatePreflight, validatePersistedPreflight } from "./preflight.js";
6
+ import { evaluatePreflight, validatePersistedPreflight, validateReadyProtocolConsistency } from "./preflight.js";
7
7
  import { PROTOCOL_VERSION } from "./protocol.js";
8
8
  import { validateReceipt } from "./receipt.js";
9
9
  import { readPersistedRoute } from "./route-artifact.js";
@@ -245,6 +245,47 @@ export async function getNextAction({ target, packageRoot } = {}) {
245
245
  );
246
246
  }
247
247
  if (!workState.value) {
248
+ const persistedPreflight = await loadArtifact(
249
+ () => readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot),
250
+ ARTIFACT_PATHS.preflight,
251
+ );
252
+ if (!persistedPreflight.error && persistedPreflight.value?.value?.status === "READY") {
253
+ try {
254
+ const consistencyErrors = await validateReadyProtocolConsistency({
255
+ target,
256
+ packageRoot,
257
+ persisted: persistedPreflight.value.value,
258
+ });
259
+ if (consistencyErrors.length > 0) {
260
+ return result({
261
+ taskId: persistedPreflight.value.value.taskId ?? "unknown",
262
+ currentPhase: "ROUTED",
263
+ nextAction: NEXT_ACTIONS.RESOLVE_BLOCKER,
264
+ reasons: consistencyErrors,
265
+ requiredArtifacts: [
266
+ ARTIFACT_PATHS.state,
267
+ ARTIFACT_PATHS.contract,
268
+ ARTIFACT_PATHS.route,
269
+ ARTIFACT_PATHS.preflight,
270
+ ARTIFACT_PATHS.events,
271
+ ],
272
+ missingArtifacts: [ARTIFACT_PATHS.state],
273
+ });
274
+ }
275
+ } catch (error) {
276
+ return result({
277
+ taskId: persistedPreflight.value.value.taskId ?? "unknown",
278
+ currentPhase: "ROUTED",
279
+ nextAction: NEXT_ACTIONS.RESOLVE_BLOCKER,
280
+ reasons: [artifactError(
281
+ error.code ?? "E_PREFLIGHT_READY_INCONSISTENT",
282
+ error.message,
283
+ error.artifacts ?? [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events],
284
+ )],
285
+ requiredArtifacts: [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events],
286
+ });
287
+ }
288
+ }
248
289
  return decision(
249
290
  {},
250
291
  NEXT_ACTIONS.DISCOVER,