@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.
@@ -1,19 +1,24 @@
1
- import { ARTIFACT_PATHS, canonicalFingerprint, readJsonArtifact } from "./artifacts.js";
1
+ import { ARTIFACT_PATHS, canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
2
2
  import { requiredEvidenceForTarget } from "./completion-artifacts.js";
3
- import { appendProtocolEvent, validateEventLedger } from "./events.js";
3
+ import { appendProtocolEvent, LIFECYCLE_MILESTONES, validateEventLedger } from "./events.js";
4
4
  import { evaluatePreflight } from "./preflight.js";
5
5
  import { readContract } from "./contract.js";
6
6
  import { readPersistedRoute } from "./route-artifact.js";
7
7
  import { readWorkState, writeWorkState, classifyLoadedWorkState } from "./work-state.js";
8
- import { validateReceipt } from "./receipt.js";
9
- import { assertCheckList, requiredChecksSatisfied } from "./checks.js";
10
- import { assertCoverageList, coverageForRequirements } from "./coverage.js";
8
+ import { createReceipt, validateReceipt } from "./receipt.js";
9
+ import { completionRelationshipErrors } from "./completion-relationships.js";
11
10
  import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
11
+ import { evaluateStartExecutionPrerequisites, hasExecutionStarted } from "./execution-prerequisites.js";
12
12
 
13
13
  function issue(code, message, artifacts = [], details = {}) {
14
14
  return { code, message, artifacts, ...details };
15
15
  }
16
16
 
17
+ export function completionIdentityErrors({ contract, state, receipt } = {}) {
18
+ return completionRelationshipErrors({ contract, state, receipt })
19
+ .filter((error) => ["E_STATE_TASK_MISMATCH", "E_RECEIPT_TASK_MISMATCH"].includes(error.code));
20
+ }
21
+
17
22
  function repairNext(error) {
18
23
  switch (error.code) {
19
24
  case "E_RECEIPT_MISSING":
@@ -94,7 +99,7 @@ async function validateLedger(target, taskId, state, errors, packageRoot) {
94
99
  }
95
100
  const ledger = await validateEventLedger(target, packageRoot);
96
101
  for (const error of ledger.errors) errors.push({ ...error, artifacts: [ARTIFACT_PATHS.events] });
97
- const requiredEvents = ["CONTRACT_VALIDATED", "ROUTE_VALIDATED", "PREFLIGHT_READY", "EXECUTION_STARTED", "VERIFICATION_RECORDED"];
102
+ const requiredEvents = LIFECYCLE_MILESTONES.slice(0, state?.phase === "COMPLETE" ? undefined : -1);
98
103
  const observed = new Set(ledger.events.filter((event) => event.taskId === taskId).map((event) => event.event));
99
104
  for (const event of requiredEvents) {
100
105
  if (!observed.has(event)) {
@@ -104,8 +109,8 @@ async function validateLedger(target, taskId, state, errors, packageRoot) {
104
109
  if (ledger.events.some((event) => event.taskId !== taskId)) {
105
110
  errors.push(issue("E_PHASE_CHRONOLOGY_INVALID", "Protocol events must belong to the current task", [ARTIFACT_PATHS.events]));
106
111
  }
107
- if (state?.phase === "COMPLETE" && !observed.has("COMPLETION_VALIDATED")) {
108
- errors.push(issue("E_PHASE_CHRONOLOGY_INVALID", "COMPLETE state requires COMPLETION_VALIDATED", [ARTIFACT_PATHS.events]));
112
+ if (state?.phase !== "COMPLETE" && observed.has("COMPLETION_VALIDATED")) {
113
+ errors.push(issue("E_PHASE_CHRONOLOGY_INVALID", "COMPLETION_VALIDATED requires COMPLETE state", [ARTIFACT_PATHS.events]));
109
114
  }
110
115
  return ledger;
111
116
  }
@@ -150,6 +155,11 @@ export async function evaluateCompletion({ target, packageRoot, strict = false }
150
155
  errors,
151
156
  );
152
157
 
158
+ if (state && hasExecutionStarted(state.phase)) {
159
+ const prerequisites = await evaluateStartExecutionPrerequisites({ target, state, packageRoot });
160
+ errors.push(...prerequisites.errors);
161
+ }
162
+
153
163
  if (receipt) {
154
164
  try {
155
165
  await validateReceipt(receipt.value, packageRoot);
@@ -158,34 +168,12 @@ export async function evaluateCompletion({ target, packageRoot, strict = false }
158
168
  }
159
169
  }
160
170
 
161
- if (contract && route && route.value.contractFingerprint !== undefined
162
- && route.value.contractFingerprint !== contract.fingerprint) {
163
- errors.push(issue("E_ROUTE_STALE", "Routing result does not match the current contract", [ARTIFACT_PATHS.route, ARTIFACT_PATHS.contract]));
164
- }
165
- if (contract && state && state.contractFingerprint !== contract.fingerprint) {
166
- errors.push(issue("E_CONTRACT_STALE", "Work state does not match the current contract", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract]));
167
- }
168
- if (route && state && JSON.stringify(route.value.guides) !== JSON.stringify(state.selectedGuides)) {
169
- errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "Work state guides do not match the persisted route", [ARTIFACT_PATHS.route, ARTIFACT_PATHS.state]));
170
- }
171
- if (route && receipt && JSON.stringify(route.value.guides) !== JSON.stringify(receipt.value.selectedGuides)) {
172
- errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "Receipt guides do not match the persisted route", [ARTIFACT_PATHS.route, ARTIFACT_PATHS.receipt]));
173
- }
174
- if (contract && receipt && receipt.value.contractFingerprint !== contract.fingerprint) {
175
- errors.push(issue("E_RECEIPT_CONTRACT_MISMATCH", "Receipt does not match the current contract", [ARTIFACT_PATHS.contract, ARTIFACT_PATHS.receipt]));
176
- }
177
- if (route && receipt && receipt.value.routeFingerprint !== undefined && receipt.value.routeFingerprint !== route.fingerprint) {
178
- errors.push(issue("E_RECEIPT_ROUTE_MISMATCH", "Receipt does not match the persisted route", [ARTIFACT_PATHS.route, ARTIFACT_PATHS.receipt]));
179
- }
180
- if (state && receipt && receipt.value.stateFingerprint !== undefined && receipt.value.stateFingerprint !== canonicalFingerprint(state)) {
181
- errors.push(issue("E_RECEIPT_STATE_MISMATCH", "Receipt does not match the recorded work state", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.receipt]));
182
- }
183
171
  if (state && !["REVIEWING", "COMPLETE"].includes(state.phase)) {
184
172
  errors.push(issue("E_PHASE_PREREQUISITE_MISSING", `Completion requires REVIEWING or COMPLETE state, found ${state.phase}`, [ARTIFACT_PATHS.state]));
185
173
  }
186
174
 
187
175
  let coverage = [];
188
- if (receipt && contract && route) {
176
+ if (contract && route) {
189
177
  const requiredEvidence = await requiredEvidenceForTarget({
190
178
  target,
191
179
  contract,
@@ -193,37 +181,15 @@ export async function evaluateCompletion({ target, packageRoot, strict = false }
193
181
  packageRoot,
194
182
  additionalEvidence: preflight?.policy?.requiredEvidence ?? [],
195
183
  });
196
- coverage = receipt.value.evidenceCoverage
197
- ? receipt.value.evidenceCoverage
198
- : coverageForRequirements(requiredEvidence, receipt.value.checks);
199
- try {
200
- assertCoverageList(coverage);
201
- } catch (error) {
202
- errors.push(issue(error.code ?? "E_EVIDENCE_COVERAGE_PARTIAL", error.message, [ARTIFACT_PATHS.receipt]));
203
- }
204
- const byRequirement = new Map(coverage.map((item) => [item.requirement, item]));
205
- for (const requirement of requiredEvidence) {
206
- const item = byRequirement.get(requirement);
207
- if (!item) {
208
- errors.push(issue("E_EVIDENCE_REQUIRED", `Evidence coverage is missing: ${requirement}`, [ARTIFACT_PATHS.receipt], { requirement }));
209
- } else if (item.status !== "COVERED") {
210
- errors.push(issue(
211
- item.status === "BLOCKED" ? "E_EVIDENCE_COVERAGE_PARTIAL" : "E_EVIDENCE_COVERAGE_PARTIAL",
212
- `Evidence coverage is ${item.status}: ${requirement}`,
213
- [ARTIFACT_PATHS.receipt],
214
- { requirement, status: item.status },
215
- ));
216
- }
217
- }
218
- const structuredChecks = receipt.value.checks.filter((check) => check?.schemaVersion === 1 || check?.id !== undefined || check?.evidenceKind !== undefined);
219
- if (structuredChecks.length > 0) {
220
- try {
221
- assertCheckList(structuredChecks, "receipt.checks");
222
- errors.push(...requiredChecksSatisfied(structuredChecks, requiredEvidence));
223
- } catch (error) {
224
- errors.push(issue(error.code ?? "E_CHECK_INVALID", error.message, [ARTIFACT_PATHS.receipt]));
225
- }
226
- }
184
+ const relationshipErrors = completionRelationshipErrors({
185
+ contract,
186
+ route,
187
+ state,
188
+ receipt: receipt?.value,
189
+ requiredEvidence,
190
+ });
191
+ errors.push(...relationshipErrors);
192
+ coverage = receipt?.value?.evidenceCoverage ?? [];
227
193
  }
228
194
 
229
195
  const ledger = contract && state
@@ -283,7 +249,12 @@ export async function runComplete({ target, packageRoot, strict = false, persist
283
249
  publicationStatus: result.publicationStatus,
284
250
  lastUpdated: new Date().toISOString(),
285
251
  };
252
+ const nextReceipt = await createReceipt({
253
+ ...receipt.value,
254
+ stateFingerprint: canonicalFingerprint(next),
255
+ }, packageRoot);
286
256
  await writeWorkState(target, next, { packageRoot });
257
+ await writeJsonArtifact(target, ARTIFACT_PATHS.receipt, nextReceipt, "execution-receipt", packageRoot);
287
258
  }
288
259
  const contract = await readContract(target, packageRoot);
289
260
  const ledger = await validateEventLedger(target, packageRoot);
@@ -10,6 +10,16 @@ import { assertSecretFree } from "./receipt.js";
10
10
  import { PROTOCOL_VERSION } from "./protocol.js";
11
11
 
12
12
  const EVENT_SCHEMA_VERSION = 1;
13
+ export const LIFECYCLE_MILESTONES = Object.freeze([
14
+ "CONTRACT_VALIDATED",
15
+ "ROUTE_VALIDATED",
16
+ "PREFLIGHT_READY",
17
+ "EXECUTION_STARTED",
18
+ "VERIFICATION_STARTED",
19
+ "VERIFICATION_RECORDED",
20
+ "COMPLETION_VALIDATED",
21
+ ]);
22
+ const REPEATABLE_MILESTONES = new Set(["VERIFICATION_RECORDED"]);
13
23
 
14
24
  function eventHash(event) {
15
25
  const { hash, ...body } = event;
@@ -82,6 +92,8 @@ export async function validateEventLedger(target, packageRoot) {
82
92
  const errors = [];
83
93
  let taskId = null;
84
94
  const seen = new Set();
95
+ let lastMilestone = -1;
96
+ const milestoneCounts = new Map();
85
97
  for (const [index, event] of events.entries()) {
86
98
  if (event.seq !== index + 1) {
87
99
  errors.push({ code: "E_EVENT_INVALID", message: `event sequence must be ${index + 1}` });
@@ -94,6 +106,25 @@ export async function validateEventLedger(target, packageRoot) {
94
106
  if (event.hash !== eventHash(event)) {
95
107
  errors.push({ code: "E_LEDGER_HASH_INVALID", message: `event ${event.seq} hash does not match its content` });
96
108
  }
109
+ const milestoneIndex = LIFECYCLE_MILESTONES.indexOf(event.event);
110
+ if (milestoneIndex >= 0) {
111
+ const count = (milestoneCounts.get(event.event) ?? 0) + 1;
112
+ milestoneCounts.set(event.event, count);
113
+ if (count > 1 && !REPEATABLE_MILESTONES.has(event.event)) {
114
+ errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
115
+ }
116
+ if (milestoneIndex > lastMilestone + 1) {
117
+ errors.push({
118
+ code: "E_PHASE_CHRONOLOGY_INVALID",
119
+ message: `${event.event} is missing prerequisite milestone: ${LIFECYCLE_MILESTONES[lastMilestone + 1]}`,
120
+ });
121
+ } else if (milestoneIndex < lastMilestone) {
122
+ errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `${event.event} is out of lifecycle order` });
123
+ } else if (milestoneIndex === lastMilestone && !REPEATABLE_MILESTONES.has(event.event)) {
124
+ errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
125
+ }
126
+ if (milestoneIndex > lastMilestone) lastMilestone = milestoneIndex;
127
+ }
97
128
  seen.add(event.event);
98
129
  if (event.event === "EXECUTION_STARTED" && !seen.has("ROUTE_VALIDATED")) {
99
130
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "execution started before route validation" });
@@ -116,6 +147,9 @@ export async function validateEventLedger(target, packageRoot) {
116
147
  }
117
148
  }
118
149
  }
150
+ if (event.event === "VERIFICATION_RECORDED" && !seen.has("VERIFICATION_STARTED")) {
151
+ errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "verification evidence recorded before verification started" });
152
+ }
119
153
  if (event.event === "COMPLETION_VALIDATED" && !seen.has("VERIFICATION_RECORDED")) {
120
154
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion validated before verification evidence" });
121
155
  }
@@ -0,0 +1,200 @@
1
+ import { ARTIFACT_PATHS, readJsonArtifact } from "./artifacts.js";
2
+ import { readContract } from "./contract.js";
3
+ import { validateEventLedger } from "./events.js";
4
+ import { evaluatePreflight, validatePersistedPreflight } from "./preflight.js";
5
+ import { readPersistedRoute } from "./route-artifact.js";
6
+ import { stateIdentityErrors } from "./completion-relationships.js";
7
+ import { classifyLoadedWorkState } from "./work-state.js";
8
+
9
+ const START_EXECUTION_EVENTS = Object.freeze([
10
+ "CONTRACT_VALIDATED",
11
+ "ROUTE_VALIDATED",
12
+ "PREFLIGHT_READY",
13
+ ]);
14
+ const POST_EXECUTION_PHASES = new Set([
15
+ "EXECUTING",
16
+ "VERIFYING",
17
+ "DIAGNOSING",
18
+ "CORRECTING",
19
+ "REVIEWING",
20
+ "COMPLETE",
21
+ ]);
22
+ export const PREFLIGHT_ROUTE_IDENTITY_ERROR_MESSAGE = "PREFLIGHT_READY event routing fingerprint does not match the current READY preflight and route";
23
+ export const PREFLIGHT_CONTRACT_IDENTITY_ERROR_MESSAGE = "PREFLIGHT_READY event contract fingerprint does not match the current READY preflight";
24
+
25
+ export function hasExecutionStarted(phase) {
26
+ return POST_EXECUTION_PHASES.has(phase);
27
+ }
28
+
29
+ export function prerequisiteError(prerequisites) {
30
+ const first = prerequisites.errors[0];
31
+ if (!first) return null;
32
+ const error = new Error(first.message);
33
+ error.code = first.code;
34
+ error.artifacts = first.artifacts;
35
+ return error;
36
+ }
37
+
38
+ export async function assertExecutionPrerequisites(input = {}) {
39
+ const prerequisites = await evaluateStartExecutionPrerequisites(input);
40
+ const error = prerequisiteError(prerequisites);
41
+ if (error) throw error;
42
+ return prerequisites;
43
+ }
44
+
45
+ function issue(code, message, artifacts = []) {
46
+ return { code, message, artifacts };
47
+ }
48
+
49
+ function sameStringSet(left, right) {
50
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
51
+ return left.length === new Set(left).size
52
+ && right.length === new Set(right).size
53
+ && left.length === right.length
54
+ && left.every((value) => right.includes(value));
55
+ }
56
+
57
+ async function load(loadArtifact, code, message, artifacts, errors) {
58
+ try {
59
+ return await loadArtifact();
60
+ } catch (error) {
61
+ errors.push(issue(code, `${message}: ${error.message}`, artifacts));
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function freshnessErrors(state, freshness) {
67
+ if (freshness.status !== "REVALIDATION_REQUIRED") return [];
68
+ return [issue(
69
+ "E_STATE_REVALIDATION_REQUIRED",
70
+ `EXECUTING requires a fresh work-state checkpoint: ${freshness.reasons.join(", ")}`,
71
+ [
72
+ ARTIFACT_PATHS.state,
73
+ ARTIFACT_PATHS.contract,
74
+ ...(state.requiredArtifacts?.map((artifact) => artifact.path) ?? []),
75
+ ],
76
+ )];
77
+ }
78
+
79
+ function prerequisiteLedgerErrors(ledger, taskId, preflight, route) {
80
+ const errors = (ledger.errors ?? []).map((error) => issue(
81
+ error.code ?? "E_PHASE_CHRONOLOGY_INVALID",
82
+ error.message,
83
+ [ARTIFACT_PATHS.events],
84
+ ));
85
+ const events = ledger.events ?? [];
86
+ if (events.some((event) => event.taskId !== taskId)) {
87
+ errors.push(issue(
88
+ "E_PHASE_CHRONOLOGY_INVALID",
89
+ "EXECUTING requires protocol prerequisite events to belong to the current task",
90
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
91
+ ));
92
+ return errors;
93
+ }
94
+ const currentEvents = events.filter((event) => event.taskId === taskId);
95
+ for (const requiredEvent of START_EXECUTION_EVENTS) {
96
+ if (!currentEvents.some((event) => event.event === requiredEvent)) {
97
+ errors.push(issue(
98
+ "E_PHASE_CHRONOLOGY_INVALID",
99
+ `EXECUTING requires a ${requiredEvent} protocol event`,
100
+ [ARTIFACT_PATHS.events],
101
+ ));
102
+ }
103
+ }
104
+ const preflightEvent = currentEvents.find((event) => event.event === "PREFLIGHT_READY");
105
+ if (preflightEvent && (!sameStringSet(preflightEvent.details?.requiredGates, preflight.requiredGates)
106
+ || !sameStringSet(preflightEvent.details?.satisfiedGates, preflight.satisfiedGates))) {
107
+ errors.push(issue(
108
+ "E_PHASE_CHRONOLOGY_INVALID",
109
+ "PREFLIGHT_READY event gate sets do not match the current READY preflight",
110
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight],
111
+ ));
112
+ }
113
+ if (preflightEvent && preflightEvent.fingerprint !== preflight.fingerprints.contract) {
114
+ errors.push(issue(
115
+ "E_PHASE_CHRONOLOGY_INVALID",
116
+ PREFLIGHT_CONTRACT_IDENTITY_ERROR_MESSAGE,
117
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.contract],
118
+ ));
119
+ }
120
+ if (preflightEvent && (preflightEvent.details?.routingFingerprint !== preflight.fingerprints.routing
121
+ || preflightEvent.details?.routingFingerprint !== route.fingerprint)) {
122
+ errors.push(issue(
123
+ "E_PHASE_CHRONOLOGY_INVALID",
124
+ PREFLIGHT_ROUTE_IDENTITY_ERROR_MESSAGE,
125
+ [ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.route],
126
+ ));
127
+ }
128
+ return errors;
129
+ }
130
+
131
+ export async function evaluateStartExecutionPrerequisites({ target, state, packageRoot } = {}) {
132
+ const errors = [];
133
+ const requiredArtifacts = [
134
+ ARTIFACT_PATHS.state,
135
+ ARTIFACT_PATHS.contract,
136
+ ARTIFACT_PATHS.route,
137
+ ARTIFACT_PATHS.preflight,
138
+ ARTIFACT_PATHS.events,
139
+ ];
140
+ if (!state) {
141
+ return {
142
+ errors: [issue("E_PHASE_PREREQUISITE_MISSING", "EXECUTING requires a work state", [ARTIFACT_PATHS.state])],
143
+ requiredArtifacts,
144
+ };
145
+ }
146
+
147
+ const contract = await load(
148
+ () => readContract(target, packageRoot),
149
+ "E_PHASE_PREREQUISITE_MISSING",
150
+ `EXECUTING requires ${ARTIFACT_PATHS.contract}`,
151
+ [ARTIFACT_PATHS.contract],
152
+ errors,
153
+ );
154
+ const route = await load(
155
+ () => readPersistedRoute(target, packageRoot),
156
+ "E_PHASE_PREREQUISITE_MISSING",
157
+ `EXECUTING requires ${ARTIFACT_PATHS.route}`,
158
+ [ARTIFACT_PATHS.route],
159
+ errors,
160
+ );
161
+ if (!contract || !route) return { errors, requiredArtifacts, contract, route };
162
+
163
+ if (state.routeFingerprint !== route.fingerprint) {
164
+ errors.push(issue(
165
+ "E_ROUTE_STALE",
166
+ "EXECUTING requires work state and route to match the current contract",
167
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.route, ARTIFACT_PATHS.contract],
168
+ ));
169
+ }
170
+ errors.push(...stateIdentityErrors({ contract, route, state }));
171
+
172
+ const freshness = await classifyLoadedWorkState({
173
+ target,
174
+ state,
175
+ contractFile: ARTIFACT_PATHS.contract,
176
+ });
177
+ errors.push(...freshnessErrors(state, freshness));
178
+
179
+ const preflight = await evaluatePreflight({ target, packageRoot });
180
+ let persistedPreflight = null;
181
+ try {
182
+ persistedPreflight = await readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot);
183
+ } catch {
184
+ // validatePersistedPreflight reports the stable, actionable preflight reason.
185
+ }
186
+ const persistedPreflightErrors = validatePersistedPreflight(persistedPreflight?.value, preflight);
187
+ if (!sameStringSet(state.requiredGates, preflight.requiredGates)
188
+ || !sameStringSet(state.satisfiedGates, preflight.satisfiedGates)) {
189
+ errors.push(issue(
190
+ "E_PREFLIGHT_GATES_STALE",
191
+ "Work state gate sets do not match the current preflight evaluation",
192
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.gates],
193
+ ));
194
+ }
195
+
196
+ const ledger = await validateEventLedger(target, packageRoot);
197
+ errors.push(...prerequisiteLedgerErrors(ledger, contract.value.taskId, preflight, route));
198
+ errors.push(...persistedPreflightErrors);
199
+ return { errors, requiredArtifacts, contract, route, preflight, persistedPreflight, ledger };
200
+ }