@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.
- package/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +1 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +20 -8
- package/EXECUTION_STATE.md +60 -0
- package/LOOP_ENGINEERING.md +135 -5
- package/LOOP_SYSTEM_DESIGN.md +54 -1
- package/PROTOCOL_INTEGRATION.md +87 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +69 -9
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/THREAT_MODEL.md +59 -1
- package/docs/ARTIFACT_REFERENCE.md +183 -0
- package/docs/CLI_REFERENCE.md +391 -6
- package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
- package/docs/DIAGNOSTIC_MODEL.md +181 -0
- package/docs/DOCUMENTATION_GUIDE.md +36 -13
- package/docs/EXECUTION_TRACE.md +76 -0
- package/docs/GETTING_STARTED.md +1 -0
- package/docs/MCP.md +159 -0
- package/docs/RECIPES.md +149 -0
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +217 -3
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
- package/docs/diagrams/README.md +55 -0
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
- package/docs/diagrams/manifest.json +42 -0
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
- package/package.json +21 -8
- package/schemas/action.schema.json +100 -0
- package/schemas/approval.schema.json +51 -0
- package/schemas/capability-policy.schema.json +41 -0
- package/schemas/diagnostic-case.schema.json +85 -0
- package/schemas/execution-receipt.schema.json +16 -0
- package/schemas/hypothesis-disposition.schema.json +16 -0
- package/schemas/intervention.schema.json +27 -0
- package/schemas/policy-lock.schema.json +1 -0
- package/schemas/policy-snapshot.schema.json +2 -0
- package/schemas/task-recovery.schema.json +61 -0
- package/schemas/trajectory-evaluation.schema.json +64 -0
- package/schemas/trajectory-scenario.schema.json +42 -0
- package/src/cli.js +267 -347
- package/src/commands/action-authorize.js +41 -0
- package/src/commands/action-propose.js +10 -0
- package/src/commands/action-reconcile.js +10 -0
- package/src/commands/action-record.js +47 -0
- package/src/commands/action-show.js +10 -0
- package/src/commands/action-verify.js +10 -0
- package/src/commands/advance.js +7 -2
- package/src/commands/approval-request.js +64 -0
- package/src/commands/approval-resolve.js +10 -0
- package/src/commands/audit.js +5 -0
- package/src/commands/baseline.js +3 -3
- package/src/commands/eval.js +6 -0
- package/src/commands/history.js +18 -0
- package/src/commands/init.js +2 -2
- package/src/commands/inspect.js +55 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/next.js +8 -2
- package/src/commands/policy-discover.js +2 -2
- package/src/commands/progress.js +6 -2
- package/src/commands/record-diagnosis.js +37 -1
- package/src/commands/record-hypothesis-disposition.js +45 -0
- package/src/commands/record-intervention.js +35 -0
- package/src/commands/reflect.js +38 -0
- package/src/commands/report.js +9 -1
- package/src/commands/run-action.js +18 -0
- package/src/commands/status.js +17 -0
- package/src/commands/task-create.js +39 -1
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +2 -2
- package/src/commands/task-recover.js +202 -0
- package/src/commands/task-repair-legacy-recovery.js +417 -0
- package/src/commands/task-resume.js +172 -0
- package/src/commands/task-scope.js +23 -4
- package/src/commands/task-show.js +18 -4
- package/src/commands/trace.js +34 -0
- package/src/commands/validate-protocol.js +40 -15
- package/src/core/action-authorization.js +106 -0
- package/src/core/action-constants.js +86 -0
- package/src/core/action-execution.js +105 -0
- package/src/core/action-ledger-projection.js +302 -0
- package/src/core/action-model.js +581 -0
- package/src/core/action-readiness.js +141 -0
- package/src/core/action-reconciliation-policy.js +49 -0
- package/src/core/action-reconciliation.js +66 -0
- package/src/core/action-verification.js +111 -0
- package/src/core/actions.js +462 -0
- package/src/core/approvals.js +405 -0
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +45 -4
- package/src/core/bundles.js +30 -0
- package/src/core/capability-policy.js +226 -0
- package/src/core/cli-command-definitions.js +260 -5
- package/src/core/command-executors.js +543 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +117 -0
- package/src/core/completion-artifacts.js +39 -15
- package/src/core/completion-ownership.js +88 -0
- package/src/core/completion-recovery-rebind.js +194 -0
- package/src/core/completion.js +70 -0
- package/src/core/continuity-reconciliation.js +24 -5
- package/src/core/diagnostic-model.js +396 -0
- package/src/core/diagnostic-projection.js +51 -0
- package/src/core/diagnostic-record.js +360 -0
- package/src/core/error-codes.js +461 -1
- package/src/core/events.js +171 -2
- package/src/core/execution-prerequisites.js +4 -1
- package/src/core/execution.js +26 -188
- package/src/core/failure-signature.js +70 -0
- package/src/core/failure-surface.js +57 -0
- package/src/core/filesystem.js +55 -6
- package/src/core/history.js +110 -0
- package/src/core/hypothesis-projection.js +85 -0
- package/src/core/information-gain-projection.js +283 -0
- package/src/core/information-gain.js +138 -0
- package/src/core/inspect.js +132 -7
- package/src/core/integration-invocation-policy.js +217 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +178 -0
- package/src/core/next-action-model.js +94 -0
- package/src/core/next-action.js +490 -3
- package/src/core/phase.js +42 -22
- package/src/core/policy-engine.js +113 -6
- package/src/core/preflight-consistency.js +31 -5
- package/src/core/preflight.js +19 -2
- package/src/core/prepared-execution.js +227 -0
- package/src/core/progress.js +41 -4
- package/src/core/project-root.js +21 -0
- package/src/core/protocol-info.js +61 -0
- package/src/core/protocol.js +14 -0
- package/src/core/receipt.js +1 -0
- package/src/core/reconcile-closure.js +35 -10
- package/src/core/recovery-history.js +116 -0
- package/src/core/reflection.js +305 -0
- package/src/core/resumability.js +57 -3
- package/src/core/schema-validation.js +9 -0
- package/src/core/strategy-analysis.js +97 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +5 -1
- package/src/core/task-conflict-inspection.js +321 -0
- package/src/core/task-context.js +32 -29
- package/src/core/task-discovery.js +14 -1
- package/src/core/task-lock.js +216 -22
- package/src/core/task-paths.js +31 -2
- package/src/core/task-recovery-migration.js +192 -0
- package/src/core/task-recovery.js +205 -0
- package/src/core/task-scope.js +33 -1
- package/src/core/task-snapshot.js +53 -0
- package/src/core/templates.js +9 -0
- package/src/core/trace.js +548 -0
- package/src/core/trajectory-evaluation.js +71 -0
- package/src/core/trajectory-metrics.js +80 -0
- package/src/core/transaction.js +36 -2
- package/src/core/work-state.js +10 -5
- package/src/integration.js +47 -0
- package/docs/assets/forgeloop-flow.svg +0 -1
- package/docs/forgeloop-flow.mmd +0 -51
package/src/core/filesystem.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
58
|
-
const resolvedExisting = await
|
|
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
|
+
}
|