@cassiomc1/forgeloop 0.1.2 → 0.1.3

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/src/core/phase.js CHANGED
@@ -1,11 +1,15 @@
1
- import { ARTIFACT_PATHS } from "./artifacts.js";
2
- import { readJsonArtifact } from "./artifacts.js";
1
+ import { ARTIFACT_PATHS, canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
3
2
  import { readContract } from "./contract.js";
4
- import { appendProtocolEvent, validateEventLedger } from "./events.js";
3
+ import { appendProtocolEvent, LIFECYCLE_MILESTONES, validateEventLedger } from "./events.js";
5
4
  import { readPersistedRoute } from "./route-artifact.js";
6
5
  import { assertWorkPhase, isValidTransition } from "./protocol.js";
7
6
  import { readWorkState, writeWorkState } from "./work-state.js";
8
7
  import { evaluateCompletion } from "./completion.js";
8
+ import { evaluatePreflight, validatePersistedPreflight } from "./preflight.js";
9
+ import { requiredEvidenceForTarget } from "./completion-artifacts.js";
10
+ import { assertCompletionRelationships, assertStateIdentity } from "./completion-relationships.js";
11
+ import { createReceipt, validateReceipt } from "./receipt.js";
12
+ import { assertExecutionPrerequisites, hasExecutionStarted } from "./execution-prerequisites.js";
9
13
 
10
14
  function phaseError(code, message, artifacts = []) {
11
15
  const error = new Error(message);
@@ -24,6 +28,72 @@ const PHASE_EVENTS = Object.freeze({
24
28
  COMPLETE: "COMPLETION_VALIDATED",
25
29
  });
26
30
 
31
+ const LATE_PHASES = new Set([
32
+ "EXECUTING",
33
+ "VERIFYING",
34
+ "DIAGNOSING",
35
+ "CORRECTING",
36
+ "REVIEWING",
37
+ "COMPLETE",
38
+ ]);
39
+
40
+ async function assertPersistedStateIdentity(target, state, toPhase, packageRoot) {
41
+ const requireContract = LATE_PHASES.has(state.phase)
42
+ || ["CONTRACT_READY", "ROUTED", "EXECUTING"].includes(toPhase);
43
+ const requireRoute = LATE_PHASES.has(state.phase)
44
+ || ["ROUTED", "EXECUTING"].includes(toPhase);
45
+ let contract = null;
46
+ let route = null;
47
+ try {
48
+ contract = await readContract(target, packageRoot);
49
+ } catch (error) {
50
+ if (error.code === "ARTIFACT_MISSING" && !requireContract) {
51
+ contract = null;
52
+ } else {
53
+ throw phaseError(
54
+ requireContract ? "E_PHASE_PREREQUISITE_MISSING" : error.code ?? "E_CONTRACT_INVALID",
55
+ `${requireContract ? `Phase ${toPhase} requires current contract` : "Unable to validate current contract"}: ${error.message}`,
56
+ [ARTIFACT_PATHS.contract],
57
+ );
58
+ }
59
+ }
60
+ try {
61
+ route = await readPersistedRoute(target, packageRoot);
62
+ } catch (error) {
63
+ if (error.code === "ARTIFACT_MISSING" && !requireRoute) {
64
+ route = null;
65
+ } else {
66
+ throw phaseError(
67
+ requireRoute ? "E_PHASE_PREREQUISITE_MISSING" : error.code ?? "E_ROUTE_INVALID",
68
+ `${requireRoute ? `Phase ${toPhase} requires persisted route` : "Unable to validate persisted route"}: ${error.message}`,
69
+ [ARTIFACT_PATHS.route],
70
+ );
71
+ }
72
+ }
73
+ if (!contract && !route) return;
74
+ try {
75
+ assertStateIdentity({ contract, route, state });
76
+ } catch (error) {
77
+ throw phaseError(
78
+ error.code,
79
+ error.message,
80
+ error.artifacts,
81
+ );
82
+ }
83
+ }
84
+
85
+ function reconcileImplementationStep(state, toPhase) {
86
+ if (state.phase !== "EXECUTING" || toPhase !== "VERIFYING") return state;
87
+ if (!state.pendingSteps.includes("implementation")) return state;
88
+ return {
89
+ ...state,
90
+ completedSteps: state.completedSteps.includes("implementation")
91
+ ? [...state.completedSteps]
92
+ : [...state.completedSteps, "implementation"],
93
+ pendingSteps: state.pendingSteps.filter((step) => step !== "implementation"),
94
+ };
95
+ }
96
+
27
97
  async function assertPhasePrerequisites(target, state, toPhase, packageRoot) {
28
98
  if (toPhase === "CONTRACT_READY" || toPhase === "ROUTED" || toPhase === "EXECUTING") {
29
99
  try {
@@ -39,21 +109,11 @@ async function assertPhasePrerequisites(target, state, toPhase, packageRoot) {
39
109
  throw phaseError("E_PHASE_PREREQUISITE_MISSING", `Phase ${toPhase} requires ${ARTIFACT_PATHS.route}: ${error.message}`, [ARTIFACT_PATHS.route]);
40
110
  }
41
111
  }
42
- if (toPhase === "EXECUTING") {
43
- let preflight;
112
+ if (hasExecutionStarted(toPhase)) {
44
113
  try {
45
- preflight = await readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot);
114
+ await assertExecutionPrerequisites({ target, state, packageRoot });
46
115
  } catch (error) {
47
- throw phaseError("E_PHASE_PREREQUISITE_MISSING", `EXECUTING requires READY ${ARTIFACT_PATHS.preflight}: ${error.message}`, [ARTIFACT_PATHS.preflight]);
48
- }
49
- if (preflight.value.status !== "READY") {
50
- throw phaseError("E_PREFLIGHT_NOT_READY", "EXECUTING requires a READY preflight", [ARTIFACT_PATHS.preflight]);
51
- }
52
- const ledger = await validateEventLedger(target, packageRoot);
53
- for (const requiredEvent of ["CONTRACT_VALIDATED", "ROUTE_VALIDATED", "PREFLIGHT_READY"]) {
54
- if (!ledger.events.some((event) => event.event === requiredEvent)) {
55
- throw phaseError("E_PHASE_CHRONOLOGY_INVALID", `EXECUTING requires a ${requiredEvent} protocol event`, [ARTIFACT_PATHS.events]);
56
- }
116
+ throw phaseError(error.code, error.message, error.artifacts);
57
117
  }
58
118
  }
59
119
  if (toPhase === "COMPLETE" && state.verificationEvidence.length === 0) {
@@ -72,17 +132,81 @@ export async function advanceWorkState(target, toPhase, { packageRoot, now = new
72
132
  const state = await readWorkState(target, packageRoot);
73
133
  if (!state) throw phaseError("E_PHASE_PREREQUISITE_MISSING", "Cannot advance without work state", [ARTIFACT_PATHS.state]);
74
134
  await assertPhasePrerequisites(target, state, toPhase, packageRoot);
135
+ await assertPersistedStateIdentity(target, state, toPhase, packageRoot);
75
136
  if (!isValidTransition(state.phase, toPhase)) {
76
137
  throw phaseError("E_PHASE_TRANSITION_INVALID", `Invalid work-state transition: ${state.phase} -> ${toPhase}`);
77
138
  }
139
+ const ledger = await validateEventLedger(target, packageRoot);
140
+ if (!ledger.valid) {
141
+ const first = ledger.errors[0];
142
+ throw phaseError(first.code, first.message, [ARTIFACT_PATHS.events]);
143
+ }
144
+ if (ledger.events.some((event) => event.taskId !== state.taskId)) {
145
+ throw phaseError(
146
+ "E_PHASE_CHRONOLOGY_INVALID",
147
+ "Cannot advance work state with lifecycle events from a different task",
148
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.state],
149
+ );
150
+ }
151
+ const eventType = PHASE_EVENTS[toPhase];
152
+ if (eventType && ledger.events.some((event) => event.taskId === state.taskId && event.event === eventType)) {
153
+ throw phaseError("E_PHASE_CHRONOLOGY_INVALID", `Lifecycle milestone already exists: ${eventType}`, [ARTIFACT_PATHS.events]);
154
+ }
155
+ const milestoneIndex = LIFECYCLE_MILESTONES.indexOf(eventType);
156
+ if (milestoneIndex >= 0) {
157
+ const lastMilestone = ledger.events.reduce((last, event) => Math.max(last, LIFECYCLE_MILESTONES.indexOf(event.event)), -1);
158
+ if (lastMilestone !== milestoneIndex - 1) {
159
+ throw phaseError("E_PHASE_CHRONOLOGY_INVALID", `Phase ${toPhase} cannot append ${eventType} after the current lifecycle ledger`, [ARTIFACT_PATHS.events]);
160
+ }
161
+ }
162
+ const reconciled = reconcileImplementationStep(state, toPhase);
78
163
  const next = {
79
- ...state,
164
+ ...reconciled,
80
165
  previousPhase: state.phase,
81
166
  phase: toPhase,
82
167
  lastUpdated: now,
83
168
  };
169
+ let nextReceipt = null;
170
+ try {
171
+ const receipt = await readJsonArtifact(target, ARTIFACT_PATHS.receipt, "execution-receipt", packageRoot);
172
+ const contract = await readContract(target, packageRoot);
173
+ const route = await readPersistedRoute(target, packageRoot);
174
+ const preflight = await evaluatePreflight({ target, packageRoot });
175
+ const requiredEvidence = await requiredEvidenceForTarget({
176
+ target,
177
+ contract,
178
+ route,
179
+ packageRoot,
180
+ additionalEvidence: preflight.policy?.requiredEvidence ?? [],
181
+ });
182
+ await validateReceipt(receipt.value, packageRoot);
183
+ assertCompletionRelationships({
184
+ contract,
185
+ route,
186
+ state,
187
+ receipt: receipt.value,
188
+ requiredEvidence,
189
+ requireRequiredChecks: false,
190
+ });
191
+ nextReceipt = await createReceipt({
192
+ ...receipt.value,
193
+ stateFingerprint: canonicalFingerprint(next),
194
+ }, packageRoot);
195
+ assertCompletionRelationships({
196
+ contract,
197
+ route,
198
+ state: next,
199
+ receipt: nextReceipt,
200
+ requiredEvidence,
201
+ requireRequiredChecks: false,
202
+ });
203
+ } catch (error) {
204
+ if (error.code !== "ARTIFACT_MISSING") throw error;
205
+ }
84
206
  await writeWorkState(target, next, { packageRoot });
85
- const eventType = PHASE_EVENTS[toPhase];
207
+ if (nextReceipt) {
208
+ await writeJsonArtifact(target, ARTIFACT_PATHS.receipt, nextReceipt, "execution-receipt", packageRoot);
209
+ }
86
210
  if (eventType) await appendProtocolEvent(target, { taskId: state.taskId, event: eventType, at: now }, packageRoot);
87
211
  return next;
88
212
  }
@@ -5,12 +5,13 @@ import { requiredGatesForGuides } from "./guide-metadata.js";
5
5
  import { assertRouteInvariants } from "./router.js";
6
6
  import { assertSourceProvenance } from "./sources.js";
7
7
  import { readPersistedRoute } from "./route-artifact.js";
8
- import { appendProtocolEvent } from "./events.js";
8
+ import { appendProtocolEvent, LIFECYCLE_MILESTONES, validateEventLedger } from "./events.js";
9
9
  import { readWorkState } from "./work-state.js";
10
10
  import { readConfig } from "./config.js";
11
11
  import { assertSafePath, ensureWithin, fileExists, readBytes } from "./filesystem.js";
12
12
  import { sha256 } from "./manifest.js";
13
13
  import { validateProfileSources } from "./profile.js";
14
+ import { assertStateIdentity } from "./completion-relationships.js";
14
15
 
15
16
  const PREVIEW_DECISION_LIMIT = 10;
16
17
  const PREVIEW_DECISION_MAX_LENGTH = 240;
@@ -29,6 +30,66 @@ function sortIssues(errors) {
29
30
  || left.message.localeCompare(right.message));
30
31
  }
31
32
 
33
+ function preflightError(code, message, artifacts = []) {
34
+ const error = new Error(message);
35
+ error.code = code;
36
+ error.artifacts = artifacts;
37
+ return error;
38
+ }
39
+
40
+ function sameStringSet(left, right) {
41
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
42
+ const leftSet = new Set(left);
43
+ const rightSet = new Set(right);
44
+ return leftSet.size === left.length
45
+ && rightSet.size === right.length
46
+ && leftSet.size === rightSet.size
47
+ && [...leftSet].every((value) => rightSet.has(value));
48
+ }
49
+
50
+ export function validatePersistedPreflight(persisted, current) {
51
+ const errors = [];
52
+ if (persisted?.status !== "READY") {
53
+ errors.push(issue("E_PREFLIGHT_NOT_READY", "A persisted READY preflight is required", [ARTIFACT_PATHS.preflight]));
54
+ return errors;
55
+ }
56
+ if (current?.status !== "READY") {
57
+ errors.push(issue("E_PREFLIGHT_NOT_READY", "The current preflight evaluation is not READY", [ARTIFACT_PATHS.preflight]));
58
+ }
59
+ if (persisted.taskId !== current?.taskId) {
60
+ errors.push(issue(
61
+ "E_PREFLIGHT_TASK_MISMATCH",
62
+ "Persisted preflight does not belong to the current task",
63
+ [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.contract],
64
+ ));
65
+ }
66
+ if (persisted.fingerprints?.contract !== current?.fingerprints?.contract
67
+ || persisted.contract?.fingerprint !== current?.contract?.fingerprint) {
68
+ errors.push(issue(
69
+ "E_PREFLIGHT_CONTRACT_STALE",
70
+ "Persisted preflight does not match the current contract fingerprint",
71
+ [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.contract],
72
+ ));
73
+ }
74
+ if (persisted.fingerprints?.routing !== current?.fingerprints?.routing
75
+ || persisted.routing?.fingerprint !== current?.routing?.fingerprint) {
76
+ errors.push(issue(
77
+ "E_PREFLIGHT_ROUTE_STALE",
78
+ "Persisted preflight does not match the current routing fingerprint",
79
+ [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.route],
80
+ ));
81
+ }
82
+ if (!sameStringSet(persisted.requiredGates, current?.requiredGates)
83
+ || !sameStringSet(persisted.satisfiedGates, current?.satisfiedGates)) {
84
+ errors.push(issue(
85
+ "E_PREFLIGHT_GATES_STALE",
86
+ "Persisted preflight gate sets do not match the current evaluation",
87
+ [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.gates],
88
+ ));
89
+ }
90
+ return sortIssues(errors);
91
+ }
92
+
32
93
  async function readProfile(target) {
33
94
  const relativePath = "PROJECT_PROFILE.md";
34
95
  await assertSafePath(target, relativePath);
@@ -219,11 +280,112 @@ export async function evaluatePreflight({ target, packageRoot, strict = false }
219
280
  };
220
281
  }
221
282
 
283
+ async function readOptionalIdentityArtifact(readArtifact, invalidCode, artifactPath) {
284
+ try {
285
+ return await readArtifact();
286
+ } catch (error) {
287
+ if (error.code === "ARTIFACT_MISSING") return null;
288
+ throw preflightError(invalidCode, error.message, [artifactPath]);
289
+ }
290
+ }
291
+
292
+ async function assertPreflightPersistenceSafety(target, packageRoot, taskId) {
293
+ let state;
294
+ try {
295
+ state = await readWorkState(target, packageRoot);
296
+ } catch (error) {
297
+ throw preflightError("E_STATE_INVALID", error.message, [ARTIFACT_PATHS.state]);
298
+ }
299
+ const contract = await readOptionalIdentityArtifact(
300
+ () => readContract(target, packageRoot),
301
+ "E_CONTRACT_INVALID",
302
+ ARTIFACT_PATHS.contract,
303
+ );
304
+ const route = await readOptionalIdentityArtifact(
305
+ () => readPersistedRoute(target, packageRoot),
306
+ "E_ROUTE_INVALID",
307
+ ARTIFACT_PATHS.route,
308
+ );
309
+ if (state) {
310
+ if (contract || route) assertStateIdentity({ contract, route, state });
311
+ }
312
+
313
+ if (taskId === "unknown") return null;
314
+ const ledger = await validateEventLedger(target, packageRoot);
315
+ if (!ledger.valid) {
316
+ const first = ledger.errors[0];
317
+ throw preflightError(first.code, first.message, [ARTIFACT_PATHS.events]);
318
+ }
319
+ if (ledger.events.some((event) => event.taskId !== taskId)) {
320
+ throw preflightError(
321
+ "E_PHASE_CHRONOLOGY_INVALID",
322
+ "Preflight cannot append events to a ledger owned by a different task",
323
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.contract],
324
+ );
325
+ }
326
+ return ledger;
327
+ }
328
+
329
+ const PREFLIGHT_IDENTITY_BARRIER_CODES = new Set([
330
+ "E_CONTRACT_STALE",
331
+ "E_GATE_TASK_MISMATCH",
332
+ "E_ROUTE_STALE",
333
+ "E_STATE_TASK_MISMATCH",
334
+ "E_ROUTE_GUIDE_MISMATCH",
335
+ ]);
336
+
337
+ function assertPreflightResultPersistenceSafety(result) {
338
+ const identityError = result.errors.find((error) => PREFLIGHT_IDENTITY_BARRIER_CODES.has(error.code));
339
+ if (identityError) {
340
+ throw preflightError(identityError.code, identityError.message, identityError.artifacts);
341
+ }
342
+ }
343
+
344
+ function sameReadyPreflightEvent(event, result) {
345
+ return event.fingerprint === result.fingerprints.contract
346
+ && event.details?.routingFingerprint === result.fingerprints.routing
347
+ && sameStringSet(event.details?.requiredGates, result.requiredGates)
348
+ && sameStringSet(event.details?.satisfiedGates, result.satisfiedGates);
349
+ }
350
+
351
+ function planReadyPreflightLifecycleWrite(ledger, result) {
352
+ if (!ledger) return { appendEvents: false };
353
+ const existing = ledger.events.find((event) => event.event === "PREFLIGHT_READY");
354
+ if (existing) {
355
+ if (sameReadyPreflightEvent(existing, result)) return { appendEvents: false };
356
+ throw preflightError(
357
+ "E_PHASE_CHRONOLOGY_INVALID",
358
+ "PREFLIGHT_READY already exists with different READY preflight details; repair the contract, route, or gate lifecycle before refreshing preflight",
359
+ [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events, ARTIFACT_PATHS.contract, ARTIFACT_PATHS.route, ARTIFACT_PATHS.gates],
360
+ );
361
+ }
362
+ const lastMilestone = ledger.events.reduce(
363
+ (last, event) => Math.max(last, LIFECYCLE_MILESTONES.indexOf(event.event)),
364
+ -1,
365
+ );
366
+ const routeMilestone = LIFECYCLE_MILESTONES.indexOf("ROUTE_VALIDATED");
367
+ const preflightMilestone = LIFECYCLE_MILESTONES.indexOf("PREFLIGHT_READY");
368
+ if (lastMilestone < routeMilestone) return { appendEvents: false };
369
+ if (lastMilestone !== preflightMilestone - 1) {
370
+ throw preflightError(
371
+ "E_PHASE_CHRONOLOGY_INVALID",
372
+ "PREFLIGHT_READY cannot be appended after the current lifecycle ledger",
373
+ [ARTIFACT_PATHS.events],
374
+ );
375
+ }
376
+ return { appendEvents: true };
377
+ }
378
+
222
379
  export async function runPreflight({ target, packageRoot, strict = false, persist = true } = {}) {
223
380
  const result = await evaluatePreflight({ target, packageRoot, strict });
224
381
  if (persist) {
382
+ const ledger = await assertPreflightPersistenceSafety(target, packageRoot, result.taskId);
383
+ assertPreflightResultPersistenceSafety(result);
384
+ const lifecycleWrite = result.status === "READY"
385
+ ? planReadyPreflightLifecycleWrite(ledger, result)
386
+ : { appendEvents: true };
225
387
  await writeJsonArtifact(target, ARTIFACT_PATHS.preflight, result, "preflight", packageRoot);
226
- if (result.taskId !== "unknown") {
388
+ if (result.taskId !== "unknown" && lifecycleWrite.appendEvents) {
227
389
  for (const gate of result.satisfiedGates) {
228
390
  await appendProtocolEvent(target, {
229
391
  taskId: result.taskId,
@@ -238,6 +400,7 @@ export async function runPreflight({ target, packageRoot, strict = false, persis
238
400
  details: {
239
401
  requiredGates: result.requiredGates,
240
402
  satisfiedGates: result.satisfiedGates,
403
+ routingFingerprint: result.fingerprints.routing,
241
404
  },
242
405
  }, packageRoot);
243
406
  }