@crewhaus/tool-proof 0.0.0-stage → 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.
@@ -0,0 +1,52 @@
1
+ /** The session-id shape every session store and log enforces. */
2
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
3
+ /** provability's spec-name rule (`proofsRoot` refuses anything else). */
4
+ const SPEC_NAME_REGEX = /^[a-zA-Z0-9_\-.]+$/;
5
+ function bridgeOf(ctx) {
6
+ const bridge = ctx?.bridge;
7
+ return typeof bridge === "object" && bridge !== null ? bridge : undefined;
8
+ }
9
+ /** The run's context: `ctx.runContext` first, the bridge's otherwise. */
10
+ export function runContextOf(ctx) {
11
+ return ctx?.runContext ?? bridgeOf(ctx)?.runContext;
12
+ }
13
+ /** The run's session id, when it has a well-formed one. */
14
+ export function sessionIdOf(ctx) {
15
+ const id = runContextOf(ctx)?.sessionId;
16
+ return typeof id === "string" && SESSION_ID_REGEX.test(id) ? id : undefined;
17
+ }
18
+ /** The run's trace bus, or undefined off the loop (publishing is then a no-op). */
19
+ export function eventBusOf(ctx) {
20
+ const bus = runContextOf(ctx)?.eventBus;
21
+ return bus !== undefined && typeof bus.publish === "function" ? bus : undefined;
22
+ }
23
+ /** The run's session event log, when the runtime handed one over. */
24
+ export function eventLogOf(ctx) {
25
+ const log = bridgeOf(ctx)?.eventLog;
26
+ return log !== undefined && typeof log.append === "function" ? log : undefined;
27
+ }
28
+ /** Where the run's session logs live, when the runtime said. */
29
+ export function sessionRootOf(ctx) {
30
+ const dir = bridgeOf(ctx)?.sessionRootDir;
31
+ return typeof dir === "string" && dir !== "" ? dir : undefined;
32
+ }
33
+ /** Whether the loop was told a sandbox backend is wired (`runChatLoop({ sandboxAvailable })`). */
34
+ export function bridgeSandboxAvailable(ctx) {
35
+ return bridgeOf(ctx)?.sandboxAvailable === true;
36
+ }
37
+ /** The spec name the run carries, when it carries a valid one. */
38
+ export function specNameOf(ctx) {
39
+ const name = runContextOf(ctx)?.specName;
40
+ return typeof name === "string" && SPEC_NAME_REGEX.test(name) && !/^\.+$/.test(name)
41
+ ? name
42
+ : undefined;
43
+ }
44
+ /** Log a warning on the run's logger; silent off the loop. Never throws. */
45
+ export function warn(ctx, msg, fields) {
46
+ try {
47
+ runContextOf(ctx)?.logger?.warn(msg, fields);
48
+ }
49
+ catch {
50
+ // A logger that throws must not turn a finished check into a failure.
51
+ }
52
+ }
@@ -0,0 +1,57 @@
1
+ import { type EvidenceIndex, type InputSchemaLookup } from "@crewhaus/provability";
2
+ export type SessionEvidenceOptions = {
3
+ /** The run's session; absent ⇒ an empty index (nothing reachable was read). */
4
+ readonly sessionId?: string;
5
+ /** Where session `.jsonl` logs live (default `.crewhaus/sessions`, cwd-relative, as the runtime writes them). */
6
+ readonly rootDir?: string;
7
+ /** Workspace root for path-shaped inputs (default `process.cwd()`). */
8
+ readonly workspaceRoot?: string;
9
+ /**
10
+ * Where the runtime spilled results too long for the log (the tenant's
11
+ * `toolResultRoot` inside a tenant scope; default `.crewhaus/tool-results`
12
+ * under the workspace), so a spilled page is read whole.
13
+ */
14
+ readonly toolResultsRoot?: string;
15
+ /** `session` (every call the run made) or `turn` (the current turn's). */
16
+ readonly scope: "session" | "turn";
17
+ /** Told when a log could not be read to its end (records before it are kept). */
18
+ readonly onUnreadable?: (info: {
19
+ readonly sessionId: string;
20
+ readonly message: string;
21
+ }) => void;
22
+ /**
23
+ * The input schema each tool declared (provability's
24
+ * `EvidenceOptions.inputSchemaOf`), forwarded to every log read: an MCP
25
+ * call's origin is a field its schema declares. Absent ⇒ provability
26
+ * looks in `defaultCatalog`.
27
+ */
28
+ readonly inputSchemaOf?: InputSchemaLookup;
29
+ };
30
+ /** The index, plus what a reader of the preflight should know about how it was built. */
31
+ export type SessionEvidence = {
32
+ readonly index: EvidenceIndex;
33
+ readonly notes: ReadonlyArray<string>;
34
+ };
35
+ /**
36
+ * Is a logged `user_message` payload the user's own message — the start of a
37
+ * turn? Runtime-injected messages carry `synthetic: true`, and the runtime
38
+ * logs each batch of tool results as a user message whose content is only
39
+ * `tool_result` blocks; neither starts a turn.
40
+ */
41
+ export declare function startsTurn(payload: unknown): boolean;
42
+ /**
43
+ * The current turn of a session log: the ids of the calls made after the
44
+ * last real user message, and the sub-agent sessions started after it.
45
+ * Reading never creates the log directory; a missing log is an empty turn.
46
+ * A log that cannot be read to its end keeps what was read before it.
47
+ */
48
+ export declare function currentTurn(opts: {
49
+ readonly sessionId: string;
50
+ readonly rootDir: string;
51
+ readonly onUnreadable?: SessionEvidenceOptions["onUnreadable"];
52
+ }): Promise<{
53
+ readonly toolUseIds: ReadonlySet<string>;
54
+ readonly children: readonly string[];
55
+ }>;
56
+ /** The evidence index over a session's log, scoped as the config says. */
57
+ export declare function sessionEvidence(opts: SessionEvidenceOptions): Promise<SessionEvidence>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The evidence index the proof tools read when nobody injects one: the
3
+ * run's own session event log (design §5.2).
4
+ *
5
+ * The log is the gate's source too — it records every `tool_use` (full
6
+ * input) and `tool_result` (full output) verbatim, children included, and
7
+ * compaction cannot evict from it. A preflight runs mid-turn, after the
8
+ * turn's earlier calls were logged (runtime-core appends `tool_use` before a
9
+ * call runs and `tool_result` when it returns), so the draft is checked
10
+ * against everything the run has read so far. The composition root injects
11
+ * its own builder (the log merged with the in-memory transcript), which is
12
+ * what makes the gate and the tools agree exactly; this default is for the
13
+ * builtin instances.
14
+ *
15
+ * `scope: turn` (design §3) keeps only the current turn's calls. The log
16
+ * records no turn number on a call, so the turn is read the way the gate
17
+ * reads it from the transcript: everything after the last REAL user message
18
+ * — not a runtime-injected one (`synthetic: true`: a retry nudge, a
19
+ * recovery note) and not the tool-result carrier the runtime logs as a user
20
+ * message. Sub-agents started in the turn count whole: their calls ran for
21
+ * it.
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { DEFAULT_ROOT_DIR, openEventLog } from "@crewhaus/event-log";
26
+ import { buildEvidenceIndex, evidenceFromSessionLog, } from "@crewhaus/provability";
27
+ import { clip } from "./check";
28
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
29
+ /** How many unreadable logs the note names; it counts the rest. */
30
+ const UNREADABLE_NAMED = 3;
31
+ /** How much of the first unreadable log's error the note quotes. */
32
+ const UNREADABLE_MESSAGE_CHARS = 200;
33
+ /** One less than the walk's own bound: a child started in the turn is already one level down. */
34
+ const CHILD_MAX_DEPTH = 7;
35
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
36
+ /**
37
+ * Is a logged `user_message` payload the user's own message — the start of a
38
+ * turn? Runtime-injected messages carry `synthetic: true`, and the runtime
39
+ * logs each batch of tool results as a user message whose content is only
40
+ * `tool_result` blocks; neither starts a turn.
41
+ */
42
+ export function startsTurn(payload) {
43
+ if (!isRecord(payload) || payload["synthetic"] === true)
44
+ return false;
45
+ const content = payload["content"];
46
+ if (!Array.isArray(content))
47
+ return true;
48
+ if (content.length === 0)
49
+ return true;
50
+ return !content.every((block) => isRecord(block) && block["type"] === "tool_result");
51
+ }
52
+ /**
53
+ * The current turn of a session log: the ids of the calls made after the
54
+ * last real user message, and the sub-agent sessions started after it.
55
+ * Reading never creates the log directory; a missing log is an empty turn.
56
+ * A log that cannot be read to its end keeps what was read before it.
57
+ */
58
+ export async function currentTurn(opts) {
59
+ const toolUseIds = new Set();
60
+ let children = [];
61
+ if (!existsSync(join(opts.rootDir, `${opts.sessionId}.jsonl`))) {
62
+ return { toolUseIds, children };
63
+ }
64
+ const log = await openEventLog(opts.sessionId, { rootDir: opts.rootDir });
65
+ try {
66
+ for await (const event of log.read()) {
67
+ const p = event.payload;
68
+ if (event.kind === "user_message") {
69
+ if (startsTurn(p)) {
70
+ toolUseIds.clear();
71
+ children = [];
72
+ }
73
+ }
74
+ else if (event.kind === "tool_use" && isRecord(p) && typeof p["id"] === "string") {
75
+ toolUseIds.add(p["id"]);
76
+ }
77
+ else if (event.kind === "sub_agent_start" && isRecord(p)) {
78
+ const child = p["childSessionId"];
79
+ if (typeof child === "string" &&
80
+ SESSION_ID_REGEX.test(child) &&
81
+ !children.includes(child)) {
82
+ children.push(child);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ catch (err) {
88
+ opts.onUnreadable?.({
89
+ sessionId: opts.sessionId,
90
+ message: err instanceof Error ? err.message : String(err),
91
+ });
92
+ }
93
+ finally {
94
+ await log.close();
95
+ }
96
+ return { toolUseIds, children };
97
+ }
98
+ /**
99
+ * One note for every log that could not be read to its end, however many
100
+ * there were: a run whose sub-agents crashed mid-append can leave dozens of
101
+ * torn logs, and a note per log would crowd the preflight's instructions out
102
+ * of the report. Each log is still reported to `onUnreadable` on its own.
103
+ */
104
+ function unreadableNote(unreadable) {
105
+ const ids = [...unreadable.keys()];
106
+ const [first] = ids;
107
+ if (first === undefined)
108
+ return [];
109
+ const why = clip(unreadable.get(first) ?? "", UNREADABLE_MESSAGE_CHARS);
110
+ if (ids.length === 1) {
111
+ return [
112
+ `the session log ${first} could not be read to its end (${why}); only the calls before that point count`,
113
+ ];
114
+ }
115
+ const named = ids.slice(0, UNREADABLE_NAMED);
116
+ const rest = ids.length - named.length;
117
+ return [
118
+ `${ids.length} session logs could not be read to their end (${named.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}; the first: ${why}); in each, only the calls before that point count`,
119
+ ];
120
+ }
121
+ /** The evidence index over a session's log, scoped as the config says. */
122
+ export async function sessionEvidence(opts) {
123
+ if (opts.sessionId === undefined || !SESSION_ID_REGEX.test(opts.sessionId)) {
124
+ return {
125
+ index: buildEvidenceIndex([]),
126
+ notes: [
127
+ "no session log is reachable from this call (it carries no run context), so no source counts as fetched and no tool result as run",
128
+ ],
129
+ };
130
+ }
131
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
132
+ // Each unreadable log once, with its first error: the parent log is read
133
+ // twice under `scope: turn` (the turn, then its records).
134
+ const unreadable = new Map();
135
+ const onUnreadable = (info) => {
136
+ if (!unreadable.has(info.sessionId))
137
+ unreadable.set(info.sessionId, info.message);
138
+ opts.onUnreadable?.(info);
139
+ };
140
+ const common = {
141
+ rootDir,
142
+ onUnreadable,
143
+ ...(opts.workspaceRoot !== undefined ? { workspaceRoot: opts.workspaceRoot } : {}),
144
+ ...(opts.toolResultsRoot !== undefined ? { toolResultsRoot: opts.toolResultsRoot } : {}),
145
+ ...(opts.inputSchemaOf !== undefined ? { inputSchemaOf: opts.inputSchemaOf } : {}),
146
+ };
147
+ if (opts.scope === "session") {
148
+ const records = await evidenceFromSessionLog({ sessionId: opts.sessionId, ...common });
149
+ return {
150
+ index: buildEvidenceIndex(records, { sessionId: opts.sessionId }),
151
+ notes: unreadableNote(unreadable),
152
+ };
153
+ }
154
+ const turn = await currentTurn({ sessionId: opts.sessionId, rootDir, onUnreadable });
155
+ const own = await evidenceFromSessionLog({
156
+ sessionId: opts.sessionId,
157
+ includeChildren: false,
158
+ ...common,
159
+ });
160
+ const records = own.filter((r) => turn.toolUseIds.has(r.toolUseId));
161
+ for (const child of turn.children) {
162
+ records.push(...(await evidenceFromSessionLog({
163
+ sessionId: child,
164
+ maxDepth: CHILD_MAX_DEPTH,
165
+ ...common,
166
+ })));
167
+ }
168
+ // The parent named, so a child's call never answers the parent's id.
169
+ return {
170
+ index: buildEvidenceIndex(records, { sessionId: opts.sessionId }),
171
+ notes: unreadableNote(unreadable),
172
+ };
173
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `ProvabilityPreflight`: the gate's verdict on a draft, before the answer
3
+ * is sent (design §2, §7).
4
+ *
5
+ * It calls the gate's own verifier — `verifyDocument`, the one function the
6
+ * gate, this tool, `crewhaus provability check` and the research report
7
+ * gate share — with the gate's config, its evidence and its artifact store,
8
+ * so a draft is graded here exactly as the gate will grade it, except for
9
+ * the three steps that reach outside the run, which a read-only preflight
10
+ * never takes:
11
+ *
12
+ * - `sources.resolve: head` — the gate sends one guarded HEAD per cited URL
13
+ * (an egress). The preflight checks with `resolve: off` instead of
14
+ * failing every URL as `unresolved`, and says so.
15
+ * - replay at `checked` — the gate re-runs a cited pure tool and compares
16
+ * digests. The preflight does not: a binding reads `replayed: false`.
17
+ * Replaying a pure tool with its logged input reproduces it, so this only
18
+ * matters for a tool that is not as deterministic as its flags say.
19
+ * - the paraphrase-waiver judge — never consulted here (it spends, and a
20
+ * preflight must not). A paraphrase the gate's judge might accept shows
21
+ * as `not_found_in_source`: the preflight is the stricter of the two.
22
+ *
23
+ * Nothing is gated, persisted or published: the draft is not an answer.
24
+ */
25
+ import { type EvidenceIndex, type ProofArtifactStore, type ProofBundle, type ProvabilityConfig, type VerifyOptions } from "@crewhaus/provability";
26
+ /** The longest draft a preflight reads (a long research report fits). */
27
+ export declare const DRAFT_CHARS = 200000;
28
+ /**
29
+ * The most claims the table lists; the counts above it always cover all of
30
+ * them. The report's byte budget ({@link REPORT_BYTES}) usually stops the
31
+ * table sooner.
32
+ */
33
+ export declare const TABLE_ROWS = 100;
34
+ /** The most bindings one row lists; `moreEvidence` counts the rest. */
35
+ export declare const ROW_BINDINGS = 6;
36
+ /**
37
+ * The notes' share. The preflight writes at most three or four short ones
38
+ * (where it differs from the gate, how the evidence was read), but they sit
39
+ * ahead of the nudge, so they are budgeted like it: a note list that grew
40
+ * with the run (one per unreadable log, say) would otherwise push the nudge
41
+ * out of the head the runtime shows.
42
+ */
43
+ export declare const NOTES_BYTES = 1600;
44
+ /** The config the preflight verifies with, and what it left out of the gate's. */
45
+ export declare function preflightConfig(config: ProvabilityConfig): {
46
+ readonly config: ProvabilityConfig;
47
+ readonly notes: ReadonlyArray<string>;
48
+ };
49
+ /** Run the gate's verifier over `draft` with the preflight's config (see the module doc). */
50
+ export declare function preflightBundle(draft: string, opts: {
51
+ readonly config: ProvabilityConfig;
52
+ readonly evidence: EvidenceIndex;
53
+ readonly artifacts: ProofArtifactStore;
54
+ readonly workspaceRoot: string;
55
+ /**
56
+ * `scope: turn` as the gate applies it to a session-wide index
57
+ * (`VerifyOptions.turnNumbers`). Absent: `evidence` is used as given,
58
+ * so it must already be scoped.
59
+ */
60
+ readonly turnNumbers?: ReadonlySet<number>;
61
+ /** The gate's re-check of a stored proof (`recheckProof`), so the preflight reads proofs as it does. */
62
+ readonly recheckProof?: VerifyOptions["recheckProof"];
63
+ /** The directories the gate refuses path sources under (`VerifyOptions.stateRoots`, `runtimeStateRoots`). */
64
+ readonly stateRoots?: VerifyOptions["stateRoots"];
65
+ readonly now?: () => Date;
66
+ }): Promise<{
67
+ readonly bundle: ProofBundle;
68
+ readonly notes: ReadonlyArray<string>;
69
+ }>;
70
+ /**
71
+ * The preflight's answer, in the order the model should read it: the
72
+ * verdict and counts, the footer the reader would see, what to do next,
73
+ * the notes on where the preflight differs from the gate, the gate's own
74
+ * retry nudge when the draft would not pass cleanly, and then a row per
75
+ * claim with each binding's verdict — the unmet claims first.
76
+ *
77
+ * The whole report fits in {@link REPORT_BYTES}: the runtime shows the model
78
+ * only the head of a larger result, so the instructions go first — the
79
+ * notes and the nudge each held to a byte share ({@link NOTES_BYTES},
80
+ * `NUDGE_BYTES`) — and the table takes the room that is left, row by row; `tableOmitted` counts the
81
+ * claims it had no room for (the counts above it cover every claim, and the
82
+ * nudge already names the unmet ones). Excerpts of fetched bodies are left
83
+ * out on purpose: the model already read those bodies, and the nudge's rule
84
+ * (no fetched content) is the right one for anything the gate machinery
85
+ * hands back to the model.
86
+ */
87
+ export declare function preflightReport(bundle: ProofBundle, notes: ReadonlyArray<string>): Record<string, unknown>;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `ProvabilityPreflight`: the gate's verdict on a draft, before the answer
3
+ * is sent (design §2, §7).
4
+ *
5
+ * It calls the gate's own verifier — `verifyDocument`, the one function the
6
+ * gate, this tool, `crewhaus provability check` and the research report
7
+ * gate share — with the gate's config, its evidence and its artifact store,
8
+ * so a draft is graded here exactly as the gate will grade it, except for
9
+ * the three steps that reach outside the run, which a read-only preflight
10
+ * never takes:
11
+ *
12
+ * - `sources.resolve: head` — the gate sends one guarded HEAD per cited URL
13
+ * (an egress). The preflight checks with `resolve: off` instead of
14
+ * failing every URL as `unresolved`, and says so.
15
+ * - replay at `checked` — the gate re-runs a cited pure tool and compares
16
+ * digests. The preflight does not: a binding reads `replayed: false`.
17
+ * Replaying a pure tool with its logged input reproduces it, so this only
18
+ * matters for a tool that is not as deterministic as its flags say.
19
+ * - the paraphrase-waiver judge — never consulted here (it spends, and a
20
+ * preflight must not). A paraphrase the gate's judge might accept shows
21
+ * as `not_found_in_source`: the preflight is the stricter of the two.
22
+ *
23
+ * Nothing is gated, persisted or published: the draft is not an answer.
24
+ */
25
+ import { renderFooter, renderNudge, verifyDocument, } from "@crewhaus/provability";
26
+ import { REPORT_BYTES, clip, clipMiddle, fitText, jsonBytes, listed } from "./check";
27
+ /** The longest draft a preflight reads (a long research report fits). */
28
+ export const DRAFT_CHARS = 200_000;
29
+ /**
30
+ * The most claims the table lists; the counts above it always cover all of
31
+ * them. The report's byte budget ({@link REPORT_BYTES}) usually stops the
32
+ * table sooner.
33
+ */
34
+ export const TABLE_ROWS = 100;
35
+ /** The most bindings one row lists; `moreEvidence` counts the rest. */
36
+ export const ROW_BINDINGS = 6;
37
+ /** The nudge's share of the report; cut in the middle, so its closing instructions stay. */
38
+ const NUDGE_BYTES = 3_500;
39
+ /**
40
+ * The notes' share. The preflight writes at most three or four short ones
41
+ * (where it differs from the gate, how the evidence was read), but they sit
42
+ * ahead of the nudge, so they are budgeted like it: a note list that grew
43
+ * with the run (one per unreadable log, say) would otherwise push the nudge
44
+ * out of the head the runtime shows.
45
+ */
46
+ export const NOTES_BYTES = 1_600;
47
+ /** One note as listed. */
48
+ const NOTE_CHARS = 500;
49
+ const moreNotes = (rest) => `… and ${rest} more ${rest === 1 ? "note" : "notes"} on how this preflight read the run`;
50
+ /** The config the preflight verifies with, and what it left out of the gate's. */
51
+ export function preflightConfig(config) {
52
+ const notes = [];
53
+ let checked = config;
54
+ if (config.sources.resolve === "head") {
55
+ checked = { ...config, sources: { ...config.sources, resolve: "off" } };
56
+ if (config.level === "cited" || config.level === "grounded") {
57
+ notes.push("provability.sources.resolve is head: the gate also sends one HEAD request per cited URL and fails an unreachable one (unresolved); this preflight sends none, so it does not check reachability");
58
+ }
59
+ }
60
+ if (config.judge !== undefined && config.level === "grounded") {
61
+ notes.push("the gate may ask its paraphrase judge to accept a claim this preflight reports as not_found_in_source; the judge is never consulted here, so fix those claims rather than count on it");
62
+ }
63
+ return { config: checked, notes };
64
+ }
65
+ /** Run the gate's verifier over `draft` with the preflight's config (see the module doc). */
66
+ export async function preflightBundle(draft, opts) {
67
+ const { config, notes } = preflightConfig(opts.config);
68
+ const bundle = await verifyDocument(draft, {
69
+ config,
70
+ evidence: opts.evidence,
71
+ artifacts: opts.artifacts,
72
+ workspaceRoot: opts.workspaceRoot,
73
+ ...(opts.turnNumbers !== undefined ? { turnNumbers: opts.turnNumbers } : {}),
74
+ ...(opts.recheckProof !== undefined ? { recheckProof: opts.recheckProof } : {}),
75
+ ...(opts.stateRoots !== undefined ? { stateRoots: opts.stateRoots } : {}),
76
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
77
+ // Deliberately absent: replay, isPureTool, resolveHead, judgeWaiver.
78
+ });
79
+ const replayed = config.level === "checked" &&
80
+ config.execution.replay &&
81
+ bundle.claims.some((c) => c.bindings.some((b) => b.kind === "execution" && b.ok));
82
+ return {
83
+ bundle,
84
+ notes: replayed
85
+ ? [
86
+ ...notes,
87
+ "the gate also re-runs each cited pure tool with its logged input and fails a claim whose output differs (replay_mismatch); this preflight does not re-run them",
88
+ ]
89
+ : notes,
90
+ };
91
+ }
92
+ function bindingRow(b) {
93
+ return {
94
+ ref: b.ref,
95
+ kind: b.kind,
96
+ rung: b.rung,
97
+ ok: b.ok,
98
+ ...(b.reason !== undefined ? { reason: b.reason } : {}),
99
+ // Detail says why a binding fell short; on a met binding it is noise.
100
+ ...(!b.ok && b.detail !== undefined ? { detail: clip(b.detail, 300) } : {}),
101
+ ...(b.origin !== undefined ? { origin: clip(b.origin, 200) } : {}),
102
+ ...(b.kind === "execution" && b.replayed !== undefined ? { replayed: b.replayed } : {}),
103
+ ...(b.checker !== undefined ? { checker: b.checker } : {}),
104
+ };
105
+ }
106
+ function claimRow(c) {
107
+ // A sentence can carry any number of markers; past a few the row says how
108
+ // many more rather than spending the report on them.
109
+ const shown = c.bindings.slice(0, ROW_BINDINGS);
110
+ return {
111
+ index: c.index,
112
+ status: c.status,
113
+ best: c.best,
114
+ text: clip(c.text, 300),
115
+ ...(c.line !== undefined ? { line: c.line } : {}),
116
+ ...(c.definition !== undefined ? { definition: c.definition } : {}),
117
+ evidence: shown.map(bindingRow),
118
+ ...(c.bindings.length > shown.length ? { moreEvidence: c.bindings.length - shown.length } : {}),
119
+ };
120
+ }
121
+ /** Room kept for `"tableOmitted":<n>` after the rows. */
122
+ const OMITTED_KEY_BYTES = 32;
123
+ /**
124
+ * The preflight's answer, in the order the model should read it: the
125
+ * verdict and counts, the footer the reader would see, what to do next,
126
+ * the notes on where the preflight differs from the gate, the gate's own
127
+ * retry nudge when the draft would not pass cleanly, and then a row per
128
+ * claim with each binding's verdict — the unmet claims first.
129
+ *
130
+ * The whole report fits in {@link REPORT_BYTES}: the runtime shows the model
131
+ * only the head of a larger result, so the instructions go first — the
132
+ * notes and the nudge each held to a byte share ({@link NOTES_BYTES},
133
+ * `NUDGE_BYTES`) — and the table takes the room that is left, row by row; `tableOmitted` counts the
134
+ * claims it had no room for (the counts above it cover every claim, and the
135
+ * nudge already names the unmet ones). Excerpts of fetched bodies are left
136
+ * out on purpose: the model already read those bodies, and the nudge's rule
137
+ * (no fetched content) is the right one for anything the gate machinery
138
+ * hands back to the model.
139
+ */
140
+ export function preflightReport(bundle, notes) {
141
+ const clean = bundle.verdict === "pass" && bundle.unmet === 0;
142
+ const head = {
143
+ verdict: bundle.verdict,
144
+ level: bundle.level,
145
+ score: bundle.score,
146
+ threshold: bundle.threshold,
147
+ achieved: bundle.achieved,
148
+ claims: bundle.claimCount,
149
+ met: bundle.met,
150
+ unmet: bundle.unmet,
151
+ assumptions: bundle.assumptions,
152
+ ...(bundle.assumptionsRefused !== undefined
153
+ ? { assumptionsRefused: bundle.assumptionsRefused }
154
+ : {}),
155
+ ...(bundle.hypotheses !== undefined ? { hypotheses: bundle.hypotheses } : {}),
156
+ sources: bundle.sources,
157
+ ...(bundle.vacuous !== undefined ? { vacuous: bundle.vacuous } : {}),
158
+ footer: renderFooter(bundle),
159
+ next: clean
160
+ ? "the gate would accept this draft as it stands; send it without changing the claims or their citations"
161
+ : bundle.verdict === "pass"
162
+ ? "the gate would accept this draft, but the claims marked unmet are not backed; fix or drop them if you can"
163
+ : "the gate would not accept this draft; fix every unmet claim, then run ProvabilityPreflight again before you answer",
164
+ ...(notes.length > 0 ? { notes: listed(notes, NOTES_BYTES, moreNotes, NOTE_CHARS) } : {}),
165
+ // The nudge is written for the gate's retry; here the draft is not yet an
166
+ // answer. Cut in the middle: its head names the verdict and the first
167
+ // unmet claims, its tail how to cite and to preflight again.
168
+ ...(clean
169
+ ? {}
170
+ : {
171
+ nudge: fitText(renderNudge(bundle).replace("your last answer", "this draft"), NUDGE_BYTES, clipMiddle),
172
+ }),
173
+ };
174
+ // Unmet claims first, each group in draft order: they are what to fix.
175
+ const ordered = [
176
+ ...bundle.claims.filter((c) => c.status === "unmet"),
177
+ ...bundle.claims.filter((c) => c.status !== "unmet"),
178
+ ];
179
+ const rows = [];
180
+ let used = jsonBytes({ ...head, table: [] }) + OMITTED_KEY_BYTES;
181
+ for (const claim of ordered.slice(0, TABLE_ROWS)) {
182
+ const row = claimRow(claim);
183
+ const cost = jsonBytes(row) + (rows.length > 0 ? 1 : 0);
184
+ if (used + cost > REPORT_BYTES)
185
+ break;
186
+ rows.push(row);
187
+ used += cost;
188
+ }
189
+ return {
190
+ ...head,
191
+ table: rows,
192
+ ...(bundle.claims.length > rows.length
193
+ ? { tableOmitted: bundle.claims.length - rows.length }
194
+ : {}),
195
+ };
196
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The `proven` rung's re-check (design §6.4; `VerifyOptions.recheckProof`):
3
+ * the statement and proof a stored artifact holds, checked again by the
4
+ * builtin checker before the artifact backs a claim.
5
+ *
6
+ * An artifact is a file in the workspace. The verifier already requires the
7
+ * run's own `ProofCheck` call that wrote it, with the input it was given;
8
+ * this is the second, independent reading: the checker itself, run on what
9
+ * the file says now. Only the builtin is re-run — it is pure, needs no
10
+ * sandbox, runs on a worker thread and is bounded by `formal.timeoutMs`. The
11
+ * sandbox checkers start a container per check, so their artifacts rest on
12
+ * the logged call alone.
13
+ *
14
+ * A re-check that stops without a verdict (cancelled, out of time, the
15
+ * checker failing) is not a verdict: it returns `undefined`, and the
16
+ * verifier falls back to the logged call.
17
+ */
18
+ import { type ProofChecker } from "@crewhaus/proof-checker";
19
+ import type { ProofArtifact, RecheckedProof } from "@crewhaus/provability";
20
+ export type RecheckProofOptions = {
21
+ /** The check's budget: the spec's `formal.timeoutMs`. */
22
+ readonly timeoutMs: number;
23
+ /** The turn's signal: an aborted turn re-checks nothing. */
24
+ readonly signal?: AbortSignal;
25
+ /** The builtin checker to run (tests). Default: one shared builtin. */
26
+ readonly checker?: ProofChecker;
27
+ };
28
+ /** Re-check `artifact` with the builtin checker (see the module doc). */
29
+ export declare function recheckProof(artifact: ProofArtifact, opts: RecheckProofOptions): Promise<RecheckedProof | undefined>;
30
+ /** A `VerifyOptions.recheckProof` over {@link recheckProof} with these options. */
31
+ export declare function proofRecheck(opts: RecheckProofOptions): (artifact: ProofArtifact) => Promise<RecheckedProof | undefined>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The `proven` rung's re-check (design §6.4; `VerifyOptions.recheckProof`):
3
+ * the statement and proof a stored artifact holds, checked again by the
4
+ * builtin checker before the artifact backs a claim.
5
+ *
6
+ * An artifact is a file in the workspace. The verifier already requires the
7
+ * run's own `ProofCheck` call that wrote it, with the input it was given;
8
+ * this is the second, independent reading: the checker itself, run on what
9
+ * the file says now. Only the builtin is re-run — it is pure, needs no
10
+ * sandbox, runs on a worker thread and is bounded by `formal.timeoutMs`. The
11
+ * sandbox checkers start a container per check, so their artifacts rest on
12
+ * the logged call alone.
13
+ *
14
+ * A re-check that stops without a verdict (cancelled, out of time, the
15
+ * checker failing) is not a verdict: it returns `undefined`, and the
16
+ * verifier falls back to the logged call.
17
+ */
18
+ import { BUILTIN_CHECKER_ID, createBuiltinChecker, stoppedWithoutVerdict, } from "@crewhaus/proof-checker";
19
+ let shared;
20
+ /** Re-check `artifact` with the builtin checker (see the module doc). */
21
+ export async function recheckProof(artifact, opts) {
22
+ if (artifact.checker !== BUILTIN_CHECKER_ID)
23
+ return undefined;
24
+ // Read afresh each time: the turn may end while the check runs.
25
+ const aborted = () => opts.signal?.aborted === true;
26
+ if (aborted())
27
+ return undefined;
28
+ shared ??= createBuiltinChecker();
29
+ const checker = opts.checker ?? shared;
30
+ const result = await checker.check({
31
+ claim: artifact.claim,
32
+ statement: artifact.statement,
33
+ proof: artifact.proof,
34
+ timeoutMs: opts.timeoutMs,
35
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
36
+ });
37
+ if (result.ok) {
38
+ return { ok: true, assumptions: [...result.assumptions], axioms: [...result.axioms] };
39
+ }
40
+ if (aborted() || stoppedWithoutVerdict(result))
41
+ return undefined;
42
+ return { ok: false };
43
+ }
44
+ /** A `VerifyOptions.recheckProof` over {@link recheckProof} with these options. */
45
+ export function proofRecheck(opts) {
46
+ return (artifact) => recheckProof(artifact, opts);
47
+ }