@alisio/core 0.7.0 → 0.8.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 (64) hide show
  1. package/dist/application.d.ts +15 -0
  2. package/dist/application.js +38 -3
  3. package/dist/changelog/data.js +23 -0
  4. package/dist/commands/builtins.js +7 -0
  5. package/dist/config.d.ts +30 -0
  6. package/dist/config.js +32 -0
  7. package/dist/core/runner.d.ts +4 -0
  8. package/dist/core/runner.js +57 -1
  9. package/dist/eval/engine.d.ts +1 -0
  10. package/dist/eval/engine.js +3 -0
  11. package/dist/eval/secrets.d.ts +2 -0
  12. package/dist/eval/secrets.js +10 -0
  13. package/dist/goal/command.d.ts +7 -2
  14. package/dist/goal/command.js +10 -2
  15. package/dist/goal/format.js +2 -0
  16. package/dist/goal/machine.d.ts +50 -3
  17. package/dist/goal/machine.js +80 -2
  18. package/dist/goal/service.d.ts +22 -2
  19. package/dist/goal/service.js +94 -7
  20. package/dist/goal/store.js +22 -3
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +2 -1
  23. package/dist/plan/criteria.d.ts +30 -0
  24. package/dist/plan/criteria.js +256 -0
  25. package/dist/plan/exit-plan.d.ts +8 -0
  26. package/dist/plan/exit-plan.js +89 -8
  27. package/dist/plan/manifest.d.ts +4 -2
  28. package/dist/plan/manifest.js +6 -1
  29. package/dist/plan/state.d.ts +13 -2
  30. package/dist/plan/state.js +7 -1
  31. package/dist/plugins/host.d.ts +7 -0
  32. package/dist/plugins/host.js +15 -0
  33. package/dist/proof/command.d.ts +24 -0
  34. package/dist/proof/command.js +109 -0
  35. package/dist/proof/coverage.d.ts +40 -0
  36. package/dist/proof/coverage.js +102 -0
  37. package/dist/proof/divergence.d.ts +24 -0
  38. package/dist/proof/divergence.js +120 -0
  39. package/dist/proof/export.d.ts +65 -0
  40. package/dist/proof/export.js +144 -0
  41. package/dist/proof/index.d.ts +12 -0
  42. package/dist/proof/index.js +12 -0
  43. package/dist/proof/ledger.d.ts +38 -0
  44. package/dist/proof/ledger.js +57 -0
  45. package/dist/proof/plugin-verifiers.d.ts +23 -0
  46. package/dist/proof/plugin-verifiers.js +39 -0
  47. package/dist/proof/recorder.d.ts +69 -0
  48. package/dist/proof/recorder.js +347 -0
  49. package/dist/proof/scope.d.ts +12 -0
  50. package/dist/proof/scope.js +121 -0
  51. package/dist/proof/store.d.ts +52 -0
  52. package/dist/proof/store.js +192 -0
  53. package/dist/proof/tree.d.ts +6 -0
  54. package/dist/proof/tree.js +78 -0
  55. package/dist/proof/types.d.ts +21 -0
  56. package/dist/proof/types.js +1 -0
  57. package/dist/proof/verifiers.d.ts +46 -0
  58. package/dist/proof/verifiers.js +176 -0
  59. package/dist/proof/verify.d.ts +36 -0
  60. package/dist/proof/verify.js +171 -0
  61. package/dist/runtime/store.js +60 -0
  62. package/dist/tools/goal.d.ts +5 -1
  63. package/dist/tools/goal.js +35 -4
  64. package/package.json +2 -2
@@ -0,0 +1,57 @@
1
+ const time = (iso) => Date.parse(iso);
2
+ /** D2: only evidence that names its run, its call and a real date can back a status. */
3
+ const backs = (e) => e.runId.length > 0 && e.callId.length > 0 && Number.isFinite(time(e.capturedAt));
4
+ /**
5
+ * Derives the status of one criterion from its own evidence.
6
+ *
7
+ * - user `not_applicable` wins; no verifier is `not_checked` whatever evidence exists (D1);
8
+ * a `manual` verifier is `manual_review`.
9
+ * - the latest backed evidence decides (later `capturedAt` wins; ties go to the later entry); a
10
+ * latest evidence of kind `manual` means a verifier degraded the criterion to `manual_review`;
11
+ * - an edit at or after that evidence expires it (D5, fail safe on equal instants): an expired
12
+ * success is `not_checked` and stale, an expired failure is just `not_checked`.
13
+ */
14
+ export function deriveStatus(criterion, evidence, options = {}) {
15
+ if (criterion.notApplicableReason)
16
+ return { status: "not_applicable", stale: false };
17
+ if (!criterion.verifier)
18
+ return { status: "not_checked", stale: false };
19
+ if (criterion.verifier.kind === "manual")
20
+ return { status: "manual_review", stale: false };
21
+ let latest;
22
+ for (const e of evidence) {
23
+ if (backs(e) && (!latest || time(e.capturedAt) >= time(latest.capturedAt)))
24
+ latest = e;
25
+ }
26
+ if (!latest)
27
+ return { status: "not_checked", stale: false };
28
+ const edited = options.lastEditAt !== undefined && time(options.lastEditAt) >= time(latest.capturedAt);
29
+ if (edited)
30
+ return { status: "not_checked", stale: latest.ok };
31
+ // A verifier handed the criterion to a person (a plugin result with no traceable evidence).
32
+ if (latest.verifierKind === "manual")
33
+ return { status: "manual_review", stale: false };
34
+ return { status: latest.ok ? "verified" : "failed", stale: false };
35
+ }
36
+ /** Assembles the ledger of one session: every criterion derived from its own evidence. */
37
+ export function buildLedger(sessionId, criteria, evidence, options) {
38
+ const derived = criteria.map((criterion) => {
39
+ const own = evidence.filter((e) => e.criterionId === criterion.criterionId);
40
+ const { status, stale } = deriveStatus(criterion, own, {
41
+ lastEditAt: options.lastEditAt?.(criterion),
42
+ });
43
+ return {
44
+ ...criterion,
45
+ status,
46
+ ...(stale ? { stale } : {}),
47
+ evidenceIds: own.map((e) => e.evidenceId),
48
+ };
49
+ });
50
+ return {
51
+ sessionId,
52
+ criteria: derived,
53
+ evidence: [...evidence],
54
+ divergences: options.divergences ?? [],
55
+ generatedAt: options.now,
56
+ };
57
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Registry of the Proof verifiers plugins contribute (`api.proof.registerVerifier`, specs/011
3
+ * 3.8). Criteria bind to a verifier as `<pluginId>:<id>`, exactly like plugin commands, so two
4
+ * plugins cannot shadow each other. The registry holds code only: it never creates criteria or
5
+ * evidence, and it grants no permission (a verifier's tool calls go through the core gate).
6
+ */
7
+ import type { ProofVerifierRegistration } from "@alisio/sdk";
8
+ export interface RegisteredVerifier {
9
+ plugin: string;
10
+ verifier: ProofVerifierRegistration;
11
+ }
12
+ export declare class ProofVerifierRegistry {
13
+ private readonly verifiers;
14
+ /** Registers `verifier` for `plugin`; returns the function that removes exactly this entry. */
15
+ register(plugin: string, verifier: ProofVerifierRegistration): () => void;
16
+ get(verifierId: string): RegisteredVerifier | undefined;
17
+ /** What is registered, sorted by id (no handlers). */
18
+ list(): Array<{
19
+ verifierId: string;
20
+ plugin: string;
21
+ describe: string;
22
+ }>;
23
+ }
@@ -0,0 +1,39 @@
1
+ const VERIFIER_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
2
+ const DESCRIBE_MAX = 500;
3
+ export class ProofVerifierRegistry {
4
+ verifiers = new Map();
5
+ /** Registers `verifier` for `plugin`; returns the function that removes exactly this entry. */
6
+ register(plugin, verifier) {
7
+ if (typeof verifier?.id !== "string" || !VERIFIER_ID.test(verifier.id))
8
+ throw new Error(`Invalid verifier id: ${String(verifier?.id)}`);
9
+ if (typeof verifier.describe !== "string" || !verifier.describe.trim())
10
+ throw new Error("A verifier needs a description");
11
+ if (typeof verifier.run !== "function")
12
+ throw new Error("A verifier needs a run function");
13
+ const key = `${plugin}:${verifier.id}`;
14
+ if (this.verifiers.has(key))
15
+ throw new Error(`Duplicate verifier ${key}`);
16
+ const entry = {
17
+ plugin,
18
+ verifier: { ...verifier, describe: verifier.describe.trim().slice(0, DESCRIBE_MAX) },
19
+ };
20
+ this.verifiers.set(key, entry);
21
+ return () => {
22
+ if (this.verifiers.get(key) === entry)
23
+ this.verifiers.delete(key);
24
+ };
25
+ }
26
+ get(verifierId) {
27
+ return this.verifiers.get(verifierId);
28
+ }
29
+ /** What is registered, sorted by id (no handlers). */
30
+ list() {
31
+ return [...this.verifiers.entries()]
32
+ .map(([verifierId, { plugin, verifier }]) => ({
33
+ verifierId,
34
+ plugin,
35
+ describe: verifier.describe,
36
+ }))
37
+ .sort((a, b) => (a.verifierId < b.verifierId ? -1 : a.verifierId > b.verifierId ? 1 : 0));
38
+ }
39
+ }
@@ -0,0 +1,69 @@
1
+ import type { RunEventDataMap, SqlDatabase, ToolResult } from "@alisio/sdk";
2
+ import { type Divergence } from "./divergence.ts";
3
+ export type ProofUpdate = RunEventDataMap["proof_updated"];
4
+ /** A tool call that already executed, as the runner reports it. */
5
+ export interface ExecutedCall {
6
+ sessionId: string;
7
+ runId: string;
8
+ callId: string;
9
+ name: string;
10
+ input: Record<string, unknown>;
11
+ /** The result exactly as the tool returned it. */
12
+ result: ToolResult;
13
+ workspace: string;
14
+ durationMs: number;
15
+ }
16
+ /** The last `EXCERPT_BYTES` bytes of the redacted output (the end holds the summary and errors). */
17
+ export declare function excerptOf(output: string): string;
18
+ export declare class ProofRecorder {
19
+ private readonly db;
20
+ private readonly options;
21
+ private readonly store;
22
+ /** `bg_run` calls whose exit code has not been seen yet, by session and task id. */
23
+ private readonly background;
24
+ constructor(db: SqlDatabase, options?: {
25
+ now?: () => Date;
26
+ /** `proof.coverage.enabled`, read on every call so a config reload applies at once. */
27
+ coverage?: () => boolean;
28
+ });
29
+ /** Records the evidence a just-executed call yields; returns the ledger changes to announce. */
30
+ capture(call: ExecutedCall): ProofUpdate[];
31
+ /**
32
+ * A `read_file` that threw because the file is not there (the runner reports it here: a throw
33
+ * never reaches `capture`). It refutes every `file` criterion on that path; any other failure
34
+ * proves nothing.
35
+ */
36
+ captureFailure(call: ExecutedCall): ProofUpdate[];
37
+ /** The criteria of the session the recorder can capture evidence for (not marked N/A). */
38
+ private tracked;
39
+ private finding;
40
+ private fileFindings;
41
+ private dataFindings;
42
+ private commandFindings;
43
+ /** Appends one evidence per finding, once the call is proven to be in the session journal. */
44
+ private persist;
45
+ private clock;
46
+ /**
47
+ * Notes a successful write/edit of a workspace file (D5) and announces the criteria whose
48
+ * derived status or staleness changed because of it. Edits outside the workspace cannot touch
49
+ * a workspace criterion and are ignored.
50
+ */
51
+ private captureEdit;
52
+ /**
53
+ * The claims of a final answer that the ledger does not back (D8: shown, never acted on). Empty
54
+ * without criteria, after one indexed read (D7).
55
+ */
56
+ divergences(sessionId: string, finalText: string): Divergence[];
57
+ /** Reads what a command call proved, or undefined when it proved nothing (yet). */
58
+ private outcome;
59
+ private processOutcome;
60
+ private startedBackground;
61
+ /** `bg_output` carries the exit code of a task this recorder saw start; once per task. */
62
+ private backgroundOutcome;
63
+ private key;
64
+ /**
65
+ * D2/D3: the call must exist in the session's tool-call journal under this run and name, which
66
+ * the runner writes before the tool executes. A forged or foreign `runId`/`callId` fails here.
67
+ */
68
+ private executed;
69
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Proof evidence capture (specs/011 3.3, D3, D6, D7). The runner hands the recorder the RAW
3
+ * result of a tool call right after it executed, before the model-facing bound. Only the
4
+ * recorder writes evidence: nothing the model says or a tool prints can create, edit or mark a
5
+ * criterion, because the only input that counts is the structured result of the four command
6
+ * tools below and the journal of the call that produced it.
7
+ *
8
+ * Without `command`, `file` or `data-check` criteria in the session `capture` returns before doing
9
+ * anything but one indexed read, and only for the command tools, `read_file`, `data_query` and
10
+ * `write_file`/`edit_file` (D7).
11
+ *
12
+ * A successful `write_file`/`edit_file` is noted per path (D5); the ledger derives from those
13
+ * times on every read, so evaluation stays pure. `divergences` checks a final answer's claims.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { isAbsolute, relative, resolve, sep } from "node:path";
17
+ import { redactOutput } from "../eval/secrets.js";
18
+ import { callFingerprint, criterionFingerprints, normalizeCwd, relativeCwd, } from "./command.js";
19
+ import { judgeCoverage } from "./coverage.js";
20
+ import { detectDivergence } from "./divergence.js";
21
+ import { ProofStore } from "./store.js";
22
+ import { treeFingerprint } from "./tree.js";
23
+ import { judgeDataCheck, judgeFile, judgeMissingFile, readsFile, runsCanonicalQuery, } from "./verifiers.js";
24
+ /** Tools whose raw result can back a `command` criterion. */
25
+ const COMMAND_TOOLS = new Set(["run_process", "shell", "bg_run", "bg_output"]);
26
+ /** Tools whose raw result can back a `file` (`read_file`) or `data-check` (`data_query`) criterion. */
27
+ const VERIFIER_TOOLS = new Set(["read_file", "data_query"]);
28
+ /** Criteria bound to these verifier kinds are the ones the recorder captures evidence for. */
29
+ const CAPTURING = new Set(["command", "coverage", "file", "data-check"]);
30
+ /** Tools whose success edits workspace files and so expires evidence (D5). */
31
+ const EDIT_TOOLS = new Set(["write_file", "edit_file"]);
32
+ const EXCERPT_BYTES = 4096;
33
+ /** Background tasks remembered per recorder (oldest dropped first). */
34
+ const MAX_BACKGROUND = 256;
35
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
36
+ function textOf(result) {
37
+ return result.content.map((part) => (part.type === "text" ? part.text : "")).join("");
38
+ }
39
+ function jsonObject(text) {
40
+ try {
41
+ const value = JSON.parse(text);
42
+ return value && typeof value === "object" && !Array.isArray(value)
43
+ ? value
44
+ : undefined;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ /** The last `EXCERPT_BYTES` bytes of the redacted output (the end holds the summary and errors). */
51
+ export function excerptOf(output) {
52
+ const redacted = redactOutput(output);
53
+ const bytes = Buffer.from(redacted);
54
+ if (bytes.length <= EXCERPT_BYTES)
55
+ return redacted;
56
+ let start = bytes.length - EXCERPT_BYTES;
57
+ // Never start inside a multi-byte character: it would decode to a longer replacement char.
58
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80)
59
+ start++;
60
+ return bytes.subarray(start).toString("utf8");
61
+ }
62
+ function passes(verifier, outcome) {
63
+ if (!(verifier.successExitCodes ?? [0]).includes(outcome.exitCode))
64
+ return false;
65
+ if (verifier.outputPattern === undefined)
66
+ return true;
67
+ try {
68
+ return new RegExp(verifier.outputPattern).test(outcome.output);
69
+ }
70
+ catch {
71
+ return false; // an unusable pattern never verifies anything
72
+ }
73
+ }
74
+ export class ProofRecorder {
75
+ db;
76
+ options;
77
+ store;
78
+ /** `bg_run` calls whose exit code has not been seen yet, by session and task id. */
79
+ background = new Map();
80
+ constructor(db, options = {}) {
81
+ this.db = db;
82
+ this.options = options;
83
+ this.store = new ProofStore(db);
84
+ }
85
+ /** Records the evidence a just-executed call yields; returns the ledger changes to announce. */
86
+ capture(call) {
87
+ const edit = EDIT_TOOLS.has(call.name);
88
+ const command = COMMAND_TOOLS.has(call.name);
89
+ if (!edit && !command && !VERIFIER_TOOLS.has(call.name))
90
+ return [];
91
+ const criteria = this.tracked(call.sessionId);
92
+ if (criteria.length === 0)
93
+ return [];
94
+ if (edit)
95
+ return this.captureEdit(call);
96
+ const findings = command
97
+ ? this.commandFindings(call, criteria)
98
+ : call.name === "read_file"
99
+ ? this.fileFindings(call, criteria)
100
+ : this.dataFindings(call, criteria);
101
+ return this.persist(call, findings);
102
+ }
103
+ /**
104
+ * A `read_file` that threw because the file is not there (the runner reports it here: a throw
105
+ * never reaches `capture`). It refutes every `file` criterion on that path; any other failure
106
+ * proves nothing.
107
+ */
108
+ captureFailure(call) {
109
+ if (call.name !== "read_file" || !call.result.isError)
110
+ return [];
111
+ const judged = judgeMissingFile(textOf(call.result));
112
+ if (!judged)
113
+ return [];
114
+ const matching = this.tracked(call.sessionId).filter((c) => c.verifier?.kind === "file" && readsFile(c.verifier, call.input.path, call.workspace));
115
+ return this.persist(call, matching.map((criterion) => this.finding(call, criterion, "file", judged, textOf(call.result))));
116
+ }
117
+ /** The criteria of the session the recorder can capture evidence for (not marked N/A). */
118
+ tracked(sessionId) {
119
+ return this.store.criteria(sessionId).filter((c) => c.verifier &&
120
+ CAPTURING.has(c.verifier.kind) &&
121
+ !c.notApplicableReason &&
122
+ // Opt-in: with `proof.coverage.enabled` off a coverage criterion is never captured.
123
+ (c.verifier.kind !== "coverage" || this.options.coverage?.() === true));
124
+ }
125
+ finding(call, criterion, verifierKind, judged, rawOutput) {
126
+ return {
127
+ criterion,
128
+ verifierKind,
129
+ ok: judged.ok,
130
+ outputHash: sha256(rawOutput),
131
+ excerpt: excerptOf(judged.summary),
132
+ durationMs: call.durationMs,
133
+ };
134
+ }
135
+ fileFindings(call, criteria) {
136
+ if (call.result.isError)
137
+ return [];
138
+ const text = textOf(call.result);
139
+ const body = jsonObject(text);
140
+ if (!body)
141
+ return [];
142
+ return criteria.flatMap((c) => {
143
+ if (c.verifier?.kind !== "file" || !readsFile(c.verifier, call.input.path, call.workspace))
144
+ return [];
145
+ const judged = judgeFile(c.verifier, body);
146
+ return judged ? [this.finding(call, c, "file", judged, text)] : [];
147
+ });
148
+ }
149
+ dataFindings(call, criteria) {
150
+ if (call.result.isError)
151
+ return [];
152
+ const text = textOf(call.result);
153
+ return criteria.flatMap((c) => {
154
+ if (c.verifier?.kind !== "data-check" || !runsCanonicalQuery(c.verifier, call.input))
155
+ return [];
156
+ const judged = judgeDataCheck(c.verifier, text);
157
+ return judged ? [this.finding(call, c, "data-check", judged, text)] : [];
158
+ });
159
+ }
160
+ commandFindings(call, criteria) {
161
+ const commands = criteria.filter((c) => c.verifier?.kind === "command");
162
+ const coverages = criteria.filter((c) => c.verifier?.kind === "coverage");
163
+ if (commands.length === 0 && coverages.length === 0)
164
+ return [];
165
+ const outcome = this.outcome(call);
166
+ if (!outcome)
167
+ return [];
168
+ const coverage = coverages
169
+ .filter((c) => criterionFingerprints(c.verifier.command, ".").includes(outcome.fingerprint))
170
+ .map((c) => {
171
+ const judged = judgeCoverage(c.verifier, outcome);
172
+ return {
173
+ criterion: c,
174
+ verifierKind: "coverage",
175
+ ok: judged.ok,
176
+ outputHash: sha256(outcome.output),
177
+ // The verdict goes last: the excerpt keeps the END of the text (see `excerptOf`).
178
+ excerpt: excerptOf(`${outcome.output ? `${outcome.output}\n` : ""}${judged.summary}`),
179
+ exitCode: outcome.exitCode,
180
+ fingerprint: outcome.fingerprint,
181
+ durationMs: outcome.durationMs,
182
+ };
183
+ });
184
+ return [
185
+ ...coverage,
186
+ ...commands
187
+ .filter((c) => criterionFingerprints(c.verifier.command, normalizeCwd(c.verifier.cwd ?? ".")).includes(outcome.fingerprint))
188
+ .map((c) => ({
189
+ criterion: c,
190
+ verifierKind: "command",
191
+ ok: passes(c.verifier, outcome),
192
+ outputHash: sha256(outcome.output),
193
+ ...(outcome.output ? { excerpt: excerptOf(outcome.output) } : {}),
194
+ exitCode: outcome.exitCode,
195
+ fingerprint: outcome.fingerprint,
196
+ durationMs: outcome.durationMs,
197
+ })),
198
+ ];
199
+ }
200
+ /** Appends one evidence per finding, once the call is proven to be in the session journal. */
201
+ persist(call, findings) {
202
+ if (findings.length === 0 || !this.executed(call))
203
+ return [];
204
+ const capturedAt = this.clock();
205
+ const filesTouched = this.store.edits(call.sessionId).map((e) => e.path);
206
+ const tree = treeFingerprint(call.workspace, filesTouched);
207
+ const appended = findings.map((f) => this.store.appendEvidence({
208
+ criterionId: f.criterion.criterionId,
209
+ verifierKind: f.verifierKind,
210
+ runId: call.runId,
211
+ callId: call.callId,
212
+ ok: f.ok,
213
+ ...(f.fingerprint ? { commandFingerprint: f.fingerprint } : {}),
214
+ ...(f.exitCode !== undefined ? { exitCode: f.exitCode } : {}),
215
+ durationMs: f.durationMs,
216
+ outputHash: f.outputHash,
217
+ ...(f.excerpt ? { excerpt: f.excerpt } : {}),
218
+ treeFingerprint: tree,
219
+ filesTouched,
220
+ capturedAt,
221
+ }));
222
+ const ledger = this.store.ledger(call.sessionId, capturedAt);
223
+ return appended.flatMap((evidence) => {
224
+ const criterion = ledger.criteria.find((c) => c.criterionId === evidence.criterionId);
225
+ return criterion
226
+ ? [
227
+ {
228
+ criterionId: criterion.criterionId,
229
+ status: criterion.status,
230
+ stale: criterion.stale ?? false,
231
+ evidenceId: evidence.evidenceId,
232
+ },
233
+ ]
234
+ : [];
235
+ });
236
+ }
237
+ clock() {
238
+ return (this.options.now?.() ?? new Date()).toISOString();
239
+ }
240
+ /**
241
+ * Notes a successful write/edit of a workspace file (D5) and announces the criteria whose
242
+ * derived status or staleness changed because of it. Edits outside the workspace cannot touch
243
+ * a workspace criterion and are ignored.
244
+ */
245
+ captureEdit(call) {
246
+ if (call.result.isError || typeof call.input.path !== "string" || !this.executed(call))
247
+ return [];
248
+ const path = relative(call.workspace, resolve(call.workspace, call.input.path));
249
+ if (!path || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
250
+ return [];
251
+ const at = this.clock();
252
+ const before = this.store.ledger(call.sessionId, at).criteria;
253
+ this.store.recordEdit(call.sessionId, path.split(sep).join("/"), at);
254
+ const after = this.store.ledger(call.sessionId, at).criteria;
255
+ return after.flatMap((criterion) => {
256
+ const previous = before.find((c) => c.criterionId === criterion.criterionId);
257
+ const stale = criterion.stale ?? false;
258
+ if (previous && previous.status === criterion.status && (previous.stale ?? false) === stale)
259
+ return [];
260
+ return [{ criterionId: criterion.criterionId, status: criterion.status, stale }];
261
+ });
262
+ }
263
+ /**
264
+ * The claims of a final answer that the ledger does not back (D8: shown, never acted on). Empty
265
+ * without criteria, after one indexed read (D7).
266
+ */
267
+ divergences(sessionId, finalText) {
268
+ if (this.store.criteria(sessionId).length === 0)
269
+ return [];
270
+ return detectDivergence(finalText, this.store.ledger(sessionId, this.clock()));
271
+ }
272
+ /** Reads what a command call proved, or undefined when it proved nothing (yet). */
273
+ outcome(call) {
274
+ const body = jsonObject(textOf(call.result));
275
+ if (!body)
276
+ return undefined;
277
+ if (call.name === "run_process" || call.name === "shell")
278
+ return this.processOutcome(call, body);
279
+ if (call.name === "bg_run") {
280
+ this.startedBackground(call, body);
281
+ return undefined;
282
+ }
283
+ return this.backgroundOutcome(call, body);
284
+ }
285
+ processOutcome(call, body) {
286
+ if (typeof body.exitCode !== "number" || !Number.isFinite(body.exitCode))
287
+ return undefined;
288
+ const command = call.input.command;
289
+ if (typeof command !== "string")
290
+ return undefined;
291
+ const args = Array.isArray(call.input.args) ? call.input.args.map(String) : [];
292
+ const stdout = typeof body.stdout === "string" ? body.stdout : "";
293
+ const stderr = typeof body.stderr === "string" ? body.stderr : "";
294
+ return {
295
+ exitCode: body.exitCode,
296
+ output: [stdout, stderr].filter(Boolean).join("\n"),
297
+ durationMs: call.durationMs,
298
+ fingerprint: callFingerprint(call.name, { command, args }, "."),
299
+ };
300
+ }
301
+ startedBackground(call, body) {
302
+ const command = call.input.command;
303
+ if (typeof body.id !== "string" || typeof command !== "string")
304
+ return;
305
+ const cwd = typeof call.input.cwd === "string" ? call.input.cwd : undefined;
306
+ this.background.set(this.key(call.sessionId, body.id), {
307
+ fingerprint: callFingerprint("shell", { command }, relativeCwd(call.workspace, cwd)),
308
+ startedAt: Date.now() - call.durationMs,
309
+ recorded: false,
310
+ });
311
+ while (this.background.size > MAX_BACKGROUND) {
312
+ const oldest = this.background.keys().next().value;
313
+ if (oldest === undefined)
314
+ break;
315
+ this.background.delete(oldest);
316
+ }
317
+ }
318
+ /** `bg_output` carries the exit code of a task this recorder saw start; once per task. */
319
+ backgroundOutcome(call, body) {
320
+ const id = typeof body.id === "string" ? body.id : undefined;
321
+ const task = id ? this.background.get(this.key(call.sessionId, id)) : undefined;
322
+ if (!task || task.recorded)
323
+ return undefined;
324
+ if (typeof body.exit_code !== "number" || !Number.isFinite(body.exit_code))
325
+ return undefined;
326
+ task.recorded = true;
327
+ return {
328
+ exitCode: body.exit_code,
329
+ output: typeof body.text === "string" ? body.text : "",
330
+ durationMs: Math.max(0, Date.now() - task.startedAt),
331
+ fingerprint: task.fingerprint,
332
+ };
333
+ }
334
+ key(sessionId, taskId) {
335
+ return `${sessionId}\u0000${taskId}`;
336
+ }
337
+ /**
338
+ * D2/D3: the call must exist in the session's tool-call journal under this run and name, which
339
+ * the runner writes before the tool executes. A forged or foreign `runId`/`callId` fails here.
340
+ */
341
+ executed(call) {
342
+ const row = this.db
343
+ .prepare("SELECT 1 AS ok FROM tool_calls WHERE session=? AND call_id=? AND run_id=? AND name=?")
344
+ .get(call.sessionId, call.callId, call.runId, call.name);
345
+ return row !== undefined && row !== null;
346
+ }
347
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Scope globs against edited paths (specs/011 3.4, D5). Pure: no filesystem, no clock.
3
+ *
4
+ * Paths are workspace-relative with `/` separators. A scope entry covers a path when its glob
5
+ * matches the path or any ancestor directory of it, so `src` and `src/**` both cover
6
+ * `src/a/b.ts`. No scope (absent or empty) means the whole workspace. Supported glob syntax:
7
+ * `*`, `?`, `**`, `[abc]`/`[a-z]`/`[!x]` and `{a,b}`; anything else is literal.
8
+ */
9
+ /** Forward slashes, no leading `./`, no trailing `/`. */
10
+ export declare function normalizeScopePath(path: string): string;
11
+ /** Whether any scope entry covers `path`. Absent or empty scope: the whole workspace. */
12
+ export declare function scopeMatches(scope: readonly string[] | undefined, path: string): boolean;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Scope globs against edited paths (specs/011 3.4, D5). Pure: no filesystem, no clock.
3
+ *
4
+ * Paths are workspace-relative with `/` separators. A scope entry covers a path when its glob
5
+ * matches the path or any ancestor directory of it, so `src` and `src/**` both cover
6
+ * `src/a/b.ts`. No scope (absent or empty) means the whole workspace. Supported glob syntax:
7
+ * `*`, `?`, `**`, `[abc]`/`[a-z]`/`[!x]` and `{a,b}`; anything else is literal.
8
+ */
9
+ const MAX_PATTERN = 256;
10
+ const MAX_EXPANSIONS = 32;
11
+ /** Forward slashes, no leading `./`, no trailing `/`. */
12
+ export function normalizeScopePath(path) {
13
+ let out = path.replace(/\\/g, "/");
14
+ while (out.startsWith("./"))
15
+ out = out.slice(2);
16
+ out = out.replace(/\/{2,}/g, "/");
17
+ while (out.length > 1 && out.endsWith("/"))
18
+ out = out.slice(0, -1);
19
+ return out;
20
+ }
21
+ /** Expands `{a,b}` groups (innermost first) into plain patterns, bounded. */
22
+ function expandBraces(pattern) {
23
+ const open = pattern.search(/\{[^{}]*\}/);
24
+ if (open < 0)
25
+ return [pattern];
26
+ const close = pattern.indexOf("}", open);
27
+ const head = pattern.slice(0, open);
28
+ const tail = pattern.slice(close + 1);
29
+ const out = [];
30
+ for (const option of pattern.slice(open + 1, close).split(",")) {
31
+ for (const rest of expandBraces(head + option + tail)) {
32
+ out.push(rest);
33
+ if (out.length >= MAX_EXPANSIONS)
34
+ return out;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+ const escapeChar = (c) => c.replace(/[\\^$.*+?()[\]{}|/-]/g, "\\$&");
40
+ /** Compiles one brace-free glob to an anchored regular expression. */
41
+ function compile(glob) {
42
+ let g = glob.replace(/(?:\*\*\/)+/g, "**/").replace(/\*{3,}/g, "**");
43
+ // A trailing `/**` also matches the directory itself.
44
+ const tail = g.endsWith("/**") ? "(?:/.*)?" : "";
45
+ if (tail)
46
+ g = g.slice(0, -3);
47
+ let re = "";
48
+ for (let i = 0; i < g.length; i++) {
49
+ const c = g.charAt(i);
50
+ if (c === "*") {
51
+ if (g[i + 1] === "*") {
52
+ const slash = g[i + 2] === "/";
53
+ // `**/` also matches nothing.
54
+ if (slash) {
55
+ re += "(?:.*/)?";
56
+ i += 2;
57
+ }
58
+ else {
59
+ re += ".*";
60
+ i += 1;
61
+ }
62
+ }
63
+ else
64
+ re += "[^/]*";
65
+ }
66
+ else if (c === "?")
67
+ re += "[^/]";
68
+ else if (c === "[") {
69
+ const close = g.indexOf("]", i + 2);
70
+ if (close < 0)
71
+ re += "\\[";
72
+ else {
73
+ const body = g.slice(i + 1, close);
74
+ const negate = body.startsWith("!") || body.startsWith("^");
75
+ const set = (negate ? body.slice(1) : body).replace(/[\\\]^]/g, "\\$&");
76
+ re += `[${negate ? "^/" : ""}${set}]`;
77
+ i = close;
78
+ }
79
+ }
80
+ else
81
+ re += escapeChar(c);
82
+ }
83
+ return new RegExp(`^${re}${tail}$`);
84
+ }
85
+ const cache = new Map();
86
+ function compiled(entry) {
87
+ const known = cache.get(entry);
88
+ if (known !== undefined)
89
+ return known;
90
+ let value;
91
+ try {
92
+ value = expandBraces(normalizeScopePath(entry)).map(compile);
93
+ }
94
+ catch {
95
+ value = null; // unusable entry: callers fail safe and treat it as matching everything
96
+ }
97
+ if (cache.size >= 512)
98
+ cache.clear();
99
+ cache.set(entry, value);
100
+ return value;
101
+ }
102
+ /** Whether any scope entry covers `path`. Absent or empty scope: the whole workspace. */
103
+ export function scopeMatches(scope, path) {
104
+ if (!scope || scope.length === 0)
105
+ return true;
106
+ const target = normalizeScopePath(path);
107
+ const segments = target.split("/");
108
+ const prefixes = segments.map((_, i) => segments.slice(0, i + 1).join("/"));
109
+ for (const entry of scope) {
110
+ if (entry.length > MAX_PATTERN)
111
+ return true;
112
+ const patterns = compiled(entry);
113
+ if (patterns === null)
114
+ return true;
115
+ for (const pattern of patterns) {
116
+ if (prefixes.some((p) => pattern.test(p)))
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ }