@cassiomc1/forgeloop 1.3.0 → 1.6.0

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.
Files changed (163) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/AGENTS.md +1 -0
  3. package/CLAUDE.md +1 -0
  4. package/DOCS_INDEX.md +20 -8
  5. package/EXECUTION_STATE.md +60 -0
  6. package/LOOP_ENGINEERING.md +135 -5
  7. package/LOOP_SYSTEM_DESIGN.md +54 -1
  8. package/PROTOCOL_INTEGRATION.md +87 -0
  9. package/QUALITY_SCORECARD.md +2 -0
  10. package/README.md +69 -9
  11. package/TERMINOLOGY.md +15 -0
  12. package/THIRD_PARTY_NOTICES.md +30 -0
  13. package/THREAT_MODEL.md +59 -1
  14. package/docs/ARTIFACT_REFERENCE.md +183 -0
  15. package/docs/CLI_REFERENCE.md +391 -6
  16. package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
  17. package/docs/DIAGNOSTIC_MODEL.md +181 -0
  18. package/docs/DOCUMENTATION_GUIDE.md +36 -13
  19. package/docs/EXECUTION_TRACE.md +76 -0
  20. package/docs/GETTING_STARTED.md +1 -0
  21. package/docs/MCP.md +159 -0
  22. package/docs/RECIPES.md +149 -0
  23. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  24. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  25. package/docs/TROUBLESHOOTING.md +217 -3
  26. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  27. package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
  28. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
  29. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
  30. package/docs/diagrams/README.md +55 -0
  31. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
  32. package/docs/diagrams/manifest.json +42 -0
  33. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
  34. package/package.json +21 -8
  35. package/schemas/action.schema.json +100 -0
  36. package/schemas/approval.schema.json +51 -0
  37. package/schemas/capability-policy.schema.json +41 -0
  38. package/schemas/diagnostic-case.schema.json +85 -0
  39. package/schemas/execution-receipt.schema.json +16 -0
  40. package/schemas/hypothesis-disposition.schema.json +16 -0
  41. package/schemas/intervention.schema.json +27 -0
  42. package/schemas/policy-lock.schema.json +1 -0
  43. package/schemas/policy-snapshot.schema.json +2 -0
  44. package/schemas/task-recovery.schema.json +61 -0
  45. package/schemas/trajectory-evaluation.schema.json +64 -0
  46. package/schemas/trajectory-scenario.schema.json +42 -0
  47. package/src/cli.js +267 -347
  48. package/src/commands/action-authorize.js +41 -0
  49. package/src/commands/action-propose.js +10 -0
  50. package/src/commands/action-reconcile.js +10 -0
  51. package/src/commands/action-record.js +47 -0
  52. package/src/commands/action-show.js +10 -0
  53. package/src/commands/action-verify.js +10 -0
  54. package/src/commands/advance.js +7 -2
  55. package/src/commands/approval-request.js +64 -0
  56. package/src/commands/approval-resolve.js +10 -0
  57. package/src/commands/audit.js +5 -0
  58. package/src/commands/baseline.js +3 -3
  59. package/src/commands/eval.js +6 -0
  60. package/src/commands/history.js +18 -0
  61. package/src/commands/init.js +2 -2
  62. package/src/commands/inspect.js +55 -0
  63. package/src/commands/metrics.js +7 -0
  64. package/src/commands/next.js +8 -2
  65. package/src/commands/policy-discover.js +2 -2
  66. package/src/commands/progress.js +6 -2
  67. package/src/commands/record-diagnosis.js +37 -1
  68. package/src/commands/record-hypothesis-disposition.js +45 -0
  69. package/src/commands/record-intervention.js +35 -0
  70. package/src/commands/reflect.js +38 -0
  71. package/src/commands/report.js +9 -1
  72. package/src/commands/run-action.js +18 -0
  73. package/src/commands/status.js +17 -0
  74. package/src/commands/task-create.js +39 -1
  75. package/src/commands/task-list.js +14 -1
  76. package/src/commands/task-lock-status.js +2 -2
  77. package/src/commands/task-recover.js +202 -0
  78. package/src/commands/task-repair-legacy-recovery.js +417 -0
  79. package/src/commands/task-resume.js +172 -0
  80. package/src/commands/task-scope.js +23 -4
  81. package/src/commands/task-show.js +18 -4
  82. package/src/commands/trace.js +34 -0
  83. package/src/commands/validate-protocol.js +40 -15
  84. package/src/core/action-authorization.js +106 -0
  85. package/src/core/action-constants.js +86 -0
  86. package/src/core/action-execution.js +105 -0
  87. package/src/core/action-ledger-projection.js +302 -0
  88. package/src/core/action-model.js +581 -0
  89. package/src/core/action-readiness.js +141 -0
  90. package/src/core/action-reconciliation-policy.js +49 -0
  91. package/src/core/action-reconciliation.js +66 -0
  92. package/src/core/action-verification.js +111 -0
  93. package/src/core/actions.js +462 -0
  94. package/src/core/approvals.js +405 -0
  95. package/src/core/artifact-registry.js +60 -0
  96. package/src/core/audit.js +45 -4
  97. package/src/core/bundles.js +30 -0
  98. package/src/core/capability-policy.js +226 -0
  99. package/src/core/cli-command-definitions.js +260 -5
  100. package/src/core/command-executors.js +543 -0
  101. package/src/core/command-input.js +107 -0
  102. package/src/core/command-runtime.js +117 -0
  103. package/src/core/completion-artifacts.js +39 -15
  104. package/src/core/completion-ownership.js +88 -0
  105. package/src/core/completion-recovery-rebind.js +194 -0
  106. package/src/core/completion.js +70 -0
  107. package/src/core/continuity-reconciliation.js +24 -5
  108. package/src/core/diagnostic-model.js +396 -0
  109. package/src/core/diagnostic-projection.js +51 -0
  110. package/src/core/diagnostic-record.js +360 -0
  111. package/src/core/error-codes.js +461 -1
  112. package/src/core/events.js +171 -2
  113. package/src/core/execution-prerequisites.js +4 -1
  114. package/src/core/execution.js +26 -188
  115. package/src/core/failure-signature.js +70 -0
  116. package/src/core/failure-surface.js +57 -0
  117. package/src/core/filesystem.js +55 -6
  118. package/src/core/history.js +110 -0
  119. package/src/core/hypothesis-projection.js +85 -0
  120. package/src/core/information-gain-projection.js +283 -0
  121. package/src/core/information-gain.js +138 -0
  122. package/src/core/inspect.js +132 -7
  123. package/src/core/integration-invocation-policy.js +217 -0
  124. package/src/core/integration-limits.js +20 -0
  125. package/src/core/integration-resources.js +178 -0
  126. package/src/core/next-action-model.js +94 -0
  127. package/src/core/next-action.js +490 -3
  128. package/src/core/phase.js +42 -22
  129. package/src/core/policy-engine.js +113 -6
  130. package/src/core/preflight-consistency.js +31 -5
  131. package/src/core/preflight.js +19 -2
  132. package/src/core/prepared-execution.js +227 -0
  133. package/src/core/progress.js +41 -4
  134. package/src/core/project-root.js +21 -0
  135. package/src/core/protocol-info.js +61 -0
  136. package/src/core/protocol.js +14 -0
  137. package/src/core/receipt.js +1 -0
  138. package/src/core/reconcile-closure.js +35 -10
  139. package/src/core/recovery-history.js +116 -0
  140. package/src/core/reflection.js +305 -0
  141. package/src/core/resumability.js +57 -3
  142. package/src/core/schema-validation.js +9 -0
  143. package/src/core/strategy-analysis.js +97 -0
  144. package/src/core/task-claim-state.js +272 -0
  145. package/src/core/task-command.js +5 -1
  146. package/src/core/task-conflict-inspection.js +321 -0
  147. package/src/core/task-context.js +32 -29
  148. package/src/core/task-discovery.js +14 -1
  149. package/src/core/task-lock.js +216 -22
  150. package/src/core/task-paths.js +31 -2
  151. package/src/core/task-recovery-migration.js +192 -0
  152. package/src/core/task-recovery.js +205 -0
  153. package/src/core/task-scope.js +33 -1
  154. package/src/core/task-snapshot.js +53 -0
  155. package/src/core/templates.js +9 -0
  156. package/src/core/trace.js +548 -0
  157. package/src/core/trajectory-evaluation.js +71 -0
  158. package/src/core/trajectory-metrics.js +80 -0
  159. package/src/core/transaction.js +36 -2
  160. package/src/core/work-state.js +10 -5
  161. package/src/integration.js +47 -0
  162. package/docs/assets/forgeloop-flow.svg +0 -1
  163. package/docs/forgeloop-flow.mmd +0 -51
@@ -10,6 +10,55 @@ import {
10
10
  unlink,
11
11
  } from "node:fs/promises";
12
12
  import path from "node:path";
13
+ import { setTimeout as delay } from "node:timers/promises";
14
+
15
+ const WINDOWS_TRANSIENT_RETRY_DELAYS_MS = Object.freeze([5, 10, 20, 40]);
16
+
17
+ async function fsCallWithTransientWindowsRetry(fsImpl, filePath, {
18
+ platform = process.platform,
19
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
20
+ delayImpl = delay,
21
+ } = {}) {
22
+ let retryIndex = 0;
23
+ while (true) {
24
+ try {
25
+ return await fsImpl(filePath);
26
+ } catch (error) {
27
+ const retryable = platform === "win32"
28
+ && (error?.code === "EPERM" || error?.code === "EACCES")
29
+ && retryIndex < retryDelaysMs.length;
30
+ if (!retryable) throw error;
31
+ await delayImpl(retryDelaysMs[retryIndex]);
32
+ retryIndex += 1;
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function realpathWithTransientWindowsRetry(filePath, {
38
+ platform = process.platform,
39
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
40
+ realpathImpl = realpath,
41
+ delayImpl = delay,
42
+ } = {}) {
43
+ return fsCallWithTransientWindowsRetry(realpathImpl, filePath, {
44
+ platform,
45
+ retryDelaysMs,
46
+ delayImpl,
47
+ });
48
+ }
49
+
50
+ export function lstatWithTransientWindowsRetry(filePath, {
51
+ platform = process.platform,
52
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
53
+ lstatImpl = lstat,
54
+ delayImpl = delay,
55
+ } = {}) {
56
+ return fsCallWithTransientWindowsRetry(lstatImpl, filePath, {
57
+ platform,
58
+ retryDelaysMs,
59
+ delayImpl,
60
+ });
61
+ }
13
62
 
14
63
  export function ensureWithin(root, relativePath) {
15
64
  if (path.isAbsolute(relativePath)) {
@@ -27,7 +76,7 @@ export function ensureWithin(root, relativePath) {
27
76
  export async function assertSafePath(root, relativePath) {
28
77
  const destination = ensureWithin(root, relativePath);
29
78
  const absoluteRoot = path.resolve(root);
30
- const rootInfo = await lstat(absoluteRoot);
79
+ const rootInfo = await lstatWithTransientWindowsRetry(absoluteRoot);
31
80
  if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
32
81
  throw new Error(`Target directory must not be a symlink: ${absoluteRoot}`);
33
82
  }
@@ -37,7 +86,7 @@ export async function assertSafePath(root, relativePath) {
37
86
  for (const segment of segments) {
38
87
  current = path.join(current, segment);
39
88
  try {
40
- const info = await lstat(current);
89
+ const info = await lstatWithTransientWindowsRetry(current);
41
90
  if (info.isSymbolicLink()) {
42
91
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
43
92
  }
@@ -50,12 +99,12 @@ export async function assertSafePath(root, relativePath) {
50
99
  let existing = destination;
51
100
  while (true) {
52
101
  try {
53
- const info = await lstat(existing);
102
+ const info = await lstatWithTransientWindowsRetry(existing);
54
103
  if (info.isSymbolicLink()) {
55
104
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
56
105
  }
57
- const resolvedRoot = await realpath(absoluteRoot);
58
- const resolvedExisting = await realpath(existing);
106
+ const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
107
+ const resolvedExisting = await realpathWithTransientWindowsRetry(existing);
59
108
  const relativeResolved = path.relative(resolvedRoot, resolvedExisting);
60
109
  if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
61
110
  throw new Error(`Path escapes target directory: ${relativePath}`);
@@ -137,4 +186,4 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
137
186
  }
138
187
  throw error;
139
188
  }
140
- }
189
+ }
@@ -0,0 +1,110 @@
1
+ import { buildTaskTrace } from "./trace.js";
2
+
3
+ export const HISTORY_FILTER_OPTIONS = Object.freeze([
4
+ "type",
5
+ "phase",
6
+ "failures",
7
+ "checks",
8
+ "since",
9
+ "until",
10
+ "limit",
11
+ ]);
12
+
13
+ export async function buildTaskHistory({
14
+ target,
15
+ packageRoot,
16
+ taskId = null,
17
+ filters = {},
18
+ } = {}) {
19
+ const trace = await buildTaskTrace({ target, packageRoot, taskId });
20
+ let events = [...trace.events];
21
+
22
+ if (filters.type) {
23
+ const types = String(filters.type).split(",").map((value) => value.trim()).filter(Boolean);
24
+ if (types.length > 0) {
25
+ events = events.filter((event) => types.includes(event.type) || types.includes(event.category));
26
+ }
27
+ }
28
+ if (filters.phase) {
29
+ const phases = String(filters.phase).split(",").map((value) => value.trim()).filter(Boolean);
30
+ if (phases.length > 0) events = events.filter((event) => phases.includes(event.phase));
31
+ }
32
+ if (filters.failures) {
33
+ events = events.filter((event) => event.category === "verification"
34
+ && ["failed", "blocked"].includes(String(event.data?.status ?? "")));
35
+ }
36
+ if (filters.checks) {
37
+ events = events.filter((event) => event.category === "verification");
38
+ }
39
+ if (filters.since) {
40
+ const since = Date.parse(filters.since);
41
+ if (!Number.isNaN(since)) events = events.filter((event) => event.timestamp && Date.parse(event.timestamp) >= since);
42
+ }
43
+ if (filters.until) {
44
+ const until = Date.parse(filters.until);
45
+ if (!Number.isNaN(until)) events = events.filter((event) => event.timestamp && Date.parse(event.timestamp) <= until);
46
+ }
47
+
48
+ let omittedEvents = 0;
49
+ if (Number.isInteger(filters.limit) && filters.limit >= 0 && events.length > filters.limit) {
50
+ omittedEvents = events.length - filters.limit;
51
+ events = events.slice(-filters.limit);
52
+ }
53
+
54
+ const checkAttempts = trace.checks.reduce(
55
+ (total, check) => total + check.attemptCount,
56
+ 0,
57
+ );
58
+ const failedAttempts = trace.checks.reduce(
59
+ (total, check) => total + check.failedAttempts,
60
+ 0,
61
+ );
62
+
63
+ return {
64
+ schemaVersion: 1,
65
+ command: "history",
66
+ task: trace.task,
67
+ snapshot: trace.snapshot,
68
+ summary: {
69
+ eventCount: events.length,
70
+ totalEventCount: trace.events.length,
71
+ checkAttemptCount: checkAttempts,
72
+ failedAttemptCount: failedAttempts,
73
+ diagnosticCaseCount: trace.diagnostics.cases.length,
74
+ interventionCount: trace.diagnostics.interventions.length,
75
+ },
76
+ historyQuality: trace.historyQuality,
77
+ integrity: trace.integrity,
78
+ events,
79
+ ...(omittedEvents > 0 ? { truncated: true, truncation: { reason: "OUTPUT_LIMIT", omittedEvents } } : {}),
80
+ };
81
+ }
82
+
83
+ export function formatHistoryEvent(event) {
84
+ const time = event.timestamp && !Number.isNaN(Date.parse(event.timestamp))
85
+ ? new Date(event.timestamp).toISOString().slice(11, 19)
86
+ : "--:--:--";
87
+ const lines = [`${time} ${event.type}`];
88
+ if (event.summary && event.summary !== event.type) lines.push(` ${event.summary}`);
89
+ if (event.data?.provenance) lines.push(` provenance: ${event.data.provenance}`);
90
+ return lines.join("\n");
91
+ }
92
+
93
+ export function formatHistoryResult(result) {
94
+ const lines = [
95
+ "ForgeLoop Execution History",
96
+ "─".repeat(56),
97
+ "",
98
+ `Task: ${result.task.id ?? "unknown"}`,
99
+ `Phase: ${result.task.phase ?? "UNKNOWN"}`,
100
+ `Integrity: ${result.integrity.valid ? "VALID" : "INCONSISTENT"}`,
101
+ `History quality: ${result.historyQuality.level}`,
102
+ result.historyQuality.reasons.length > 0 ? `Reasons: ${result.historyQuality.reasons.join(", ")}` : null,
103
+ "",
104
+ ...result.events.map(formatHistoryEvent),
105
+ ].filter((line) => line !== null);
106
+ if (result.truncated) {
107
+ lines.push("", `[truncated: ${result.truncation.omittedEvents} earlier events omitted]`);
108
+ }
109
+ return lines.join("\n") + "\n";
110
+ }
@@ -0,0 +1,85 @@
1
+ import { DISPOSITION_TRANSITIONS } from "./diagnostic-model.js";
2
+
3
+ const TERMINAL_STATUSES = Object.freeze(["FALSIFIED", "SUPERSEDED", "UNRESOLVED"]);
4
+ export const HYPOTHESIS_LEGACY_ID = "h-legacy";
5
+
6
+ function canTransition(from, to) {
7
+ if (from === to) return false;
8
+ if (TERMINAL_STATUSES.includes(from)) return false;
9
+ return (DISPOSITION_TRANSITIONS[from] ?? []).includes(to);
10
+ }
11
+
12
+ function blankState(id) {
13
+ return {
14
+ id,
15
+ sourceEventSeq: null,
16
+ sourceCycle: null,
17
+ initialStatus: "OPEN",
18
+ currentStatus: "OPEN",
19
+ dispositionHistory: [],
20
+ };
21
+ }
22
+
23
+ export function projectHypothesisStates(events, { taskId = null } = {}) {
24
+ const ordered = [...events].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
25
+ const byId = new Map();
26
+ const invalidTransitions = [];
27
+
28
+ const ensure = (id, seq, cycle) => {
29
+ if (!byId.has(id)) {
30
+ const state = blankState(id);
31
+ state.sourceEventSeq = seq ?? null;
32
+ state.sourceCycle = cycle ?? null;
33
+ byId.set(id, state);
34
+ }
35
+ return byId.get(id);
36
+ };
37
+
38
+ for (const event of ordered) {
39
+ if (taskId && event.taskId && event.taskId !== taskId) continue;
40
+ const details = event.details ?? {};
41
+ if (event.event === "DIAGNOSTIC_CASE_RECORDED") {
42
+ for (const hypothesis of details.hypotheses ?? []) {
43
+ if (!hypothesis?.id) continue;
44
+ ensure(hypothesis.id, event.seq, details.verificationCycle);
45
+ }
46
+ } else if (event.event === "DIAGNOSIS_RECORDED") {
47
+ ensure(HYPOTHESIS_LEGACY_ID, event.seq, details.verificationCycle);
48
+ } else if (event.event === "HYPOTHESIS_DISPOSITION_RECORDED") {
49
+ const ref = details.hypothesisRef;
50
+ if (!ref || !details.status) continue;
51
+ const state = ensure(ref, null, null);
52
+ if (!canTransition(state.currentStatus, details.status)) {
53
+ invalidTransitions.push({
54
+ sequence: event.seq,
55
+ hypothesisRef: ref,
56
+ from: state.currentStatus,
57
+ to: details.status,
58
+ });
59
+ continue;
60
+ }
61
+ state.currentStatus = details.status;
62
+ state.dispositionHistory.push({
63
+ sequence: event.seq,
64
+ status: details.status,
65
+ evidenceRefs: [...(details.evidenceRefs ?? [])],
66
+ reason: details.reason ?? null,
67
+ });
68
+ }
69
+ }
70
+
71
+ const hypotheses = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
72
+ return {
73
+ hypotheses,
74
+ openHypotheses: hypotheses.filter((hypothesis) => hypothesis.currentStatus === "OPEN").map((hypothesis) => hypothesis.id),
75
+ invalidTransitions,
76
+ };
77
+ }
78
+
79
+ export function getOpenHypotheses(projection) {
80
+ return projection?.openHypotheses ?? [];
81
+ }
82
+
83
+ export function getHypothesisState(projection, id) {
84
+ return projection?.hypotheses.find((hypothesis) => hypothesis.id === id) ?? null;
85
+ }
@@ -0,0 +1,283 @@
1
+ import { diagnosticEventsForTask } from "./diagnostic-projection.js";
2
+ import { normalizeDiagnosticSnapshot, computeInformationGain } from "./information-gain.js";
3
+ import { computeFailureSignature } from "./failure-signature.js";
4
+
5
+ function snapshotFor(event) {
6
+ const details = event.details ?? {};
7
+ if (event.event === "DIAGNOSTIC_CASE_RECORDED") {
8
+ return normalizeDiagnosticSnapshot({ ...details, legacy: false });
9
+ }
10
+ return normalizeDiagnosticSnapshot({ ...details, legacy: true });
11
+ }
12
+
13
+ const sameSortedSet = (a, b) =>
14
+ JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
15
+
16
+ // Cycle interval rule (documented contract):
17
+ // For diagnostic event D[n], the analysis interval is
18
+ // (D[n-1].sequence, D[n].sequence] — previous diagnostic sequence exclusive,
19
+ // current diagnostic sequence inclusive.
20
+ // Events inside an interval belong to the *current* cycle's knowledge state;
21
+ // they are never attributed retroactively to the earlier diagnosis.
22
+ function intervalEvents(taskEvents, fromExclusive, toInclusive) {
23
+ return taskEvents.filter((event) =>
24
+ event.seq > fromExclusive && event.seq <= toInclusive);
25
+ }
26
+
27
+ function failureStateByCycle(taskEvents) {
28
+ const surfaces = new Map();
29
+ const signatures = new Map();
30
+ const record = (cycle, details) => {
31
+ if (!Number.isInteger(cycle)) cycle = Number(cycle) || 1;
32
+ if (!surfaces.has(cycle)) surfaces.set(cycle, new Set());
33
+ if (!signatures.has(cycle)) signatures.set(cycle, new Set());
34
+ const requirement = details.requirement ?? details.id ?? details.checkId;
35
+ if (!requirement) return;
36
+ if (details.status === "failed" || details.status === "blocked") {
37
+ surfaces.get(cycle).add(requirement);
38
+ signatures.get(cycle).add(computeFailureSignature({
39
+ requirement,
40
+ status: details.status,
41
+ exitCode: Number.isInteger(details.exitCode) ? details.exitCode : null,
42
+ failureToken: typeof details.failureToken === "string" ? details.failureToken : (typeof details.details?.failureToken === "string" ? details.details.failureToken : null),
43
+ }));
44
+ }
45
+ };
46
+ for (const event of taskEvents) {
47
+ if (event.event === "VERIFICATION_STARTED") {
48
+ const cycle = event.details?.verificationCycle;
49
+ if (Number.isInteger(cycle)) {
50
+ if (!surfaces.has(cycle)) surfaces.set(cycle, new Set());
51
+ if (!signatures.has(cycle)) signatures.set(cycle, new Set());
52
+ }
53
+ }
54
+ if (event.event === "VERIFICATION_RECORDED") {
55
+ record(event.details?.verificationCycle ?? 1, event.details ?? {});
56
+ }
57
+ }
58
+ return { surfaces, signatures };
59
+ }
60
+
61
+
62
+ function strategyFingerprintFor(diagnosticEvent, interventionsUpTo) {
63
+ const details = diagnosticEvent?.details ?? {};
64
+ const components = {
65
+ hypotheses: (details.hypotheses ?? []).map((hypothesis) => `${hypothesis.statement}`.trim().toLowerCase()),
66
+ contributors: (details.contributors ?? []).map((contributor) => `${contributor.statement}`.trim().toLowerCase()),
67
+ legacyHypothesis: diagnosticEvent?.event === "DIAGNOSIS_RECORDED"
68
+ ? [`${details.hypothesis ?? ""}`.trim().toLowerCase()]
69
+ : [],
70
+ interventions: interventionsUpTo.map((entry) => entry.fingerprint),
71
+ };
72
+ return JSON.stringify([
73
+ [...components.hypotheses, ...components.legacyHypothesis].sort(),
74
+ components.contributors.sort(),
75
+ components.interventions.sort(),
76
+ ]);
77
+ }
78
+
79
+ function snapshotHasContentDelta(previousDetails, currentDetails) {
80
+ const statementsOf = (details) => ({
81
+ observations: new Set((details.observations ?? []).map((o) => `${o.statement}`.trim().toLowerCase())),
82
+ contributors: new Set((details.contributors ?? []).map((c) => `${c.statement}`.trim().toLowerCase())),
83
+ hypotheses: new Set((details.hypotheses ?? []).map((h) => `${h.statement}`.trim().toLowerCase())),
84
+ legacyHypothesis: details.hypothesis ? new Set([`${details.hypothesis}`.trim().toLowerCase()]) : null,
85
+ evidence: new Set([
86
+ ...((details.hypotheses ?? []).flatMap((h) => h.evidenceRefs ?? [])),
87
+ ...((details.observations ?? []).map((o) => o.evidenceRef).filter(Boolean)),
88
+ ...(details.evidenceRefs ?? []),
89
+ ]),
90
+ });
91
+ const prev = statementsOf(previousDetails);
92
+ const cur = statementsOf(currentDetails);
93
+ const differs = (a, b) => {
94
+ if (!a || !b) return false;
95
+ for (const value of b) if (!a.has(value)) return true;
96
+ return false;
97
+ };
98
+ return differs(prev.observations, cur.observations)
99
+ || differs(prev.contributors, cur.contributors)
100
+ || differs(prev.hypotheses, cur.hypotheses)
101
+ || differs(prev.legacyHypothesis, cur.legacyHypothesis)
102
+ || differs(prev.evidence, cur.evidence);
103
+ }
104
+
105
+ export function buildInformationGainProjection(events, taskId) {
106
+ const taskEvents = (events ?? [])
107
+ .filter((event) => !taskId || !event.taskId || event.taskId === taskId)
108
+ .sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
109
+
110
+ const diagnosticEvents = diagnosticEventsForTask(taskEvents, taskId)
111
+ .sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
112
+ if (diagnosticEvents.length === 0) return [];
113
+
114
+ const failure = failureStateByCycle(taskEvents);
115
+
116
+ // Build final per-cycle entries first; effectiveGain is computed once at the
117
+ // end from fully final dimensions (no post-mutation anywhere).
118
+ const built = [];
119
+ let previousDiagnostic = null;
120
+ for (const diagnostic of diagnosticEvents) {
121
+ const intervalStart = previousDiagnostic ? previousDiagnostic.seq : -Infinity;
122
+ const interval = intervalEvents(taskEvents, intervalStart, diagnostic.seq);
123
+
124
+ const hypothesisDispositionChanged =
125
+ interval.some((event) => event.event === "HYPOTHESIS_DISPOSITION_RECORDED");
126
+
127
+ // Intervention deltas recognize only genuinely NEW semantic interventions:
128
+ // recorded after the previous diagnosis, attributed to the current
129
+ // correction cycle, and never attempted before. Repeating an already-known
130
+ // intervention — or merely having executed the previous cycle's corrective
131
+ // action — is not new information.
132
+ const knownUpToPrev = new Set(
133
+ taskEvents
134
+ .filter((event) => event.event === "INTERVENTION_RECORDED"
135
+ && (previousDiagnostic ? (event.seq ?? 0) <= previousDiagnostic.seq : false))
136
+ .map((event) => event.details?.interventionSemanticFingerprint
137
+ ?? `${event.details?.intervention?.statement ?? ""}`.trim().toLowerCase())
138
+ .filter(Boolean),
139
+ );
140
+ const novelInInterval = interval
141
+ .filter((event) => event.event === "INTERVENTION_RECORDED")
142
+ .map((event) => event.details?.interventionSemanticFingerprint
143
+ ?? `${event.details?.intervention?.statement ?? ""}`.trim().toLowerCase())
144
+ .filter((fingerprint) => fingerprint && !knownUpToPrev.has(fingerprint));
145
+ // A genuinely new intervention counts as information only when the
146
+ // diagnosis itself moves: an identical re-proposal of the previous
147
+ // diagnosis after executing its already-known corrective action carries
148
+ // no new semantic state.
149
+ const sameSemanticsAsPrevious = Boolean(previousDiagnostic)
150
+ && !snapshotHasContentDelta(previousDiagnostic.details ?? {}, diagnostic.details ?? {});
151
+ const interventionChanged = Boolean(previousDiagnostic)
152
+ && novelInInterval.length > 0
153
+ && !sameSemanticsAsPrevious;
154
+
155
+ const cycle = diagnostic.details?.verificationCycle ?? 1;
156
+ const previousCycle = previousDiagnostic?.details?.verificationCycle ?? null;
157
+ const surface = [...(failure.surfaces.get(cycle) ?? new Set())].sort();
158
+ const signatures = [...(failure.signatures.get(cycle) ?? new Set())].sort();
159
+ const previousSurface = previousCycle != null
160
+ ? [...(failure.surfaces.get(previousCycle) ?? new Set())].sort()
161
+ : null;
162
+ const previousSignatures = previousCycle != null
163
+ ? [...(failure.signatures.get(previousCycle) ?? new Set())].sort()
164
+ : null;
165
+ const hasPreviousFailureState = previousSurface !== null;
166
+ const failureSurfaceChanged = hasPreviousFailureState
167
+ ? !sameSortedSet(surface, previousSurface)
168
+ : false;
169
+ const failureSignatureChanged = hasPreviousFailureState
170
+ ? !sameSortedSet(signatures, previousSignatures)
171
+ : false;
172
+
173
+ // Strategy compares the PROPOSED diagnostic approach: the case's own
174
+ // semantic content on both sides (identical bases, so the delta is real).
175
+ const strategyFingerprint = strategyFingerprintFor(diagnostic, []);
176
+ const previousStrategyFingerprint = previousDiagnostic
177
+ ? strategyFingerprintFor(previousDiagnostic, [])
178
+ : null;
179
+ const strategyChanged = Boolean(previousStrategyFingerprint
180
+ && strategyFingerprint !== previousStrategyFingerprint);
181
+
182
+ const snapshot = snapshotFor(diagnostic);
183
+
184
+ built.push({
185
+ verificationCycle: cycle,
186
+ sequence: diagnostic.seq ?? null,
187
+ diagnosticSequence: diagnostic.seq ?? null,
188
+ sourceModel: diagnostic.event === "DIAGNOSTIC_CASE_RECORDED"
189
+ ? "STRUCTURED_DIAGNOSTIC_CASE_V1"
190
+ : "LEGACY_DIAGNOSIS_V1",
191
+ snapshot,
192
+ dimensionsInput: {
193
+ hypothesisDispositionChanged,
194
+ failureSignatureChanged,
195
+ failureSurfaceChanged,
196
+ interventionChanged,
197
+ strategyChanged,
198
+ hypothesisEliminated: false,
199
+ },
200
+ evidence: {
201
+ semanticRefs: [...(snapshot.evidenceRefs ?? [])].sort(),
202
+ surface, signatures, strategyFingerprint,
203
+ },
204
+ });
205
+
206
+ previousDiagnostic = diagnostic;
207
+ }
208
+
209
+ // Hypothesis elimination: an id disappearing only counts as elimination when
210
+ // no surviving hypothesis carries the same normalized statement — ID-only
211
+ // churn is artificial novelty and must never create gain.
212
+ for (let i = 1; i < built.length; i++) {
213
+ if (built[i].sourceModel !== "STRUCTURED_DIAGNOSTIC_CASE_V1"
214
+ || built[i - 1].sourceModel !== "STRUCTURED_DIAGNOSTIC_CASE_V1") continue;
215
+ const previousHypotheses = diagnosticEvents[i - 1]?.details?.hypotheses ?? [];
216
+ const currentHypotheses = diagnosticEvents[i]?.details?.hypotheses ?? [];
217
+ const currentStatements = new Set(currentHypotheses.map(
218
+ (hypothesis) => `${hypothesis.statement}`.trim().toLowerCase()));
219
+ built[i].dimensionsInput.hypothesisEliminated = previousHypotheses.some((hypothesis) => {
220
+ const survivedById = currentHypotheses.some((candidate) => candidate.id === hypothesis.id);
221
+ return !survivedById
222
+ && !currentStatements.has(`${hypothesis.statement}`.trim().toLowerCase());
223
+ });
224
+ }
225
+
226
+ // Final classification + single-point effectiveGain computation.
227
+ const entries = computeInformationGain(built.map((entry) => ({
228
+ verificationCycle: entry.verificationCycle,
229
+ sequence: entry.sequence,
230
+ snapshot: entry.snapshot,
231
+ context: entry.dimensionsInput,
232
+ })));
233
+
234
+ return built.map((entry, index) => {
235
+ const { dimensions, classification, effectiveGain } = entries[index];
236
+ return Object.freeze({
237
+ verificationCycle: entry.verificationCycle,
238
+ sequence: entry.sequence,
239
+ diagnosticSequence: entry.diagnosticSequence,
240
+ sourceModel: entry.sourceModel,
241
+ evidence: Object.freeze({
242
+ semanticRefs: Object.freeze(entry.evidence.semanticRefs),
243
+ failureSurface: Object.freeze(entry.evidence.surface),
244
+ failureSignatures: Object.freeze(entry.evidence.signatures),
245
+ strategyFingerprint: entry.evidence.strategyFingerprint,
246
+ }),
247
+ dimensions: Object.freeze({ ...dimensions }),
248
+ classification,
249
+ effectiveGain,
250
+ });
251
+ });
252
+ }
253
+
254
+ // One canonical structured-stall policy (fail-fast):
255
+ // The latest comparable diagnostic state that produces no effective
256
+ // information gain is stalled and may not trigger another blind correction
257
+ // retry. The first diagnosis is never stalled. Legacy diagnosis keeps its
258
+ // own compatibility rule (informationGain === NONE).
259
+ export function evaluateStructuredDiagnosticStall(gainProjection, { verificationCycle = null } = {}) {
260
+ const candidates = verificationCycle == null
261
+ ? (gainProjection ?? [])
262
+ : (gainProjection ?? []).filter((entry) => entry.verificationCycle === verificationCycle);
263
+
264
+ const latest = candidates.at(-1) ?? null;
265
+ if (!latest) {
266
+ return { stalled: false, latestGain: null, reason: null };
267
+ }
268
+ if (latest.classification === "FIRST_DIAGNOSIS") {
269
+ return { stalled: false, latestGain: latest, reason: null };
270
+ }
271
+ const stalled = latest.effectiveGain === false;
272
+ return {
273
+ stalled,
274
+ latestGain: latest,
275
+ reason: stalled ? "NO_DIAGNOSTIC_INFORMATION_GAIN" : null,
276
+ };
277
+ }
278
+
279
+ export function computeCycleInformationGain(events, taskId, verificationCycle) {
280
+ const projection = buildInformationGainProjection(events, taskId);
281
+ const matching = projection.filter((entry) => entry.verificationCycle === verificationCycle);
282
+ return matching.at(-1) ?? null;
283
+ }