@alisio/core 0.6.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.
- package/dist/application.d.ts +15 -0
- package/dist/application.js +38 -3
- package/dist/changelog/data.js +39 -0
- package/dist/commands/builtins.js +7 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +32 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.js +57 -1
- package/dist/eval/canonical.d.ts +14 -0
- package/dist/eval/canonical.js +40 -0
- package/dist/eval/cassette.d.ts +180 -0
- package/dist/eval/cassette.js +424 -0
- package/dist/eval/compare.d.ts +147 -0
- package/dist/eval/compare.js +436 -0
- package/dist/eval/engine.d.ts +145 -0
- package/dist/eval/engine.js +731 -0
- package/dist/eval/export.d.ts +40 -0
- package/dist/eval/export.js +90 -0
- package/dist/eval/fingerprint.d.ts +88 -0
- package/dist/eval/fingerprint.js +186 -0
- package/dist/eval/normalize.d.ts +68 -0
- package/dist/eval/normalize.js +138 -0
- package/dist/eval/paths.d.ts +11 -0
- package/dist/eval/paths.js +50 -0
- package/dist/eval/plan.d.ts +26 -0
- package/dist/eval/plan.js +44 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +258 -0
- package/dist/eval/run-suite.d.ts +68 -0
- package/dist/eval/run-suite.js +94 -0
- package/dist/eval/secrets.d.ts +14 -0
- package/dist/eval/secrets.js +55 -0
- package/dist/eval/stats.d.ts +17 -0
- package/dist/eval/stats.js +50 -0
- package/dist/eval/suite.d.ts +20 -0
- package/dist/eval/suite.js +165 -0
- package/dist/eval/variants.d.ts +35 -0
- package/dist/eval/variants.js +137 -0
- package/dist/eval/verifiers.d.ts +49 -0
- package/dist/eval/verifiers.js +264 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +9 -0
- package/dist/forge/javascript/hosts/worker-host.js +16 -1
- package/dist/forge/kernel.js +3 -2
- package/dist/goal/command.d.ts +7 -2
- package/dist/goal/command.js +10 -2
- package/dist/goal/format.js +2 -0
- package/dist/goal/machine.d.ts +50 -3
- package/dist/goal/machine.js +80 -2
- package/dist/goal/service.d.ts +22 -2
- package/dist/goal/service.js +94 -7
- package/dist/goal/store.js +22 -3
- package/dist/index.d.ts +18 -2
- package/dist/index.js +18 -2
- package/dist/plan/criteria.d.ts +30 -0
- package/dist/plan/criteria.js +256 -0
- package/dist/plan/exit-plan.d.ts +8 -0
- package/dist/plan/exit-plan.js +91 -9
- package/dist/plan/manifest.d.ts +4 -2
- package/dist/plan/manifest.js +6 -1
- package/dist/plan/state.d.ts +13 -2
- package/dist/plan/state.js +7 -1
- package/dist/plugins/host.d.ts +7 -0
- package/dist/plugins/host.js +15 -0
- package/dist/proof/command.d.ts +24 -0
- package/dist/proof/command.js +109 -0
- package/dist/proof/coverage.d.ts +40 -0
- package/dist/proof/coverage.js +102 -0
- package/dist/proof/divergence.d.ts +24 -0
- package/dist/proof/divergence.js +120 -0
- package/dist/proof/export.d.ts +65 -0
- package/dist/proof/export.js +144 -0
- package/dist/proof/index.d.ts +12 -0
- package/dist/proof/index.js +12 -0
- package/dist/proof/ledger.d.ts +38 -0
- package/dist/proof/ledger.js +57 -0
- package/dist/proof/plugin-verifiers.d.ts +23 -0
- package/dist/proof/plugin-verifiers.js +39 -0
- package/dist/proof/recorder.d.ts +69 -0
- package/dist/proof/recorder.js +347 -0
- package/dist/proof/scope.d.ts +12 -0
- package/dist/proof/scope.js +121 -0
- package/dist/proof/store.d.ts +52 -0
- package/dist/proof/store.js +192 -0
- package/dist/proof/tree.d.ts +6 -0
- package/dist/proof/tree.js +78 -0
- package/dist/proof/types.d.ts +21 -0
- package/dist/proof/types.js +1 -0
- package/dist/proof/verifiers.d.ts +46 -0
- package/dist/proof/verifiers.js +176 -0
- package/dist/proof/verify.d.ts +36 -0
- package/dist/proof/verify.js +171 -0
- package/dist/runtime/ids.d.ts +21 -0
- package/dist/runtime/ids.js +41 -1
- package/dist/runtime/store.js +63 -2
- package/dist/tools/goal.d.ts +5 -1
- package/dist/tools/goal.js +35 -4
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence of the Proof ledger (tables `proof_criteria` and `proof_evidence`, schema v11).
|
|
3
|
+
*
|
|
4
|
+
* Evidence is append-only: there is no update path here and a database trigger rejects UPDATE;
|
|
5
|
+
* rows disappear only with their session (`ON DELETE CASCADE`). The status is never stored (D4):
|
|
6
|
+
* `ledger()` derives it on every read.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProofEvidence, ProofLedger, SqlDatabase } from "@alisio/sdk";
|
|
9
|
+
import type { CriterionRecord, NewCriterion, NewEvidence } from "./types.ts";
|
|
10
|
+
export declare class ProofStore {
|
|
11
|
+
private readonly db;
|
|
12
|
+
constructor(db: SqlDatabase);
|
|
13
|
+
addCriterion(input: NewCriterion): CriterionRecord;
|
|
14
|
+
criterion(criterionId: string): CriterionRecord | undefined;
|
|
15
|
+
/** The criteria of a session in creation order, without any derived field. */
|
|
16
|
+
criteria(sessionId: string): CriterionRecord[];
|
|
17
|
+
/** How many criteria a session has (one indexed count; the stream snapshot uses it). */
|
|
18
|
+
criteriaCount(sessionId: string): number;
|
|
19
|
+
/** Marks a criterion not applicable. Callers must be the user, never a tool result (O2). */
|
|
20
|
+
markNotApplicable(criterionId: string, reason: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* The user edits a criterion. Only the wording and the required flag change: the verifier and
|
|
23
|
+
* the scope stay, so the evidence already captured keeps meaning what it meant (add a new
|
|
24
|
+
* criterion to check something else). Callers must be the user, never a tool result (O2).
|
|
25
|
+
*/
|
|
26
|
+
updateCriterion(criterionId: string, change: {
|
|
27
|
+
text?: string;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
}): CriterionRecord | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* The unbacked claims the last COMPLETED run of the session warned about (`proof_divergence`
|
|
32
|
+
* events of the event journal). A later run that completes without a warning clears them.
|
|
33
|
+
* Display only (D8): nothing here changes a status.
|
|
34
|
+
*/
|
|
35
|
+
divergences(sessionId: string): ProofLedger["divergences"];
|
|
36
|
+
/** Appends one immutable evidence (D2: it must name its run and its call). */
|
|
37
|
+
appendEvidence(input: NewEvidence): ProofEvidence;
|
|
38
|
+
/**
|
|
39
|
+
* Notes a successful edit of a workspace-relative path (D5). One row per path keeps the newest
|
|
40
|
+
* time, so the table is bounded by the files touched and an older time never rewinds it.
|
|
41
|
+
*/
|
|
42
|
+
recordEdit(sessionId: string, path: string, editedAt: string): void;
|
|
43
|
+
/** The files edited in a session with the time of their latest edit, sorted by path. */
|
|
44
|
+
edits(sessionId: string): Array<{
|
|
45
|
+
path: string;
|
|
46
|
+
editedAt: string;
|
|
47
|
+
}>;
|
|
48
|
+
/** The ledger of a session with every status derived now (D4). */
|
|
49
|
+
ledger(sessionId: string, now?: string): ProofLedger;
|
|
50
|
+
/** The ledger every surface shows: `ledger()` plus the divergences of the last completed run. */
|
|
51
|
+
view(sessionId: string, now?: string): ProofLedger;
|
|
52
|
+
}
|