@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.
@@ -8,6 +8,8 @@ This guide provides symptom-first recovery procedures for common ForgeLoop proto
8
8
 
9
9
  - [`preflight` is `BLOCKED`](#symptom-preflight-is-blocked)
10
10
  - [`forgeloop next` returns `RESOLVE_BLOCKER`](#symptom-forgeloop-next-returns-resolve_blocker)
11
+ - [`forgeloop next` returns `RECORD_DIAGNOSIS`](#symptom-forgeloop-next-returns-record_diagnosis)
12
+ - [Progress is `STALLED` or `forgeloop next` returns `CHANGE_STRATEGY`](#symptom-progress-is-stalled)
11
13
  - [Protocol state or contract is `STALE`](#symptom-state-or-contract-is-stale)
12
14
  - [Execution continuity is `STALE`](#symptom-continuity-is-stale)
13
15
  - [Multiple tasks ambiguous (`E_TASK_AMBIGUOUS`)](#symptom-multiple-tasks-ambiguous)
@@ -27,26 +29,30 @@ This guide provides symptom-first recovery procedures for common ForgeLoop proto
27
29
 
28
30
  #### What it means
29
31
 
30
- Pre-implementation gates (e.g. `design`, `threat-boundary`) are unsatisfied, missing, or referencing stale files.
32
+ Pre-implementation gates (e.g. `design`, `threat-boundary`) are unsatisfied, missing, or referencing stale files, or the contract contains unresolved blocking decisions. ForgeLoop preserves specific preflight error codes (`E_CONTRACT_UNRESOLVED_DECISION`, `E_CONTRACT_STALE`, `E_ROUTE_STALE`, `E_GATE_UNVERIFIED`) in `reasons` instead of reducing them to generic readiness errors.
31
33
 
32
34
  #### Likely causes
33
35
 
34
- 1. A gate required by an activated guide has no corresponding `.forgeloop/task-state/<taskKey>/gates/<gate>.json` file.
35
- 2. The gate artifact references files whose SHA-256 hashes changed after the gate was satisfied.
36
- 3. Contract `unresolvedDecisions` contains blocking decisions.
36
+ 1. A gate required by an activated guide has no corresponding `.forgeloop/task-state/<taskKey>/gates/<gate>.json` file (`E_GATE_UNVERIFIED`).
37
+ 2. The gate artifact references files whose SHA-256 hashes changed after the gate was satisfied (`E_GATE_STALE`).
38
+ 3. Contract `unresolvedDecisions` contains blocking decisions (`E_CONTRACT_UNRESOLVED_DECISION`).
37
39
 
38
40
  #### Inspect
39
41
 
40
42
  ```bash
41
43
  forgeloop task-show --task <id> --json
42
44
  forgeloop preflight --task <id> --json
45
+ forgeloop next --task <id> --json
43
46
  ```
44
47
 
45
48
  #### Safe recovery
46
49
 
47
50
  1. If a gate is missing, satisfy required gates or create the gate artifact with status `"satisfied"`.
48
51
  2. If an artifact hash changed, update the artifact SHA-256 in the gate file.
49
- 3. Re-run `forgeloop preflight --task <id> --json`.
52
+ 3. If `unresolvedDecisions` contains blocking items:
53
+ - Record settlement guidance with `forgeloop record-decision-criterion --decision="..." --settled-by="..."` to provide context.
54
+ - Resolve or remove the blocking decision in `contract.json`.
55
+ 4. Re-run `forgeloop preflight --task <id> --json`.
50
56
 
51
57
  #### Do not
52
58
 
@@ -77,7 +83,75 @@ forgeloop next --task <id> --json
77
83
 
78
84
  1. Check the `reasons` field in the `forgeloop next --json` output.
79
85
  2. Follow the suggested command in `commands` or `commandSpecs`.
80
- 3. If in `VERIFYING` after a failure, record a hypothesis, apply the fix, and re-run `run-check`.
86
+ 3. If in `VERIFYING` after a failure, advance to `DIAGNOSING`, record a diagnosis with `forgeloop record-diagnosis`, and advance to `CORRECTING`.
87
+
88
+ ---
89
+
90
+ ### Symptom: `forgeloop next` returns `RECORD_DIAGNOSIS`
91
+
92
+ #### What it means
93
+
94
+ The task is in `DIAGNOSING` phase following a verification failure, but no append-only diagnosis event (`DIAGNOSIS_RECORDED`) has been recorded for the active verification cycle (`E_DIAGNOSIS_REQUIRED`).
95
+
96
+ #### Likely causes
97
+
98
+ 1. A check failed in `VERIFYING` and the phase was advanced to `DIAGNOSING` without calling `record-diagnosis`.
99
+ 2. An attempt was made to advance directly to `CORRECTING` without recording an evidence-backed root cause hypothesis.
100
+
101
+ #### Inspect
102
+
103
+ ```bash
104
+ forgeloop status --task <id> --json
105
+ forgeloop next --task <id> --json
106
+ ```
107
+
108
+ #### Safe recovery
109
+
110
+ Record an append-only diagnosis referencing at least one failed or blocked check from the current verification cycle:
111
+
112
+ ```bash
113
+ forgeloop record-diagnosis --task <id> \
114
+ --hypothesis="Root cause explanation" \
115
+ --failure-class="VERIFICATION_FAILURE" \
116
+ --evidence-ref="failed-check-id" \
117
+ --settled-by="Observable condition that settles the hypothesis" \
118
+ --next-safe-action="Smallest safe action to address the hypothesis"
119
+ ```
120
+
121
+ Then advance to `CORRECTING`:
122
+
123
+ ```bash
124
+ forgeloop advance --task <id> --to CORRECTING
125
+ ```
126
+
127
+ ---
128
+
129
+ ### Symptom: Progress is `STALLED`
130
+
131
+ #### What it means
132
+
133
+ Deterministic progress evaluation detected that iterative correction cycles are not advancing (`E_PROGRESS_STALLED`). The latest diagnosis produced `informationGain: NONE` (signal `NO_DIAGNOSTIC_INFORMATION_GAIN`), or a specific contract requirement failed across 3+ verification cycles with an identical diagnosis (`REPEATED_FAILURE_WITH_SAME_DIAGNOSIS`).
134
+
135
+ #### Likely causes
136
+
137
+ 1. A recorded diagnosis in a new cycle repeated the previous hypothesis with the exact same evidence references (`informationGain: NONE`). Note: technical retries within the *same* cycle are idempotent and do not cause stalls, but repeating in a *new* cycle does.
138
+ 2. The same requirement has repeatedly failed across 3 or more verification cycles with unchanged diagnostic hypotheses.
139
+ 3. Minor cosmetic changes were made to `settledBy` or `nextSafeAction` without changing the root hypothesis or evidence references.
140
+
141
+ #### Inspect
142
+
143
+ ```bash
144
+ forgeloop progress --task <id> --json
145
+ forgeloop next --task <id> --json
146
+ ```
147
+
148
+ #### Safe recovery
149
+
150
+ 1. When stalled, `forgeloop next` returns `nextAction: "CHANGE_STRATEGY"` with error code `E_PROGRESS_STALLED`.
151
+ 2. Do not repeat the same retry or correction action.
152
+ 3. Re-examine the failure evidence from a new angle or gather fresh diagnostic evidence.
153
+ 4. Formulate a genuinely new root-cause hypothesis with new evidence references and record it with `forgeloop record-diagnosis`.
154
+ 5. Once a diagnosis with positive information gain (`NEW_HYPOTHESIS`, `NEW_EVIDENCE`, `NEW_HYPOTHESIS_AND_EVIDENCE`) is recorded, `forgeloop next` returns `CORRECT` and status returns to `ADVANCING`.
81
155
 
82
156
  ---
83
157
 
@@ -341,5 +415,13 @@ forgeloop next --task <id> --json
341
415
  | `E_TASK_SCOPE_CONFLICT` | Task write claims overlap with another non-complete task in the same checkout. | Adjust write claims to non-overlapping paths or run tasks in separate worktrees. |
342
416
  | `E_TASK_SCOPE_DIRTY` | Claimed paths contain pre-existing uncommitted changes. | Commit or stash changes in claimed paths before defining or adopting the scope. |
343
417
  | `E_TASK_CHANGE_OUTSIDE_SCOPE` | Modified paths in repository exceed the declared task write claims. | Update write claims with forgeloop task-scope or revert out-of-scope modifications. |
418
+ | `E_DIAGNOSIS_REQUIRED` | Current correction cycle has no append-only diagnosis record. | Run forgeloop record-diagnosis with current failed evidence before correcting. |
419
+ | `E_DIAGNOSIS_INVALID` | Diagnosis record details or parameters are malformed. | Provide valid failureClass, hypothesis, evidenceRefs, settledBy, and nextSafeAction. |
420
+ | `E_DIAGNOSIS_EVIDENCE_INVALID` | Referenced diagnosis evidence is missing or has no failed checks in the current cycle. | Reference at least one failed or blocked check ID from the active verification cycle. |
421
+ | `E_DIAGNOSIS_CYCLE_MISMATCH` | Diagnosis verification cycle does not match the active work state verification cycle. | Record diagnosis for the current active verification cycle. |
422
+ | `E_DIAGNOSIS_NO_NEW_INFORMATION` | The proposed retry repeats the previous hypothesis with the same evidence. | Change the hypothesis, collect independent evidence, or change strategy. |
423
+ | `E_PROGRESS_STALLED` | Persisted correction history shows no new diagnostic information. | Use an independent check, revisit assumptions, or record a materially different diagnosis. |
424
+ | `E_DECISION_CRITERION_INVALID` | Decision settlement criterion details or parameters are malformed. | Provide non-empty decision text and settledBy criterion. |
425
+ | `E_DECISION_NOT_UNRESOLVED` | A settlement criterion referenced a decision not present in current unresolvedDecisions. | Use the exact current unresolved decision text or update the contract first. |
344
426
 
345
427
  <!-- END FORGELOOP GENERATED: public-error-codes -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cassiomc1/forgeloop",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Portable, verifiable engineering protocol for AI coding environments and developer workflows",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.js CHANGED
@@ -26,6 +26,9 @@ import { formatPrepareCompletionResult, runPrepareCompletion } from "./commands/
26
26
  import { formatRecordCheckResult, runRecordCheck } from "./commands/record-check.js";
27
27
  import { formatRunCheckResult, runCheck } from "./commands/run-check.js";
28
28
  import { formatRecordTerminalResult, runRecordTerminalResult } from "./commands/record-terminal-result.js";
29
+ import { formatRecordDiagnosisResult, runRecordDiagnosis } from "./commands/record-diagnosis.js";
30
+ import { formatProgressResult, runProgress } from "./commands/progress.js";
31
+ import { formatRecordDecisionCriterionResult, runRecordDecisionCriterion } from "./commands/record-decision-criterion.js";
29
32
  import { formatNextActionResult, runNext } from "./commands/next.js";
30
33
  import { formatContinuityResult, runContinuity } from "./commands/continuity.js";
31
34
  import { formatRecordContinuityResult, runRecordContinuity } from "./commands/record-continuity.js";
@@ -500,6 +503,36 @@ export const COMMAND_HANDLERS = Object.freeze({
500
503
  console.log(options.json ? JSON.stringify(result, null, 2) : formatRecordTerminalResult(result));
501
504
  return 0;
502
505
  },
506
+ "record-diagnosis": async ({ target, packageRoot, options }) => {
507
+ const result = await runRecordDiagnosis({
508
+ target,
509
+ packageRoot,
510
+ hypothesis: options.hypothesis,
511
+ failureClass: options.failureClass,
512
+ evidenceRefs: options.evidenceRefs,
513
+ settledBy: options.settledBy,
514
+ nextSafeAction: options.nextSafeAction,
515
+ taskId: options.task,
516
+ });
517
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatRecordDiagnosisResult(result));
518
+ return 0;
519
+ },
520
+ progress: async ({ target, packageRoot, options }) => {
521
+ const result = await runProgress({ target, packageRoot, taskId: options.task });
522
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatProgressResult(result));
523
+ return result.status === "STALLED" ? 1 : 0;
524
+ },
525
+ "record-decision-criterion": async ({ target, packageRoot, options }) => {
526
+ const result = await runRecordDecisionCriterion({
527
+ target,
528
+ packageRoot,
529
+ decision: options.decision,
530
+ settledBy: options.settledBy,
531
+ taskId: options.task,
532
+ });
533
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatRecordDecisionCriterionResult(result));
534
+ return 0;
535
+ },
503
536
  complete: async ({ target, packageRoot, options }) => {
504
537
  const result = await runComplete({ target, packageRoot, strict: options.strict, taskId: options.task });
505
538
  console.log(options.json ? JSON.stringify(result, null, 2) : formatCompleteResult(result));
@@ -14,7 +14,21 @@ export function formatNextActionResult(result) {
14
14
  ];
15
15
  if (result.reasons.length > 0) {
16
16
  lines.push("REASONS:");
17
- lines.push(...result.reasons.map((reason) => `- ${reason.code}: ${reason.message}`));
17
+ for (const reason of result.reasons) {
18
+ lines.push(`- ${reason.code}: ${reason.message}`);
19
+ if (reason.resolution?.kind === "SETTLEMENT_CRITERION" && reason.resolution.settledBy) {
20
+ lines.push(` SETTLED BY: ${reason.resolution.settledBy}`);
21
+ } else if (reason.resolution?.kind === "SETTLEMENT_CRITERIA" && Array.isArray(reason.resolution.items)) {
22
+ lines.push(" SETTLEMENT CRITERIA:");
23
+ for (const item of reason.resolution.items) {
24
+ lines.push(` - ${item.decision}`);
25
+ lines.push(` SETTLED BY: ${item.settledBy}`);
26
+ }
27
+ }
28
+ }
29
+ }
30
+ if (result.progress) {
31
+ lines.push(`PROGRESS: ${result.progress.status}`);
18
32
  }
19
33
  if (result.commands.length > 0) {
20
34
  lines.push("COMMANDS (SAFE SYNOPSIS ONLY):");
@@ -0,0 +1,51 @@
1
+ import { evaluateProgress, PROGRESS_STATUS } from "../core/progress.js";
2
+ import { readEvents } from "../core/events.js";
3
+ import { readWorkState } from "../core/work-state.js";
4
+ import { resolveTaskContext } from "../core/task-context.js";
5
+
6
+ export { evaluateProgress };
7
+
8
+ export async function runProgress({ target, packageRoot, taskId, task }) {
9
+ const resolved = await resolveTaskContext(target, { packageRoot, explicitTaskId: taskId ?? task });
10
+ const activeTaskId = resolved.taskId;
11
+
12
+ const state = await readWorkState(target, { packageRoot, taskId: activeTaskId });
13
+ const events = await readEvents(target, packageRoot, { taskId: activeTaskId });
14
+
15
+ const progress = evaluateProgress({ state, events });
16
+ return {
17
+ taskId: activeTaskId ?? state?.taskId ?? "unknown",
18
+ phase: state?.phase ?? "UNKNOWN",
19
+ verificationCycle: state?.verificationCycle ?? 1,
20
+ ...progress,
21
+ };
22
+ }
23
+
24
+ export function formatProgressResult(result) {
25
+ const lines = [
26
+ `FORGELOOP PROGRESS: ${result.status}`,
27
+ `PHASE: ${result.phase}`,
28
+ `CYCLE: ${result.verificationCycle}`,
29
+ ];
30
+
31
+ if (result.signals && result.signals.length > 0) {
32
+ lines.push("SIGNALS:");
33
+ for (const signal of result.signals) {
34
+ lines.push(`- ${signal.code}: ${signal.message}`);
35
+ }
36
+ } else {
37
+ lines.push("SIGNALS: none");
38
+ }
39
+
40
+ let recommended = "NONE";
41
+ if (result.status === PROGRESS_STATUS.STALLED) {
42
+ recommended = "CHANGE_STRATEGY";
43
+ } else if (result.status === PROGRESS_STATUS.WATCH) {
44
+ recommended = "REVIEW_CHECKS";
45
+ } else {
46
+ recommended = "ADVANCE";
47
+ }
48
+ lines.push(`RECOMMENDED: ${recommended}`);
49
+
50
+ return lines.join("\n") + "\n";
51
+ }
@@ -0,0 +1,34 @@
1
+ import { recordDecisionCriterion } from "../core/settlement.js";
2
+ import { withTaskMutation } from "../core/task-command.js";
3
+
4
+ export { recordDecisionCriterion };
5
+
6
+ export async function runRecordDecisionCriterion({
7
+ target,
8
+ packageRoot,
9
+ decision,
10
+ settledBy,
11
+ taskId,
12
+ task,
13
+ }) {
14
+ return withTaskMutation(target, { taskId: taskId ?? task, packageRoot }, "record-decision-criterion", async (ctx) => {
15
+ return recordDecisionCriterion({
16
+ target,
17
+ packageRoot,
18
+ decision,
19
+ settledBy,
20
+ taskId: ctx?.taskId ?? null,
21
+ });
22
+ });
23
+ }
24
+
25
+ export function formatRecordDecisionCriterionResult(result) {
26
+ const c = result.criterion ?? result.event?.details ?? {};
27
+ return [
28
+ `FORGELOOP DECISION CRITERION RECORDED`,
29
+ `DECISION: ${c.decision}`,
30
+ `DECISION ID: ${c.decisionId}`,
31
+ `SETTLED BY: ${c.settledBy}`,
32
+ `CONTRACT FINGERPRINT: ${c.contractFingerprint}`,
33
+ ].join("\n") + "\n";
34
+ }
@@ -0,0 +1,49 @@
1
+ import { recordDiagnosis } from "../core/diagnosis.js";
2
+ import { withTaskMutation } from "../core/task-command.js";
3
+
4
+ export { recordDiagnosis };
5
+
6
+ export async function runRecordDiagnosis({
7
+ target,
8
+ packageRoot,
9
+ hypothesis,
10
+ failureClass,
11
+ evidenceRefs = [],
12
+ evidenceRef = null,
13
+ settledBy,
14
+ nextSafeAction,
15
+ taskId,
16
+ task,
17
+ }) {
18
+ const refs = Array.isArray(evidenceRefs) && evidenceRefs.length > 0
19
+ ? evidenceRefs
20
+ : (evidenceRef ? [evidenceRef] : []);
21
+
22
+ return withTaskMutation(target, { taskId: taskId ?? task, packageRoot }, "record-diagnosis", async (ctx) => {
23
+ return recordDiagnosis({
24
+ target,
25
+ packageRoot,
26
+ hypothesis,
27
+ failureClass,
28
+ evidenceRefs: refs,
29
+ settledBy,
30
+ nextSafeAction,
31
+ taskId: ctx?.taskId ?? null,
32
+ });
33
+ });
34
+ }
35
+
36
+ export function formatRecordDiagnosisResult(result) {
37
+ const d = result.diagnosis ?? result.event?.details ?? {};
38
+ return [
39
+ `FORGELOOP DIAGNOSIS RECORDED`,
40
+ `CYCLE: ${d.verificationCycle}`,
41
+ `FAILURE CLASS: ${d.failureClass}`,
42
+ `HYPOTHESIS: ${d.hypothesis}`,
43
+ `INFORMATION GAIN: ${d.informationGain}`,
44
+ `EVIDENCE: ${(d.evidenceRefs ?? []).join(", ")}`,
45
+ `SETTLED BY: ${d.settledBy}`,
46
+ `NEXT SAFE ACTION: ${d.nextSafeAction}`,
47
+ `FINGERPRINT: ${d.diagnosisFingerprint}`,
48
+ ].join("\n") + "\n";
49
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Canonical, declarative definition of all 33 ForgeLoop CLI commands.
2
+ * Canonical, declarative definition of all 36 ForgeLoop CLI commands.
3
3
  * This is the machine source of truth for CLI option parsing, help text,
4
4
  * metadata, documentation generation, and conformance validation.
5
5
  *
@@ -317,6 +317,55 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
317
317
  mayExecuteExternalProcess: false,
318
318
  description: "Records external terminal result evidence (PUBLICATION or PRODUCTION_READINESS) into receipt.",
319
319
  }),
320
+ "record-diagnosis": Object.freeze({
321
+ name: "record-diagnosis",
322
+ category: "lifecycle",
323
+ mutation: "MUTATING",
324
+ options: Object.freeze({
325
+ ...CLI_COMMON_OPTIONS,
326
+ ...CLI_TASK_OPTION,
327
+ "--hypothesis": Object.freeze({ targetKey: "hypothesis", parseType: "string", takesValue: true, valueName: "text", missingValueMessage: "--hypothesis requires text", description: "specific root-cause hypothesis explaining the verification failure" }),
328
+ "--failure-class": Object.freeze({ targetKey: "failureClass", parseType: "string", takesValue: true, valueName: "class", missingValueMessage: "--failure-class requires a class", description: "canonical failure class taxonomy" }),
329
+ "--evidence-ref": Object.freeze({ targetKey: "evidenceRefs", parseType: "string", takesValue: true, valueName: "check-id", repeatable: true, missingValueMessage: "--evidence-ref requires a check ID", description: "reference to failed/blocked check from current cycle" }),
330
+ "--settled-by": Object.freeze({ targetKey: "settledBy", parseType: "string", takesValue: true, valueName: "text", missingValueMessage: "--settled-by requires text", description: "falsification or settlement criteria for the hypothesis" }),
331
+ "--next-safe-action": Object.freeze({ targetKey: "nextSafeAction", parseType: "string", takesValue: true, valueName: "text", missingValueMessage: "--next-safe-action requires text", description: "smallest safe action to address the hypothesis" }),
332
+ "--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured output as JSON" }),
333
+ }),
334
+ writes: [".forgeloop/task-state/<taskKey>/events.ndjson", ".forgeloop/task-state/<taskKey>/work-state.json"],
335
+ removes: [],
336
+ mayExecuteExternalProcess: false,
337
+ description: "Records an append-only diagnosis event in the lifecycle event ledger for the active cycle.",
338
+ }),
339
+ progress: Object.freeze({
340
+ name: "progress",
341
+ category: "diagnostics",
342
+ mutation: "READ_ONLY",
343
+ options: Object.freeze({
344
+ ...CLI_COMMON_OPTIONS,
345
+ ...CLI_TASK_OPTION,
346
+ "--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit progress evaluation as JSON" }),
347
+ }),
348
+ writes: [],
349
+ removes: [],
350
+ mayExecuteExternalProcess: false,
351
+ description: "Evaluates task progress across verification cycles and detects stalls deterministically.",
352
+ }),
353
+ "record-decision-criterion": Object.freeze({
354
+ name: "record-decision-criterion",
355
+ category: "lifecycle",
356
+ mutation: "MUTATING",
357
+ options: Object.freeze({
358
+ ...CLI_COMMON_OPTIONS,
359
+ ...CLI_TASK_OPTION,
360
+ "--decision": Object.freeze({ targetKey: "decision", parseType: "string", takesValue: true, valueName: "text", missingValueMessage: "--decision requires text", description: "unresolved decision text matching current contract" }),
361
+ "--settled-by": Object.freeze({ targetKey: "settledBy", parseType: "string", takesValue: true, valueName: "text", missingValueMessage: "--settled-by requires text", description: "criteria or guidance that settles the decision" }),
362
+ "--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured output as JSON" }),
363
+ }),
364
+ writes: [".forgeloop/task-state/<taskKey>/events.ndjson"],
365
+ removes: [],
366
+ mayExecuteExternalProcess: false,
367
+ description: "Records an append-only decision settlement criterion bound to the active contract fingerprint.",
368
+ }),
320
369
  complete: Object.freeze({
321
370
  name: "complete",
322
371
  category: "lifecycle",
@@ -0,0 +1,214 @@
1
+ import { canonicalFingerprint } from "./artifacts.js";
2
+ import { assertFailureClass } from "./protocol.js";
3
+
4
+ export const DIAGNOSIS_INFORMATION_GAIN = Object.freeze([
5
+ "FIRST_DIAGNOSIS",
6
+ "NEW_HYPOTHESIS",
7
+ "NEW_EVIDENCE",
8
+ "NEW_HYPOTHESIS_AND_EVIDENCE",
9
+ "NONE",
10
+ ]);
11
+
12
+ export function normalizeDiagnosisText(value, label = "value") {
13
+ if (typeof value !== "string" || !value.trim()) {
14
+ const error = new Error(`${label} must be a non-empty string`);
15
+ error.code = "E_DIAGNOSIS_INVALID";
16
+ throw error;
17
+ }
18
+ return value.trim().replace(/\s+/gu, " ").toLowerCase();
19
+ }
20
+
21
+ export function diagnosisFingerprint({ failureClass, hypothesis, evidenceRefs }) {
22
+ assertFailureClass(failureClass);
23
+ const normalizedHypothesis = normalizeDiagnosisText(hypothesis, "hypothesis");
24
+ if (!Array.isArray(evidenceRefs) || evidenceRefs.length === 0) {
25
+ const error = new Error("evidenceRefs must be a non-empty array of strings");
26
+ error.code = "E_DIAGNOSIS_INVALID";
27
+ throw error;
28
+ }
29
+ const cleanRefs = evidenceRefs.map((ref) => {
30
+ if (typeof ref !== "string" || !ref.trim()) {
31
+ const error = new Error("evidenceRef must be a non-empty string");
32
+ error.code = "E_DIAGNOSIS_INVALID";
33
+ throw error;
34
+ }
35
+ return ref.trim();
36
+ });
37
+ const normalizedRefs = [...new Set(cleanRefs)].sort();
38
+ return canonicalFingerprint({
39
+ failureClass,
40
+ hypothesis: normalizedHypothesis,
41
+ evidenceRefs: normalizedRefs,
42
+ });
43
+ }
44
+
45
+ export function classifyDiagnosisInformationGain(current, previous) {
46
+ if (!previous) return "FIRST_DIAGNOSIS";
47
+
48
+ const currentHypothesis = normalizeDiagnosisText(current.hypothesis, "current hypothesis");
49
+ const prevHypothesis = normalizeDiagnosisText(previous.hypothesis, "previous hypothesis");
50
+ const sameHypothesis = currentHypothesis === prevHypothesis;
51
+
52
+ const currentEvidence = [...new Set((current.evidenceRefs ?? []).map((r) => String(r).trim()))].sort();
53
+ const prevEvidence = [...new Set((previous.evidenceRefs ?? []).map((r) => String(r).trim()))].sort();
54
+ const sameEvidence = JSON.stringify(currentEvidence) === JSON.stringify(prevEvidence);
55
+
56
+ if (sameHypothesis && sameEvidence) return "NONE";
57
+ if (!sameHypothesis && !sameEvidence) return "NEW_HYPOTHESIS_AND_EVIDENCE";
58
+ if (!sameHypothesis) return "NEW_HYPOTHESIS";
59
+ return "NEW_EVIDENCE";
60
+ }
61
+
62
+ export function createDiagnosisDetails(input, previous = null) {
63
+ if (typeof input?.verificationCycle !== "number" || !Number.isInteger(input.verificationCycle) || input.verificationCycle < 1) {
64
+ const error = new Error("verificationCycle must be an integer >= 1");
65
+ error.code = "E_DIAGNOSIS_INVALID";
66
+ throw error;
67
+ }
68
+ try {
69
+ assertFailureClass(input.failureClass);
70
+ } catch {
71
+ const error = new Error(`Invalid failureClass: ${input.failureClass}`);
72
+ error.code = "E_DIAGNOSIS_INVALID";
73
+ throw error;
74
+ }
75
+ if (typeof input.hypothesis !== "string" || !input.hypothesis.trim()) {
76
+ const error = new Error("hypothesis must be a non-empty string");
77
+ error.code = "E_DIAGNOSIS_INVALID";
78
+ throw error;
79
+ }
80
+ if (!Array.isArray(input.evidenceRefs) || input.evidenceRefs.length === 0) {
81
+ const error = new Error("evidenceRefs must be a non-empty array");
82
+ error.code = "E_DIAGNOSIS_INVALID";
83
+ throw error;
84
+ }
85
+ const cleanRefs = input.evidenceRefs.map((r) => {
86
+ if (typeof r !== "string" || !r.trim()) {
87
+ const error = new Error("each evidenceRef must be a non-empty string");
88
+ error.code = "E_DIAGNOSIS_INVALID";
89
+ throw error;
90
+ }
91
+ return r.trim();
92
+ });
93
+ const uniqueRefs = [...new Set(cleanRefs)].sort();
94
+ if (typeof input.settledBy !== "string" || !input.settledBy.trim()) {
95
+ const error = new Error("settledBy must be a non-empty string");
96
+ error.code = "E_DIAGNOSIS_INVALID";
97
+ throw error;
98
+ }
99
+ if (typeof input.nextSafeAction !== "string" || !input.nextSafeAction.trim()) {
100
+ const error = new Error("nextSafeAction must be a non-empty string");
101
+ error.code = "E_DIAGNOSIS_INVALID";
102
+ throw error;
103
+ }
104
+
105
+ const fingerprint = diagnosisFingerprint({
106
+ failureClass: input.failureClass,
107
+ hypothesis: input.hypothesis,
108
+ evidenceRefs: uniqueRefs,
109
+ });
110
+ const infoGain = classifyDiagnosisInformationGain(
111
+ { hypothesis: input.hypothesis, evidenceRefs: uniqueRefs },
112
+ previous ? { hypothesis: previous.hypothesis, evidenceRefs: previous.evidenceRefs } : null,
113
+ );
114
+
115
+ return {
116
+ verificationCycle: input.verificationCycle,
117
+ failureClass: input.failureClass,
118
+ hypothesis: input.hypothesis.trim(),
119
+ evidenceRefs: uniqueRefs,
120
+ settledBy: input.settledBy.trim(),
121
+ nextSafeAction: input.nextSafeAction.trim(),
122
+ diagnosisFingerprint: fingerprint,
123
+ informationGain: infoGain,
124
+ previousDiagnosisFingerprint: previous?.diagnosisFingerprint ?? null,
125
+ };
126
+ }
127
+
128
+ export function assertDiagnosisDetails(details) {
129
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
130
+ const error = new Error("Diagnosis details must be an object");
131
+ error.code = "E_DIAGNOSIS_INVALID";
132
+ throw error;
133
+ }
134
+ if (typeof details.verificationCycle !== "number" || !Number.isInteger(details.verificationCycle) || details.verificationCycle < 1) {
135
+ const error = new Error("Diagnosis verificationCycle must be an integer >= 1");
136
+ error.code = "E_DIAGNOSIS_INVALID";
137
+ throw error;
138
+ }
139
+ try {
140
+ assertFailureClass(details.failureClass);
141
+ } catch {
142
+ const error = new Error(`Invalid diagnosis failureClass: ${details.failureClass}`);
143
+ error.code = "E_DIAGNOSIS_INVALID";
144
+ throw error;
145
+ }
146
+ if (typeof details.hypothesis !== "string" || !details.hypothesis.trim()) {
147
+ const error = new Error("Diagnosis hypothesis must be a non-empty string");
148
+ error.code = "E_DIAGNOSIS_INVALID";
149
+ throw error;
150
+ }
151
+ if (!Array.isArray(details.evidenceRefs) || details.evidenceRefs.length === 0) {
152
+ const error = new Error("Diagnosis evidenceRefs must be a non-empty array");
153
+ error.code = "E_DIAGNOSIS_INVALID";
154
+ throw error;
155
+ }
156
+ for (const ref of details.evidenceRefs) {
157
+ if (typeof ref !== "string" || !ref.trim()) {
158
+ const error = new Error("Diagnosis evidenceRef must be a non-empty string");
159
+ error.code = "E_DIAGNOSIS_INVALID";
160
+ throw error;
161
+ }
162
+ }
163
+ if (typeof details.settledBy !== "string" || !details.settledBy.trim()) {
164
+ const error = new Error("Diagnosis settledBy must be a non-empty string");
165
+ error.code = "E_DIAGNOSIS_INVALID";
166
+ throw error;
167
+ }
168
+ if (typeof details.nextSafeAction !== "string" || !details.nextSafeAction.trim()) {
169
+ const error = new Error("Diagnosis nextSafeAction must be a non-empty string");
170
+ error.code = "E_DIAGNOSIS_INVALID";
171
+ throw error;
172
+ }
173
+ if (!DIAGNOSIS_INFORMATION_GAIN.includes(details.informationGain)) {
174
+ const error = new Error(`Invalid diagnosis informationGain: ${details.informationGain}`);
175
+ error.code = "E_DIAGNOSIS_INVALID";
176
+ throw error;
177
+ }
178
+ if (typeof details.diagnosisFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(details.diagnosisFingerprint)) {
179
+ const error = new Error("Invalid diagnosisFingerprint");
180
+ error.code = "E_DIAGNOSIS_INVALID";
181
+ throw error;
182
+ }
183
+ const computed = diagnosisFingerprint({
184
+ failureClass: details.failureClass,
185
+ hypothesis: details.hypothesis,
186
+ evidenceRefs: details.evidenceRefs,
187
+ });
188
+ if (computed !== details.diagnosisFingerprint) {
189
+ const error = new Error("Diagnosis fingerprint does not match computed fingerprint");
190
+ error.code = "E_DIAGNOSIS_INVALID";
191
+ throw error;
192
+ }
193
+ if (details.previousDiagnosisFingerprint !== null && (typeof details.previousDiagnosisFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(details.previousDiagnosisFingerprint))) {
194
+ const error = new Error("Invalid previousDiagnosisFingerprint");
195
+ error.code = "E_DIAGNOSIS_INVALID";
196
+ throw error;
197
+ }
198
+ return details;
199
+ }
200
+
201
+ export function diagnosisEventsForTask(events, taskId) {
202
+ if (!Array.isArray(events)) return [];
203
+ return events.filter((e) => e.event === "DIAGNOSIS_RECORDED" && (!taskId || e.taskId === taskId));
204
+ }
205
+
206
+ export function currentCycleDiagnosis(events, taskId, verificationCycle) {
207
+ const taskEvents = diagnosisEventsForTask(events, taskId);
208
+ for (let i = taskEvents.length - 1; i >= 0; i--) {
209
+ if (taskEvents[i].details?.verificationCycle === verificationCycle) {
210
+ return taskEvents[i];
211
+ }
212
+ }
213
+ return null;
214
+ }