@cassiomc1/forgeloop 1.1.0 → 1.2.1

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.
@@ -0,0 +1,171 @@
1
+ import { appendProtocolEvent, validateEventLedger } from "./events.js";
2
+ import { readWorkState, writeWorkState } from "./work-state.js";
3
+ import {
4
+ DIAGNOSIS_INFORMATION_GAIN,
5
+ assertDiagnosisDetails,
6
+ classifyDiagnosisInformationGain,
7
+ createDiagnosisDetails,
8
+ currentCycleDiagnosis,
9
+ diagnosisEventsForTask,
10
+ diagnosisFingerprint,
11
+ normalizeDiagnosisText,
12
+ } from "./diagnosis-model.js";
13
+
14
+ export {
15
+ DIAGNOSIS_INFORMATION_GAIN,
16
+ assertDiagnosisDetails,
17
+ classifyDiagnosisInformationGain,
18
+ createDiagnosisDetails,
19
+ currentCycleDiagnosis,
20
+ diagnosisEventsForTask,
21
+ diagnosisFingerprint,
22
+ normalizeDiagnosisText,
23
+ };
24
+
25
+ export async function recordDiagnosis({
26
+ target,
27
+ packageRoot,
28
+ hypothesis,
29
+ failureClass,
30
+ evidenceRefs,
31
+ settledBy,
32
+ nextSafeAction,
33
+ taskId = null,
34
+ statePath = null,
35
+ eventsPath = null,
36
+ }) {
37
+ const state = await readWorkState(target, { packageRoot, taskId, statePath });
38
+ if (!state) {
39
+ const error = new Error("Work state not found");
40
+ error.code = "E_STATE_MISSING";
41
+ throw error;
42
+ }
43
+ if (state.phase !== "DIAGNOSING") {
44
+ const error = new Error(`Recording a diagnosis requires phase DIAGNOSING, currently ${state.phase}`);
45
+ error.code = "E_PHASE_PREREQUISITE_MISSING";
46
+ throw error;
47
+ }
48
+ if (typeof state.verificationCycle !== "number" || state.verificationCycle < 1) {
49
+ const error = new Error("Active verification cycle must be an integer >= 1");
50
+ error.code = "E_DIAGNOSIS_INVALID";
51
+ throw error;
52
+ }
53
+
54
+ const ledger = await validateEventLedger(target, packageRoot, { taskId: taskId ?? null, eventsPath });
55
+ if (!ledger.valid) {
56
+ const first = ledger.errors[0];
57
+ const error = new Error(first.message);
58
+ error.code = first.code;
59
+ throw error;
60
+ }
61
+
62
+ const verificationStarted = ledger.events.some(
63
+ (e) => e.taskId === state.taskId && e.event === "VERIFICATION_STARTED",
64
+ );
65
+ if (!verificationStarted) {
66
+ const error = new Error("No VERIFICATION_STARTED event found for active cycle");
67
+ error.code = "E_PHASE_CHRONOLOGY_INVALID";
68
+ throw error;
69
+ }
70
+
71
+ const currentCycleChecks = (state.checks ?? []).filter(
72
+ (c) => c.details?.verificationCycle === state.verificationCycle,
73
+ );
74
+
75
+ const cleanRefs = (evidenceRefs ?? []).map((r) => String(r).trim()).filter(Boolean);
76
+ if (cleanRefs.length === 0) {
77
+ const error = new Error("record-diagnosis requires at least one evidenceRef");
78
+ error.code = "E_DIAGNOSIS_EVIDENCE_INVALID";
79
+ throw error;
80
+ }
81
+
82
+ const matchedChecks = [];
83
+ for (const ref of cleanRefs) {
84
+ const check = currentCycleChecks.find((c) => (c.id === ref || c.checkId === ref));
85
+ if (!check) {
86
+ const error = new Error(`Evidence reference "${ref}" does not match any check from verification cycle ${state.verificationCycle}`);
87
+ error.code = "E_DIAGNOSIS_EVIDENCE_INVALID";
88
+ throw error;
89
+ }
90
+ matchedChecks.push(check);
91
+ }
92
+
93
+ const hasFailedOrBlocked = matchedChecks.some((c) => c.status === "failed" || c.status === "blocked");
94
+ if (!hasFailedOrBlocked) {
95
+ const error = new Error("Diagnosis evidenceRefs must include at least one failed or blocked check from the current cycle");
96
+ error.code = "E_DIAGNOSIS_EVIDENCE_INVALID";
97
+ throw error;
98
+ }
99
+
100
+ const existingEvent = currentCycleDiagnosis(
101
+ ledger.events,
102
+ state.taskId,
103
+ state.verificationCycle,
104
+ );
105
+
106
+ const requestedFingerprint = diagnosisFingerprint({
107
+ failureClass,
108
+ hypothesis,
109
+ evidenceRefs: cleanRefs,
110
+ });
111
+
112
+ if (existingEvent?.details?.diagnosisFingerprint === requestedFingerprint) {
113
+ const updatedState = {
114
+ ...state,
115
+ diagnosedHypothesis: existingEvent.details.hypothesis,
116
+ lastUpdated: new Date().toISOString(),
117
+ };
118
+ await writeWorkState(target, updatedState, {
119
+ packageRoot,
120
+ taskId: taskId ?? null,
121
+ statePath,
122
+ });
123
+ return {
124
+ event: existingEvent,
125
+ state: updatedState,
126
+ diagnosis: existingEvent.details,
127
+ idempotent: true,
128
+ };
129
+ }
130
+
131
+ const taskDiagnosisEvents = diagnosisEventsForTask(ledger.events, state.taskId);
132
+ const previousEvent = taskDiagnosisEvents.at(-1) ?? null;
133
+ const previousDetails = previousEvent?.details ?? null;
134
+
135
+ const details = createDiagnosisDetails(
136
+ {
137
+ verificationCycle: state.verificationCycle,
138
+ failureClass,
139
+ hypothesis,
140
+ evidenceRefs: cleanRefs,
141
+ settledBy,
142
+ nextSafeAction,
143
+ },
144
+ previousDetails,
145
+ );
146
+
147
+ const event = await appendProtocolEvent(
148
+ target,
149
+ {
150
+ taskId: state.taskId,
151
+ event: "DIAGNOSIS_RECORDED",
152
+ details,
153
+ },
154
+ packageRoot,
155
+ { taskId: taskId ?? null, eventsPath },
156
+ );
157
+
158
+ const updatedState = {
159
+ ...state,
160
+ diagnosedHypothesis: hypothesis.trim(),
161
+ lastUpdated: new Date().toISOString(),
162
+ };
163
+ await writeWorkState(target, updatedState, { packageRoot, taskId: taskId ?? null, statePath });
164
+
165
+ return {
166
+ event,
167
+ state: updatedState,
168
+ diagnosis: details,
169
+ idempotent: false,
170
+ };
171
+ }
@@ -30,6 +30,15 @@ export const E_TASK_LAYOUT_LEGACY = "E_TASK_LAYOUT_LEGACY";
30
30
  export const E_TASK_MIGRATION_INVALID = "E_TASK_MIGRATION_INVALID";
31
31
  export const E_TASK_MIGRATION_IDENTITY_MISMATCH = "E_TASK_MIGRATION_IDENTITY_MISMATCH";
32
32
 
33
+ export const E_DIAGNOSIS_REQUIRED = "E_DIAGNOSIS_REQUIRED";
34
+ export const E_DIAGNOSIS_INVALID = "E_DIAGNOSIS_INVALID";
35
+ export const E_DIAGNOSIS_EVIDENCE_INVALID = "E_DIAGNOSIS_EVIDENCE_INVALID";
36
+ export const E_DIAGNOSIS_CYCLE_MISMATCH = "E_DIAGNOSIS_CYCLE_MISMATCH";
37
+ export const E_DIAGNOSIS_NO_NEW_INFORMATION = "E_DIAGNOSIS_NO_NEW_INFORMATION";
38
+ export const E_PROGRESS_STALLED = "E_PROGRESS_STALLED";
39
+ export const E_DECISION_CRITERION_INVALID = "E_DECISION_CRITERION_INVALID";
40
+ export const E_DECISION_NOT_UNRESOLVED = "E_DECISION_NOT_UNRESOLVED";
41
+
33
42
  /**
34
43
  * Public, stable ForgeLoop error and reason codes documented for users and harnesses.
35
44
  */
@@ -160,6 +169,62 @@ export const PUBLIC_ERROR_CODES = Object.freeze({
160
169
  meaning: "Modified paths in repository exceed the declared task write claims.",
161
170
  safeResolution: "Update write claims with forgeloop task-scope or revert out-of-scope modifications.",
162
171
  }),
172
+ E_DIAGNOSIS_REQUIRED: Object.freeze({
173
+ code: "E_DIAGNOSIS_REQUIRED",
174
+ category: "diagnosis",
175
+ classification: "PUBLIC_STABLE",
176
+ meaning: "Current correction cycle has no append-only diagnosis record.",
177
+ safeResolution: "Run forgeloop record-diagnosis with current failed evidence before correcting.",
178
+ }),
179
+ E_DIAGNOSIS_INVALID: Object.freeze({
180
+ code: "E_DIAGNOSIS_INVALID",
181
+ category: "diagnosis",
182
+ classification: "PUBLIC_STABLE",
183
+ meaning: "Diagnosis record details or parameters are malformed.",
184
+ safeResolution: "Provide valid failureClass, hypothesis, evidenceRefs, settledBy, and nextSafeAction.",
185
+ }),
186
+ E_DIAGNOSIS_EVIDENCE_INVALID: Object.freeze({
187
+ code: "E_DIAGNOSIS_EVIDENCE_INVALID",
188
+ category: "diagnosis",
189
+ classification: "PUBLIC_STABLE",
190
+ meaning: "Referenced diagnosis evidence is missing or has no failed checks in the current cycle.",
191
+ safeResolution: "Reference at least one failed or blocked check ID from the active verification cycle.",
192
+ }),
193
+ E_DIAGNOSIS_CYCLE_MISMATCH: Object.freeze({
194
+ code: "E_DIAGNOSIS_CYCLE_MISMATCH",
195
+ category: "diagnosis",
196
+ classification: "PUBLIC_STABLE",
197
+ meaning: "Diagnosis verification cycle does not match the active work state verification cycle.",
198
+ safeResolution: "Record diagnosis for the current active verification cycle.",
199
+ }),
200
+ E_DIAGNOSIS_NO_NEW_INFORMATION: Object.freeze({
201
+ code: "E_DIAGNOSIS_NO_NEW_INFORMATION",
202
+ category: "diagnosis",
203
+ classification: "PUBLIC_STABLE",
204
+ meaning: "The proposed retry repeats the previous hypothesis with the same evidence.",
205
+ safeResolution: "Change the hypothesis, collect independent evidence, or change strategy.",
206
+ }),
207
+ E_PROGRESS_STALLED: Object.freeze({
208
+ code: "E_PROGRESS_STALLED",
209
+ category: "progress",
210
+ classification: "PUBLIC_STABLE",
211
+ meaning: "Persisted correction history shows no new diagnostic information.",
212
+ safeResolution: "Use an independent check, revisit assumptions, or record a materially different diagnosis.",
213
+ }),
214
+ E_DECISION_CRITERION_INVALID: Object.freeze({
215
+ code: "E_DECISION_CRITERION_INVALID",
216
+ category: "contract",
217
+ classification: "PUBLIC_STABLE",
218
+ meaning: "Decision settlement criterion details or parameters are malformed.",
219
+ safeResolution: "Provide non-empty decision text and settledBy criterion.",
220
+ }),
221
+ E_DECISION_NOT_UNRESOLVED: Object.freeze({
222
+ code: "E_DECISION_NOT_UNRESOLVED",
223
+ category: "contract",
224
+ classification: "PUBLIC_STABLE",
225
+ meaning: "A settlement criterion referenced a decision not present in current unresolvedDecisions.",
226
+ safeResolution: "Use the exact current unresolved decision text or update the contract first.",
227
+ }),
163
228
  });
164
229
 
165
230
  export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
@@ -12,6 +12,9 @@ import { isRecoverableCompletionEvidenceCode } from "./completion-recovery.js";
12
12
 
13
13
  import { taskArtifactPath } from "./task-paths.js";
14
14
 
15
+ import { assertDiagnosisDetails } from "./diagnosis-model.js";
16
+ import { assertDecisionCriterionDetails } from "./settlement-model.js";
17
+
15
18
  const EVENT_SCHEMA_VERSION = 1;
16
19
  export const LIFECYCLE_MILESTONES = Object.freeze([
17
20
  "CONTRACT_VALIDATED",
@@ -37,6 +40,20 @@ const REPEATABLE_MILESTONES = new Set([
37
40
  "TERMINAL_RESULT_RECORDED",
38
41
  ]);
39
42
 
43
+ export function validateKnownEventDetails(event) {
44
+ if (!event || typeof event !== "object") return;
45
+ switch (event.event) {
46
+ case "DIAGNOSIS_RECORDED":
47
+ assertDiagnosisDetails(event.details);
48
+ return;
49
+ case "DECISION_CRITERION_RECORDED":
50
+ assertDecisionCriterionDetails(event.details);
51
+ return;
52
+ default:
53
+ return;
54
+ }
55
+ }
56
+
40
57
  function eventHash(event) {
41
58
  const { hash, ...body } = event;
42
59
  return canonicalFingerprint(body);
@@ -64,8 +81,9 @@ export async function readEvents(target, packageRoot, options = {}) {
64
81
  event = JSON.parse(line);
65
82
  assertJsonLimits(event, `${relPath}[${index}]`);
66
83
  assertSchema(event, schema, `${relPath}[${index}]`);
84
+ validateKnownEventDetails(event);
67
85
  } catch (error) {
68
- throw protocolError("E_EVENT_INVALID", `${relPath} line ${index + 1}: ${error.message}`, [relPath]);
86
+ throw protocolError(error.code ?? "E_EVENT_INVALID", `${relPath} line ${index + 1}: ${error.message}`, [relPath]);
69
87
  }
70
88
  return event;
71
89
  });
@@ -88,6 +106,7 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
88
106
  previousHash: previous?.hash ?? null,
89
107
  ...(input.details ? { details: structuredClone(input.details) } : {}),
90
108
  };
109
+ validateKnownEventDetails(event);
91
110
  assertSecretFree(event);
92
111
  const schema = await readSchema("event", packageRoot);
93
112
  assertSchema(event, schema, relPath);
@@ -125,6 +144,11 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
125
144
  if (event.hash !== eventHash(event)) {
126
145
  errors.push({ code: "E_LEDGER_HASH_INVALID", message: `event ${event.seq} hash does not match its content` });
127
146
  }
147
+ try {
148
+ validateKnownEventDetails(event);
149
+ } catch (err) {
150
+ errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
151
+ }
128
152
  const milestoneIndex = LIFECYCLE_MILESTONES.indexOf(event.event);
129
153
  if (milestoneIndex >= 0) {
130
154
  const count = (milestoneCounts.get(event.event) ?? 0) + 1;
@@ -13,7 +13,9 @@ export const NEXT_ACTIONS = Object.freeze({
13
13
  CONTINUE_IMPLEMENTATION: "CONTINUE_IMPLEMENTATION",
14
14
  RECORD_VERIFICATION: "RECORD_VERIFICATION",
15
15
  DIAGNOSE: "DIAGNOSE",
16
+ RECORD_DIAGNOSIS: "RECORD_DIAGNOSIS",
16
17
  CORRECT: "CORRECT",
18
+ CHANGE_STRATEGY: "CHANGE_STRATEGY",
17
19
  ENTER_REVIEWING: "ENTER_REVIEWING",
18
20
  RECORD_TERMINAL_RESULT: "RECORD_TERMINAL_RESULT",
19
21
  PREPARE_COMPLETION: "PREPARE_COMPLETION",
@@ -32,13 +34,20 @@ export function result({
32
34
  commandSpecs = [],
33
35
  requiredArtifacts = [],
34
36
  missingArtifacts = [],
37
+ progress = undefined,
35
38
  }) {
36
39
  const normalizedReasons = reasons
37
- .map((reason) => ({
38
- code: reason.code ?? "E_NEXT_ACTION_BLOCKED",
39
- message: reason.message ?? String(reason),
40
- artifacts: uniqueSorted(reason.artifacts ?? []),
41
- }))
40
+ .map((reason) => {
41
+ const base = {
42
+ code: reason.code ?? "E_NEXT_ACTION_BLOCKED",
43
+ message: reason.message ?? String(reason),
44
+ artifacts: uniqueSorted(reason.artifacts ?? []),
45
+ };
46
+ if (reason.resolution) {
47
+ base.resolution = structuredClone(reason.resolution);
48
+ }
49
+ return base;
50
+ })
42
51
  .sort((left, right) => left.code.localeCompare(right.code)
43
52
  || left.artifacts.join("\0").localeCompare(right.artifacts.join("\0"))
44
53
  || left.message.localeCompare(right.message));
@@ -57,6 +66,7 @@ export function result({
57
66
  .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
58
67
  requiredArtifacts: uniqueSorted(requiredArtifacts),
59
68
  missingArtifacts: uniqueSorted(missingArtifacts),
69
+ ...(progress ? { progress: structuredClone(progress) } : {}),
60
70
  };
61
71
  }
62
72
 
@@ -91,6 +101,22 @@ export function recordCheckCommandSpec(requirement) {
91
101
  };
92
102
  }
93
103
 
104
+ export function recordDiagnosisCommandSpec() {
105
+ return {
106
+ commandId: "record-diagnosis",
107
+ executable: "forgeloop",
108
+ subcommand: "record-diagnosis",
109
+ argv: ["record-diagnosis"],
110
+ requiredInputs: [
111
+ { name: "hypothesis", option: "--hypothesis=<text>" },
112
+ { name: "failureClass", option: "--failure-class=<class>" },
113
+ { name: "evidenceRef", option: "--evidence-ref=<check-id>", repeatable: true },
114
+ { name: "settledBy", option: "--settled-by=<text>" },
115
+ { name: "nextSafeAction", option: "--next-safe-action=<text>" },
116
+ ],
117
+ };
118
+ }
119
+
94
120
  export function recordTerminalResultCommandSpec(requirement) {
95
121
  const reqId = requirement.id ?? requirement;
96
122
  const type = requirement.type ?? "PUBLICATION";
@@ -17,6 +17,7 @@ import {
17
17
  commandFor,
18
18
  decision,
19
19
  recordCheckCommandSpec,
20
+ recordDiagnosisCommandSpec,
20
21
  recordTerminalResultCommandSpec,
21
22
  result,
22
23
  uniqueSorted,
@@ -31,10 +32,14 @@ import {
31
32
  } from "./next-action-artifacts.js";
32
33
  import { PHASES_REQUIRING_EXECUTION_CHRONOLOGY } from "./next-action-phases.js";
33
34
  import { evaluateContinuityNextAction } from "./next-action-continuity.js";
35
+ import { currentCycleDiagnosis } from "./diagnosis-model.js";
36
+ import { evaluateProgress, PROGRESS_STATUS } from "./progress.js";
37
+ import { criterionForDecision } from "./settlement-model.js";
38
+ import { readEvents } from "./events.js";
34
39
 
35
40
  export { NEXT_ACTIONS } from "./next-action-model.js";
36
41
 
37
- export async function getNextAction(targetOrOptions = {}, packageRootOption) {
42
+ async function computeNextAction(targetOrOptions = {}, packageRootOption) {
38
43
  const normalized = typeof targetOrOptions === "string"
39
44
  ? { target: targetOrOptions, packageRoot: packageRootOption }
40
45
  : targetOrOptions;
@@ -456,19 +461,50 @@ export async function getNextAction(targetOrOptions = {}, packageRootOption) {
456
461
  });
457
462
  }
458
463
  if (state.phase === "DIAGNOSING") {
459
- if (typeof state.diagnosedHypothesis !== "string" || !state.diagnosedHypothesis.trim()) {
460
- return decision(
461
- context,
462
- NEXT_ACTIONS.RESOLVE_BLOCKER,
463
- artifactError(
464
- "E_DIAGNOSIS_HYPOTHESIS_MISSING",
465
- "Record diagnosedHypothesis in .forgeloop/work-state.json before advancing to CORRECTING",
466
- [ARTIFACT_PATHS.state],
467
- ),
464
+ const ledger = await validateEventLedger(target, packageRoot, { taskId: normalized.taskId ?? null });
465
+ const cycle = state.verificationCycle ?? 1;
466
+ const diagEvent = currentCycleDiagnosis(ledger.events, state.taskId, cycle);
467
+ if (!diagEvent) {
468
+ return result({
469
+ ...context,
470
+ nextAction: NEXT_ACTIONS.RECORD_DIAGNOSIS,
471
+ reasons: [
472
+ artifactError(
473
+ "E_DIAGNOSIS_REQUIRED",
474
+ "Current correction cycle has no append-only diagnosis record.",
475
+ [ARTIFACT_PATHS.events],
476
+ ),
477
+ ],
478
+ commandSpecs: [recordDiagnosisCommandSpec()],
479
+ requiredArtifacts: [...requiredArtifacts, ARTIFACT_PATHS.events],
480
+ });
481
+ }
482
+ const progress = evaluateProgress({ state, events: ledger.events });
483
+ if (progress.status === PROGRESS_STATUS.STALLED) {
484
+ return result({
485
+ ...context,
486
+ nextAction: NEXT_ACTIONS.CHANGE_STRATEGY,
487
+ reasons: [
488
+ artifactError(
489
+ "E_PROGRESS_STALLED",
490
+ "The current correction strategy has no new diagnostic information.",
491
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.events],
492
+ ),
493
+ ],
494
+ progress,
468
495
  requiredArtifacts,
469
- );
496
+ });
470
497
  }
471
- return decision(context, NEXT_ACTIONS.CORRECT, artifactError("PHASE_DIAGNOSING", "The persisted diagnosis hypothesis permits correction"));
498
+ return result({
499
+ ...context,
500
+ nextAction: NEXT_ACTIONS.CORRECT,
501
+ commands: [commandFor(NEXT_ACTIONS.CORRECT)],
502
+ reasons: [
503
+ artifactError("PHASE_DIAGNOSING", "The persisted diagnosis hypothesis permits correction"),
504
+ ],
505
+ ...(progress.status === PROGRESS_STATUS.WATCH ? { progress } : {}),
506
+ requiredArtifacts,
507
+ });
472
508
  }
473
509
  if (state.phase === "CORRECTING") {
474
510
  return decision(context, NEXT_ACTIONS.ENTER_VERIFYING, artifactError("PHASE_CORRECTING", "Correction is ready for verification"));
@@ -675,3 +711,64 @@ export async function getNextAction(targetOrOptions = {}, packageRootOption) {
675
711
  requiredArtifacts,
676
712
  );
677
713
  }
714
+
715
+ export async function getNextAction(targetOrOptions = {}, packageRootOption) {
716
+ const res = await computeNextAction(targetOrOptions, packageRootOption);
717
+ if (res && res.reasons && res.reasons.some((r) => r.code === "E_CONTRACT_UNRESOLVED_DECISION" || r.code === "E_UNRESOLVED_DECISION")) {
718
+ const normalized = typeof targetOrOptions === "string" ? { target: targetOrOptions, packageRoot: packageRootOption } : targetOrOptions;
719
+ const { target, packageRoot, taskId } = normalized ?? {};
720
+ try {
721
+ const explicitTaskId = taskId ?? null;
722
+ const contract = await readContract(target, packageRoot, { taskId: explicitTaskId });
723
+ const events = await readEvents(target, packageRoot, { taskId: explicitTaskId });
724
+ if (contract?.value?.unresolvedDecisions?.length > 0) {
725
+ const foundCriteria = [];
726
+ for (const dec of contract.value.unresolvedDecisions) {
727
+ const criterion = criterionForDecision(events, res.taskId, dec, contract.fingerprint);
728
+ if (criterion) {
729
+ foundCriteria.push({
730
+ decisionId: criterion.decisionId,
731
+ decision: dec,
732
+ settledBy: criterion.settledBy,
733
+ });
734
+ }
735
+ }
736
+
737
+ if (foundCriteria.length > 0) {
738
+ let resolution;
739
+ if (foundCriteria.length === 1) {
740
+ resolution = {
741
+ kind: "SETTLEMENT_CRITERION",
742
+ itemId: foundCriteria[0].decisionId,
743
+ decision: foundCriteria[0].decision,
744
+ settledBy: foundCriteria[0].settledBy,
745
+ };
746
+ } else {
747
+ resolution = {
748
+ kind: "SETTLEMENT_CRITERIA",
749
+ items: foundCriteria,
750
+ };
751
+ }
752
+
753
+ const enrichedReasons = res.reasons.map((r) => {
754
+ if (r.code === "E_CONTRACT_UNRESOLVED_DECISION" || r.code === "E_UNRESOLVED_DECISION") {
755
+ return {
756
+ ...r,
757
+ resolution,
758
+ };
759
+ }
760
+ return r;
761
+ });
762
+
763
+ return result({
764
+ ...res,
765
+ reasons: enrichedReasons,
766
+ });
767
+ }
768
+ }
769
+ } catch {
770
+ // Ignore read errors
771
+ }
772
+ }
773
+ return res;
774
+ }
package/src/core/phase.js CHANGED
@@ -21,6 +21,7 @@ import { discoverTasks } from "./task-discovery.js";
21
21
  import { assertNoScopeConflicts, assertScopeClean } from "./task-scope.js";
22
22
  import { readTaskDescriptor } from "./task-descriptor.js";
23
23
  import { E_TASK_SCOPE_REQUIRED } from "./error-codes.js";
24
+ import { currentCycleDiagnosis } from "./diagnosis-model.js";
24
25
 
25
26
  function phaseError(code, message, artifacts = []) {
26
27
  const error = new Error(message);
@@ -242,7 +243,35 @@ export async function advanceWorkState(target, toPhase, options = {}) {
242
243
  );
243
244
  }
244
245
  }
246
+ if (toPhase === "CORRECTING" && state.phase === "DIAGNOSING") {
247
+ const cycle = state.verificationCycle ?? 1;
248
+ const diagEvent = currentCycleDiagnosis(ledger.events, state.taskId, cycle);
249
+ if (!diagEvent) {
250
+ throw phaseError(
251
+ "E_DIAGNOSIS_REQUIRED",
252
+ "DIAGNOSING -> CORRECTING requires an append-only diagnosis record for the active verification cycle",
253
+ [eventsRel],
254
+ );
255
+ }
256
+ const details = diagEvent.details;
257
+ if (!details || details.informationGain === "NONE") {
258
+ throw phaseError(
259
+ "E_DIAGNOSIS_NO_NEW_INFORMATION",
260
+ "The proposed retry repeats the previous hypothesis with the same evidence without new information",
261
+ [eventsRel],
262
+ );
263
+ }
264
+ }
245
265
  if (toPhase === "VERIFYING" && state.phase === "CORRECTING") {
266
+ const cycle = state.verificationCycle ?? 1;
267
+ const diagEvent = currentCycleDiagnosis(ledger.events, state.taskId, cycle);
268
+ if (!diagEvent) {
269
+ throw phaseError(
270
+ "E_DIAGNOSIS_REQUIRED",
271
+ "CORRECTING -> VERIFYING requires an append-only diagnosis record for the current cycle",
272
+ [eventsRel],
273
+ );
274
+ }
246
275
  if (typeof state.diagnosedHypothesis !== "string" || !state.diagnosedHypothesis.trim()) {
247
276
  throw phaseError(
248
277
  "E_PHASE_PREREQUISITE_MISSING",
@@ -34,11 +34,19 @@ export function sameStringSet(left, right) {
34
34
  export function validatePersistedPreflight(persisted, current) {
35
35
  const errors = [];
36
36
  if (persisted?.status !== "READY") {
37
- errors.push(issue("E_PREFLIGHT_NOT_READY", "A persisted READY preflight is required", [ARTIFACT_PATHS.preflight]));
37
+ if (Array.isArray(persisted?.errors) && persisted.errors.length > 0) {
38
+ errors.push(...persisted.errors);
39
+ } else {
40
+ errors.push(issue("E_PREFLIGHT_NOT_READY", "A persisted READY preflight is required", [ARTIFACT_PATHS.preflight]));
41
+ }
38
42
  return errors;
39
43
  }
40
44
  if (current?.status !== "READY") {
41
- errors.push(issue("E_PREFLIGHT_NOT_READY", "The current preflight evaluation is not READY", [ARTIFACT_PATHS.preflight]));
45
+ if (Array.isArray(current?.errors) && current.errors.length > 0) {
46
+ errors.push(...current.errors);
47
+ } else {
48
+ errors.push(issue("E_PREFLIGHT_NOT_READY", "The current preflight evaluation is not READY", [ARTIFACT_PATHS.preflight]));
49
+ }
42
50
  }
43
51
  if (persisted.taskId !== current?.taskId) {
44
52
  errors.push(issue(