@jwilger/pi-development-system 0.34.0 → 0.36.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.
@@ -1,9 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
4
+ import { registerCompactionResync } from "../src/context/compaction.ts";
3
5
  import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
6
+ import { registerModelAdvice } from "../src/context/model-advice.ts";
4
7
  import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
5
8
  import { applyPromptSection } from "../src/context/system-prompt.ts";
9
+ import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
6
10
  import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
11
+ import { defaultMatrix } from "../src/core/models.ts";
7
12
  import { detectProfiles } from "../src/core/profile.ts";
8
13
  import { createApprovalStore } from "../src/gates/approvals.ts";
9
14
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
@@ -37,6 +42,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
37
42
  const jevHolder = createJevHolder();
38
43
  let jevModel: string | undefined;
39
44
  let lastCtx: ExtensionContext | undefined;
45
+ let pushMinutes = DEFAULT_PUSH_MINUTES;
40
46
 
41
47
  const refreshStatus = () => {
42
48
  lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get(), jevModel));
@@ -44,6 +50,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
44
50
 
45
51
  const refreshProfiles = async (ctx: ExtensionContext) => {
46
52
  const config = await loadConfig(ctx.cwd);
53
+ pushMinutes = config.ok ? config.value.cadence.pushMinutes : DEFAULT_PUSH_MINUTES;
47
54
  const override = config.ok ? config.value.profiles.override : [];
48
55
  const profiles = await detectProfiles(ctx.cwd, override);
49
56
  const current = state.get().profiles ?? [];
@@ -74,7 +81,13 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
74
81
  });
75
82
 
76
83
  pi.on("context", (event) => {
77
- const messages = appendContextTail(event.messages, renderContextTail(state.get()), Date.now());
84
+ const now = Date.now();
85
+ const cadence = cadenceLine(state.get(), now, pushMinutes);
86
+ const messages = appendContextTail(
87
+ event.messages,
88
+ renderContextTail(state.get(), cadence),
89
+ now,
90
+ );
78
91
  return messages === undefined ? undefined : { messages };
79
92
  });
80
93
 
@@ -101,6 +114,24 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
101
114
  });
102
115
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
103
116
  registerTestEvidence({ pi, state });
117
+ registerCompactionResync({ pi, state });
118
+ registerModelAdvice({
119
+ pi,
120
+ state,
121
+ matrix: async (ctx) => {
122
+ const config = await loadConfig(ctx.cwd);
123
+ return config.ok ? config.value.models : defaultMatrix();
124
+ },
125
+ });
126
+ registerTurnVerifier({
127
+ pi,
128
+ state,
129
+ jev: (ctx) => jevHolder.forContext(ctx),
130
+ maxPerSession: async (ctx) => {
131
+ const config = await loadConfig(ctx.cwd);
132
+ return config.ok ? config.value.verifier.maxPerSession : DEFAULT_VERIFIER_MAX;
133
+ },
134
+ });
104
135
  registerRedFirstGuard({ pi, state });
105
136
  registerLintSuppressionGuard({ pi, state });
106
137
  pi.registerTool(createRecordDepartureTool({ pi, state }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_PUSH_MINUTES = 60;
2
+
3
+ import type { DevsysState } from "../core/types.ts";
4
+
5
+ /** Advisory only: a long gap since the last push while implementing hints the increment is too big. */
6
+ export function cadenceLine(
7
+ state: DevsysState,
8
+ now: number,
9
+ pushMinutes: number,
10
+ ): string | undefined {
11
+ if (state.phase !== "implementing" || state.lastPushAt === undefined) return undefined;
12
+ const pushed = Date.parse(state.lastPushAt);
13
+ if (!Number.isFinite(pushed)) return undefined;
14
+ const minutes = Math.floor((now - pushed) / 60_000);
15
+ return minutes > pushMinutes
16
+ ? `⚠ ${minutes} min since last push — is this increment too big?`
17
+ : undefined;
18
+ }
@@ -0,0 +1,24 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionState } from "../state/session-state.ts";
3
+ import { renderStateBlock } from "./state-block.ts";
4
+
5
+ export const RESYNC_ENTRY_TYPE = "devsys-resync";
6
+
7
+ /**
8
+ * pi lets `session_before_compact` only REPLACE the whole summary (summary, firstKeptEntryId,
9
+ * tokensBefore), which would mean writing our own summariser and paying another model call. So the
10
+ * summary is left to pi, and once it lands we re-state the working state as one message the next
11
+ * request sees. State itself lives in session entries and the per-turn context tail, so nothing is
12
+ * lost; this just makes it visible to the model again.
13
+ */
14
+ export function registerCompactionResync(deps: { pi: ExtensionAPI; state: SessionState }): void {
15
+ deps.pi.on("session_compact", () => {
16
+ const state = deps.state.get();
17
+ if (state.phase === "idle" && state.openDepartures.length === 0) return;
18
+ deps.pi.sendMessage({
19
+ customType: RESYNC_ENTRY_TYPE,
20
+ content: `[devsys resync] Context was compacted. Current working state:\n\n${renderStateBlock(state)}`,
21
+ display: true,
22
+ });
23
+ });
24
+ }
@@ -9,7 +9,7 @@ const scopeLabel = (d: Departure): string =>
9
9
  d.scope.kind === "slice" ? `slice ${d.scope.slice}` : d.scope.kind;
10
10
 
11
11
  /** Small, cache-friendly reminder appended at the end of the context; undefined when nothing to say. */
12
- export function renderContextTail(state: DevsysState): string | undefined {
12
+ export function renderContextTail(state: DevsysState, cadence?: string): string | undefined {
13
13
  if (state.phase === "idle" && state.openDepartures.length === 0) return undefined;
14
14
  const head = [
15
15
  "[development-system]",
@@ -17,7 +17,7 @@ export function renderContextTail(state: DevsysState): string | undefined {
17
17
  `Jev: ${state.jev}`,
18
18
  ...((state.profiles?.length ?? 0) > 0 ? [`profiles: ${state.profiles?.join(", ")}`] : []),
19
19
  ];
20
- const tailLines = [REMINDER];
20
+ const tailLines = [...(cadence === undefined ? [] : [cadence]), REMINDER];
21
21
  const room = MAX_LINES - head.length - tailLines.length - 1;
22
22
  const lines = state.openDepartures.map(
23
23
  (d) => `- ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`,
@@ -0,0 +1,58 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { adviseModel } from "../core/model-advice.ts";
3
+ import type { ModelMatrix } from "../core/models.ts";
4
+ import type { Phase } from "../core/types.ts";
5
+ import { availableModels } from "../state/models-command.ts";
6
+ import type { SessionState } from "../state/session-state.ts";
7
+
8
+ const ADVICE_ENTRY_TYPE = "devsys-model-advice";
9
+
10
+ export type ModelAdviceDeps = {
11
+ pi: ExtensionAPI;
12
+ state: SessionState;
13
+ matrix(ctx: ExtensionContext): Promise<ModelMatrix>;
14
+ };
15
+
16
+ type Seen = { ctx: ExtensionContext; model: { provider: string; id: string } | undefined };
17
+
18
+ /**
19
+ * Recommends — never switches — when the coordinator's model tier is below what the phase's slot
20
+ * resolves to. Runs on model_select and on phase change, at most once per (phase, model).
21
+ */
22
+ export function registerModelAdvice(deps: ModelAdviceDeps): void {
23
+ const told = new Set<string>();
24
+ let last: Seen | undefined;
25
+ let phase: Phase = deps.state.get().phase;
26
+
27
+ const check = async (seen: Seen): Promise<void> => {
28
+ const { model } = seen;
29
+ const current = deps.state.get().phase;
30
+ if (model === undefined) return;
31
+ const ref = `${model.provider}/${model.id}`;
32
+ const key = `${current}|${ref}`;
33
+ if (told.has(key)) return;
34
+ const advice = adviseModel({
35
+ matrix: await deps.matrix(seen.ctx),
36
+ available: availableModels(seen.ctx.modelRegistry),
37
+ phase: current,
38
+ model: ref,
39
+ });
40
+ if (advice === undefined) return;
41
+ told.add(key);
42
+ deps.pi.sendMessage({ customType: ADVICE_ENTRY_TYPE, content: advice, display: true });
43
+ };
44
+
45
+ deps.pi.on("session_start", (_event, ctx) => {
46
+ last = { ctx, model: ctx.model };
47
+ phase = deps.state.get().phase;
48
+ });
49
+ deps.pi.on("model_select", async (event, ctx) => {
50
+ last = { ctx, model: event.model };
51
+ await check(last);
52
+ });
53
+ deps.state.onChange((state) => {
54
+ if (state.phase === phase) return;
55
+ phase = state.phase;
56
+ if (last !== undefined) void check(last).catch(() => undefined);
57
+ });
58
+ }
@@ -0,0 +1,30 @@
1
+ import type { DevsysState } from "../core/types.ts";
2
+ import { activeReview } from "./status.ts";
3
+
4
+ const MAX_DEPARTURES = 8;
5
+
6
+ /** The durable working state as a short markdown block, for compaction resync and the like. */
7
+ export function renderStateBlock(state: DevsysState): string {
8
+ const departures = state.openDepartures.map(
9
+ (d) => ` - ${d.gate} — ${d.chosen.replace(/\s+/g, " ")}`,
10
+ );
11
+ const shown =
12
+ departures.length <= MAX_DEPARTURES
13
+ ? departures
14
+ : [
15
+ ...departures.slice(0, MAX_DEPARTURES - 1),
16
+ ` - …and ${departures.length - MAX_DEPARTURES + 1} more`,
17
+ ];
18
+ const run = state.lastTestRun;
19
+ return [
20
+ "## Development System State",
21
+ `- phase: ${state.phase}`,
22
+ `- sizing: ${state.sizing ?? "unset"}`,
23
+ `- slice: ${state.activeSlice ?? "none"}`,
24
+ `- open departures: ${state.openDepartures.length === 0 ? "none" : state.openDepartures.length}`,
25
+ ...shown,
26
+ `- ${activeReview(state) ?? "review: none"}`,
27
+ `- last test run: ${run === undefined ? "none" : `exit ${run.exitCode} — ${run.summary}`}`,
28
+ `- last push: ${state.lastPushAt ?? "none"}`,
29
+ ].join("\n");
30
+ }
@@ -13,7 +13,7 @@ const ciLabel = (state: DevsysState): string | undefined => {
13
13
  return `ci ${ci.status}${sha}`;
14
14
  };
15
15
 
16
- const activeReview = (state: DevsysState): string | undefined => {
16
+ export const activeReview = (state: DevsysState): string | undefined => {
17
17
  // Without an active slice (before /devsys-start exists) show the most recent review instead of nothing.
18
18
  const review =
19
19
  state.activeSlice === undefined ? state.reviews?.at(-1) : reviewOf(state, state.activeSlice);
@@ -0,0 +1,114 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { redactSecrets } from "../core/redact.ts";
4
+ import { summarizeOutput } from "../core/test-runner.ts";
5
+ import type { Phase } from "../core/types.ts";
6
+ import type { Jev } from "../jev/client.ts";
7
+ import { judgeTurn, type ToolEvidence } from "../jev/questions/turn.ts";
8
+ import type { SessionState } from "../state/session-state.ts";
9
+
10
+ export const VERIFIER_THRESHOLD = 0.75;
11
+ export const DEFAULT_VERIFIER_MAX = 6;
12
+ export const VERIFIER_ENTRY_TYPE = "devsys-verifier";
13
+ const VERIFIED_PHASES: ReadonlySet<Phase> = new Set(["implementing", "reviewing", "delivering"]);
14
+ const MAX_EVIDENCE = 60;
15
+
16
+ export type TurnVerifierDeps = {
17
+ pi: ExtensionAPI;
18
+ state: SessionState;
19
+ jev(ctx: ExtensionContext): Jev;
20
+ maxPerSession(ctx: ExtensionContext): number | Promise<number>;
21
+ };
22
+
23
+ type Parts = { text: string; callsTools: boolean };
24
+
25
+ /** Text and tool-call presence of an assistant message; undefined for any other role. */
26
+ export function assistantParts(message: AgentMessage): Parts | undefined {
27
+ if (message.role !== "assistant") return undefined;
28
+ const text = message.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
29
+ return { text, callsTools: message.content.some((c) => c.type === "toolCall") };
30
+ }
31
+
32
+ /** True when the last non-empty line of the message ends with a question mark. */
33
+ export function asksUser(text: string): boolean {
34
+ const last = text
35
+ .split("\n")
36
+ .map((l) => l.trim())
37
+ .filter((l) => l !== "")
38
+ .at(-1);
39
+ return last !== undefined && /\?[\s)"'*_`]*$/.test(last);
40
+ }
41
+
42
+ const claimMessage = (): string =>
43
+ "Development system: your last message claims something was run, passed or done, but no tool evidence in this run shows it. Run the verification now, or restate without the claim.";
44
+
45
+ const driftMessage = (slice: string): string =>
46
+ `Development system: your last message describes work beyond the active slice "${slice}". Either return to the slice, or call devsys_record_departure (gate scope.expansion) to record why the scope is growing.`;
47
+
48
+ const note = (content: string) => ({
49
+ type: "custom_message" as const,
50
+ customType: VERIFIER_ENTRY_TYPE,
51
+ content,
52
+ display: true,
53
+ });
54
+
55
+ /**
56
+ * At turn end, asks Jev whether the final message claims results no tool call supports, or wanders
57
+ * from the active slice, and forces ONE corrective continuation. Bounded: never on a turn that
58
+ * still calls tools, never on a question to the user, never twice in a row, never more than
59
+ * `maxPerSession` times. Jev trouble means silence, not a block.
60
+ */
61
+ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
62
+ let evidence: ToolEvidence[] = [];
63
+ let corrected = 0;
64
+ let justCorrected = false;
65
+
66
+ deps.pi.on("session_start", () => {
67
+ corrected = 0;
68
+ justCorrected = false;
69
+ evidence = [];
70
+ });
71
+ deps.pi.on("agent_start", () => {
72
+ evidence = [];
73
+ justCorrected = false;
74
+ });
75
+ deps.pi.on("tool_result", (event) => {
76
+ const text = event.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
77
+ const summary = redactSecrets(summarizeOutput(text));
78
+ const item: ToolEvidence =
79
+ event.toolName === "bash"
80
+ ? { tool: "bash", summary, exitCode: event.isError ? 1 : 0 }
81
+ : { tool: event.toolName, summary };
82
+ evidence = [...evidence, item].slice(-MAX_EVIDENCE);
83
+ });
84
+
85
+ deps.pi.on("turn_end", async (event, ctx) => {
86
+ const state = deps.state.get();
87
+ if (!VERIFIED_PHASES.has(state.phase)) return undefined;
88
+ const parts = assistantParts(event.message);
89
+ if (parts === undefined || parts.callsTools || parts.text.trim() === "") return undefined;
90
+ if (justCorrected) {
91
+ justCorrected = false;
92
+ return undefined;
93
+ }
94
+ if (asksUser(parts.text) || corrected >= (await deps.maxPerSession(ctx))) return undefined;
95
+ const jev = deps.jev(ctx);
96
+ if (jev.availability() === "offline") return undefined;
97
+ const judged = await judgeTurn(jev, {
98
+ assistantText: parts.text,
99
+ toolEvidence: evidence,
100
+ ...(state.activeSlice === undefined ? {} : { activeSlice: state.activeSlice }),
101
+ });
102
+ if (!judged.ok) return undefined;
103
+ const entries = [
104
+ ...(judged.value.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
105
+ ...(judged.value.driftFromSlice >= VERIFIER_THRESHOLD && state.activeSlice !== undefined
106
+ ? [note(driftMessage(state.activeSlice))]
107
+ : []),
108
+ ];
109
+ if (entries.length === 0) return undefined;
110
+ corrected += 1;
111
+ justCorrected = true;
112
+ return { continue: true, entries };
113
+ });
114
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ type Available,
3
+ flattenSlot,
4
+ type ModelMatrix,
5
+ matchesPattern,
6
+ resolveSlot,
7
+ type Slot,
8
+ } from "./models.ts";
9
+ import type { Phase } from "./types.ts";
10
+
11
+ /** Highest first. */
12
+ export const TIERS = ["frontier", "strong", "fast"] as const;
13
+ export type Tier = (typeof TIERS)[number];
14
+
15
+ const PHASE_SLOT: Partial<Record<Phase, Slot>> = {
16
+ planning: "planning",
17
+ implementing: "implementer",
18
+ reviewing: "reviewer",
19
+ };
20
+
21
+ export const requiredSlot = (phase: Phase): Slot | undefined => PHASE_SLOT[phase];
22
+
23
+ const split = (ref: string): { provider: string; id: string } | undefined => {
24
+ const slash = ref.indexOf("/");
25
+ return slash <= 0 || slash === ref.length - 1
26
+ ? undefined
27
+ : { provider: ref.slice(0, slash), id: ref.slice(slash + 1) };
28
+ };
29
+
30
+ const matches = (candidates: readonly string[], model: string): boolean => {
31
+ const ref = split(model);
32
+ return (
33
+ ref !== undefined &&
34
+ candidates.some((candidate) => {
35
+ const c = split(candidate);
36
+ return c !== undefined && c.provider === ref.provider && matchesPattern(c.id, ref.id);
37
+ })
38
+ );
39
+ };
40
+
41
+ /**
42
+ * The lowest tier whose candidates include `provider/id`: a model that appears as a fallback in a
43
+ * higher tier (sol in frontier) still belongs to the tier it is natively listed in. Undefined when
44
+ * the model is in none of them.
45
+ */
46
+ export function tierOf(matrix: ModelMatrix, model: string): Tier | undefined {
47
+ // Direct candidates only: `fast` falls back to `@strong`, which must not make strong models fast.
48
+ return [...TIERS].reverse().find((tier) =>
49
+ matches(
50
+ matrix[tier].filter((c) => !c.startsWith("@")),
51
+ model,
52
+ ),
53
+ );
54
+ }
55
+
56
+ export type AdviceInput = {
57
+ matrix: ModelMatrix;
58
+ available: readonly Available[];
59
+ phase: Phase;
60
+ /** The coordinator's current model as `provider/id`. */
61
+ model: string;
62
+ };
63
+
64
+ /**
65
+ * One recommendation when the coordinator's tier is below what the phase's slot resolves to; never
66
+ * a model switch. Silent when the phase has no slot, the slot cannot be resolved here, or either
67
+ * model is outside the three tiers (nothing to compare).
68
+ */
69
+ export function adviseModel(input: AdviceInput): string | undefined {
70
+ const slot = requiredSlot(input.phase);
71
+ if (slot === undefined) return undefined;
72
+ const wanted = resolveSlot(input.matrix, slot, input.available);
73
+ if (!wanted.ok) return undefined;
74
+ // Anything the slot itself lists is acceptable for the phase, whatever tier it natively sits in.
75
+ if (matches(flattenSlot(input.matrix, slot), input.model)) return undefined;
76
+ const have = tierOf(input.matrix, input.model);
77
+ const need = tierOf(input.matrix, wanted.value.model);
78
+ if (have === undefined || need === undefined) return undefined;
79
+ if (TIERS.indexOf(have) <= TIERS.indexOf(need)) return undefined;
80
+ return `${input.phase} on ${input.model} (${have} tier) — the matrix prefers ${wanted.value.model} (${need} tier) for the ${slot} slot. Consider asking an advisor subagent for the hard decisions, or continue and record a departure (gate models.phase-mismatch). The system never switches your model for you.`;
81
+ }
@@ -80,7 +80,7 @@ export function compareModelVersions(a: string, b: string): number {
80
80
  }
81
81
 
82
82
  /** `*`-only glob over a model id (no regular expressions built from configuration). */
83
- function matchesPattern(pattern: string, id: string): boolean {
83
+ export function matchesPattern(pattern: string, id: string): boolean {
84
84
  const parts = pattern.split("*");
85
85
  if (parts.length === 1) return pattern === id;
86
86
  const first = parts[0] ?? "";
@@ -0,0 +1,81 @@
1
+ import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import type { Jev, JevError } from "../client.ts";
5
+
6
+ export const CLAIM_QUESTION: ClassifierBoolQuestion = {
7
+ type: "bool",
8
+ instructions:
9
+ '`assistantText` is the last message an AI coding agent wrote. `toolEvidence` lists the tool calls it made this turn (tool, summary, exit code). Does the message state as fact that something was run, passed, fixed, built, committed, pushed or verified, where `toolEvidence` does not show it? A claim that is plainly about a past turn, a plan, an intention, a question or a hedge ("I will run", "should pass") is not a claim of fact.',
10
+ criteria: {
11
+ true: "It asserts a completed action or a passing result that no tool call this turn supports (or that a failing tool call contradicts)",
12
+ false:
13
+ "Every stated result is backed by the tool evidence, or the message only plans, asks or hedges",
14
+ },
15
+ };
16
+
17
+ export const DRIFT_QUESTION: ClassifierBoolQuestion = {
18
+ type: "bool",
19
+ instructions:
20
+ "`activeSlice` names the one slice of work the agent agreed to do. Given `assistantText` and `toolEvidence`, is the agent doing substantial work that the slice does not cover: new features, extra refactors, unrelated files, or changing scope? Housekeeping needed to do the slice (tests, docs for it, commits) is not drift.",
21
+ criteria: {
22
+ true: "The agent is doing or proposing work beyond the slice's stated scope",
23
+ false: "The work stays within the slice, or is the housekeeping it needs",
24
+ },
25
+ };
26
+
27
+ export type ToolEvidence = { tool: string; summary: string; exitCode?: number };
28
+ export type TurnInput = {
29
+ assistantText: string;
30
+ toolEvidence: readonly ToolEvidence[];
31
+ activeSlice?: string;
32
+ };
33
+ export type TurnJudgement = { unverifiedClaim: number; driftFromSlice: number };
34
+
35
+ const TEXT_MAX = 4000;
36
+ const SUMMARY_MAX = 240;
37
+ const EVIDENCE_MAX = 30;
38
+
39
+ const clip = (text: string, max: number): string =>
40
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
41
+
42
+ const evidenceLine = (e: ToolEvidence): string =>
43
+ `${clip(e.tool, 40)}${e.exitCode === undefined ? "" : ` exit ${e.exitCode}`}: ${clip(e.summary, SUMMARY_MAX)}`;
44
+
45
+ const probability = (
46
+ answer: { type: string; probability?: number } | undefined,
47
+ name: string,
48
+ ): Result<number, JevError> => {
49
+ if (answer?.type !== "bool" || typeof answer.probability !== "number") {
50
+ return err({ kind: "provider", message: `missing ${name} answer` });
51
+ }
52
+ return Number.isFinite(answer.probability)
53
+ ? ok(answer.probability)
54
+ : err({ kind: "provider", message: `malformed ${name} answer` });
55
+ };
56
+
57
+ /** Two independent narrow questions; drift is only asked when a slice is active. */
58
+ export async function judgeTurn(
59
+ jev: Jev,
60
+ input: TurnInput,
61
+ ): Promise<Result<TurnJudgement, JevError>> {
62
+ const slice = input.activeSlice === undefined ? undefined : clip(input.activeSlice, 200);
63
+ const state = {
64
+ assistantText: clip(input.assistantText, TEXT_MAX),
65
+ toolEvidence: input.toolEvidence.slice(-EVIDENCE_MAX).map(evidenceLine),
66
+ ...(slice === undefined ? {} : { activeSlice: slice }),
67
+ };
68
+ const asked = await jev.ask(
69
+ state,
70
+ slice === undefined
71
+ ? { claim: CLAIM_QUESTION }
72
+ : { claim: CLAIM_QUESTION, drift: DRIFT_QUESTION },
73
+ );
74
+ if (!asked.ok) return asked;
75
+ const claim = probability(asked.value.claim, "claim");
76
+ if (!claim.ok) return claim;
77
+ if (slice === undefined) return ok({ unverifiedClaim: claim.value, driftFromSlice: 0 });
78
+ const drift = probability(asked.value.drift, "drift");
79
+ if (!drift.ok) return drift;
80
+ return ok({ unverifiedClaim: claim.value, driftFromSlice: drift.value });
81
+ }