pi-advisor-flow 0.6.0 → 0.7.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,234 @@
1
+ import { formatTokenCount } from "../usage.ts";
2
+
3
+ export interface AdvisorJevUsageTotals {
4
+ cost: number;
5
+ inputTokens: number;
6
+ outputTokens: number;
7
+ }
8
+
9
+ export interface AdvisorJevFilterLedger {
10
+ allowed: number;
11
+ failures: number;
12
+ overrides: number;
13
+ repeatSkipped: number;
14
+ screened: number;
15
+ skipped: number;
16
+ }
17
+
18
+ export interface AdvisorJevGateLedger {
19
+ checks: number;
20
+ consultations: number;
21
+ failures: number;
22
+ usage: AdvisorJevUsageTotals;
23
+ }
24
+
25
+ export interface AdvisorJevLedger {
26
+ filter: AdvisorJevFilterLedger;
27
+ gate: AdvisorJevGateLedger;
28
+ usage: AdvisorJevUsageTotals;
29
+ }
30
+
31
+ export interface AdvisorJevUsage {
32
+ cost: number;
33
+ inputTokens: number;
34
+ outputTokens: number;
35
+ }
36
+
37
+ export interface JevSkipRecord {
38
+ normalizedQuestion?: string;
39
+ turn: number;
40
+ }
41
+
42
+ /** Invocation fields the Jev summary reads; satisfied by AdvisorInvocationRecord. */
43
+ export interface JevInvocationView {
44
+ cost?: number;
45
+ kind: string;
46
+ trigger: string;
47
+ }
48
+
49
+ const freshJevUsage = (): AdvisorJevUsageTotals => ({
50
+ cost: 0,
51
+ inputTokens: 0,
52
+ outputTokens: 0,
53
+ });
54
+
55
+ const freshJevLedger = (): AdvisorJevLedger => ({
56
+ filter: {
57
+ allowed: 0,
58
+ failures: 0,
59
+ overrides: 0,
60
+ repeatSkipped: 0,
61
+ screened: 0,
62
+ skipped: 0,
63
+ },
64
+ gate: { checks: 0, consultations: 0, failures: 0, usage: freshJevUsage() },
65
+ usage: freshJevUsage(),
66
+ });
67
+
68
+ const addJevUsage = (totals: AdvisorJevUsageTotals, usage: AdvisorJevUsage) => {
69
+ totals.cost += usage.cost;
70
+ totals.inputTokens += usage.inputTokens;
71
+ totals.outputTokens += usage.outputTokens;
72
+ };
73
+
74
+ /** One session's Jev screening and turn-gate accounting plus summary lines. */
75
+ export class AdvisorJevLedgerState {
76
+ #ledger = freshJevLedger();
77
+ #lastSkip: JevSkipRecord | undefined;
78
+
79
+ reset() {
80
+ this.#ledger = freshJevLedger();
81
+ this.#lastSkip = undefined;
82
+ }
83
+
84
+ get lastSkip() {
85
+ return this.#lastSkip;
86
+ }
87
+
88
+ recordFilterAllowed() {
89
+ this.#ledger.filter.allowed += 1;
90
+ this.#ledger.filter.screened += 1;
91
+ }
92
+
93
+ recordFilterSkipped(
94
+ repeat: boolean,
95
+ normalizedQuestion: string | undefined,
96
+ turn: number
97
+ ) {
98
+ this.#ledger.filter.skipped += 1;
99
+ this.#ledger.filter.screened += 1;
100
+ if (repeat) {
101
+ this.#ledger.filter.repeatSkipped += 1;
102
+ }
103
+ this.#lastSkip = {
104
+ ...(normalizedQuestion ? { normalizedQuestion } : {}),
105
+ turn,
106
+ };
107
+ }
108
+
109
+ recordFilterOverride() {
110
+ this.#ledger.filter.overrides += 1;
111
+ }
112
+
113
+ recordFilterFailure() {
114
+ this.#ledger.filter.failures += 1;
115
+ }
116
+
117
+ recordFilterUsage(usage: AdvisorJevUsage) {
118
+ addJevUsage(this.#ledger.usage, usage);
119
+ }
120
+
121
+ recordGateCheck(usage?: AdvisorJevUsage) {
122
+ this.#ledger.gate.checks += 1;
123
+ if (usage) {
124
+ addJevUsage(this.#ledger.gate.usage, usage);
125
+ }
126
+ }
127
+
128
+ recordGateConsultation() {
129
+ this.#ledger.gate.consultations += 1;
130
+ }
131
+
132
+ recordGateFailure() {
133
+ this.#ledger.gate.failures += 1;
134
+ }
135
+
136
+ summaryLines(invocations: readonly JevInvocationView[]): string[] {
137
+ const lines: string[] = [];
138
+ const { filter, gate, usage } = this.#ledger;
139
+ const nonRepeatJevActivity =
140
+ filter.allowed +
141
+ (filter.skipped - filter.repeatSkipped) +
142
+ filter.failures;
143
+ if (nonRepeatJevActivity === 0 && filter.repeatSkipped > 0) {
144
+ // Only dedup fired — no Jev call ever happened, so the line must not
145
+ // claim Jev activity.
146
+ const parts = [
147
+ `${filter.repeatSkipped} repeat question${filter.repeatSkipped === 1 ? "" : "s"} skipped, earlier advice reattached`,
148
+ ];
149
+ if (filter.overrides > 0) {
150
+ parts.push(
151
+ `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
152
+ );
153
+ }
154
+ lines.push(`Consultation dedup: ${parts.join(", ")}`);
155
+ lines.push(
156
+ this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
157
+ );
158
+ } else if (this.#filterActive()) {
159
+ lines.push(this.#filterLine(filter));
160
+ const jevTokens = usage.inputTokens + usage.outputTokens;
161
+ if (jevTokens > 0) {
162
+ lines.push(
163
+ `Jev cost: ${this.#formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
164
+ );
165
+ }
166
+ if (filter.skipped > 0) {
167
+ lines.push(
168
+ this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
169
+ );
170
+ }
171
+ }
172
+ if (gate.checks > 0 || gate.consultations > 0) {
173
+ lines.push(this.#gateLine(gate, invocations));
174
+ }
175
+ return lines;
176
+ }
177
+
178
+ #filterActive() {
179
+ const { filter } = this.#ledger;
180
+ return filter.screened > 0 || filter.overrides > 0 || filter.failures > 0;
181
+ }
182
+
183
+ #savingsLine(markdownCosts: number[], skipped: number) {
184
+ if (markdownCosts.length === 0) {
185
+ return "Estimated saving from skips: unavailable — no observed consultation cost this session";
186
+ }
187
+ const mean =
188
+ markdownCosts.reduce((sum, cost) => sum + cost, 0) / markdownCosts.length;
189
+ return `Estimated saving from skips: ≤ $${(mean * skipped).toFixed(4)} — upper bound; assumes each skipped consultation would have cost this session's mean allowed-consultation cost ($${mean.toFixed(4)}), which the skipped calls would likely have undercut`;
190
+ }
191
+
192
+ #gateLine(
193
+ gate: AdvisorJevGateLedger,
194
+ invocations: readonly JevInvocationView[]
195
+ ) {
196
+ const consultationCosts = invocations
197
+ .filter(
198
+ (item): item is JevInvocationView & { cost: number } =>
199
+ item.trigger === "turn-gate" && typeof item.cost === "number"
200
+ )
201
+ .map((item) => item.cost);
202
+ const gateSpend = consultationCosts.reduce((sum, cost) => sum + cost, 0);
203
+ return `Turn gate: ${gate.checks} check${gate.checks === 1 ? "" : "s"} (Jev ${this.#formatJevTokens(gate.usage)} · $${gate.usage.cost.toFixed(4)}), ${gate.consultations} consultation${gate.consultations === 1 ? "" : "s"} ($${gateSpend.toFixed(4)})`;
204
+ }
205
+
206
+ #formatJevTokens(usage: AdvisorJevUsageTotals) {
207
+ return `↑${formatTokenCount(usage.inputTokens + usage.outputTokens)}`;
208
+ }
209
+
210
+ #markdownCosts(invocations: readonly JevInvocationView[]): number[] {
211
+ return invocations
212
+ .filter(
213
+ (item): item is JevInvocationView & { cost: number } =>
214
+ item.kind === "markdown" && typeof item.cost === "number"
215
+ )
216
+ .map((item) => item.cost);
217
+ }
218
+
219
+ #filterLine(filter: AdvisorJevFilterLedger) {
220
+ const head = `${filter.screened} screened (${filter.allowed} allowed, ${filter.skipped} skipped${filter.repeatSkipped > 0 ? ` [${filter.repeatSkipped} repeat]` : ""})`;
221
+ const parts = [head];
222
+ if (filter.overrides > 0) {
223
+ parts.push(
224
+ `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
225
+ );
226
+ }
227
+ if (filter.failures > 0) {
228
+ parts.push(
229
+ `${filter.failures} failure${filter.failures === 1 ? "" : "s"}`
230
+ );
231
+ }
232
+ return `Jev filter: ${parts.join(", ")}`;
233
+ }
234
+ }
@@ -0,0 +1,119 @@
1
+ import type { Questions } from "@typesafe-ai/sdk";
2
+
3
+ /** Exact rubric text for the stakes Score question; level 0 is the skip level. */
4
+ export const STAKES_RUBRIC = [
5
+ "Negligible: routine, low-risk, mechanical, or reversible; a wrong call costs little and is easy to undo.",
6
+ "Moderate: some risk or rework, but bounded and recoverable.",
7
+ "High: material consequences for correctness, security, cost, user trust, or irreversibility.",
8
+ ] as const;
9
+
10
+ const EVIDENCE_RULE =
11
+ "Judge from `executor_question` and `executor_draft` when present, otherwise from `recent_conversation`; when both are absent, `recent_conversation` is the evidence to judge from.";
12
+
13
+ export const screeningQuestions: Questions = {
14
+ self_answerable: {
15
+ criteria: {
16
+ false: "The executor needs the Advisor's second opinion.",
17
+ true: "The executor can resolve this alone with available tools and context.",
18
+ },
19
+ instructions: `Can the executor confidently resolve this request alone, using available tools and context? ${EVIDENCE_RULE}`,
20
+ type: "noul",
21
+ },
22
+ stakes: {
23
+ criteria: [...STAKES_RUBRIC],
24
+ instructions: `How material are the stakes of the decision behind this consultation request? ${EVIDENCE_RULE}`,
25
+ type: "score",
26
+ },
27
+ };
28
+
29
+ export interface TurnGateQuestions {
30
+ instructions: string;
31
+ }
32
+
33
+ export interface ScreeningCriteria {
34
+ noulMargin: number;
35
+ skipConfidence: number;
36
+ }
37
+
38
+ export type ScreeningVerdict = { skip: false } | { skip: true };
39
+
40
+ const NUMERIC_KEY_PATTERN = /^\d+$/;
41
+
42
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
43
+ Boolean(value) && typeof value === "object" && !Array.isArray(value);
44
+
45
+ const finiteNumber = (value: unknown): number | undefined =>
46
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
47
+
48
+ /**
49
+ * Probability mass on the lowest stakes level, resolved through the answer's
50
+ * legend (the key whose description equals level 0), falling back to the
51
+ * smallest numeric index key only when no legend resolves. Shape-independent:
52
+ * never hardcodes a level key and never uses `ceil(score)`.
53
+ */
54
+ export const lowestStakesProbability = (
55
+ answer: unknown
56
+ ): number | undefined => {
57
+ if (!isRecord(answer)) {
58
+ return undefined;
59
+ }
60
+ const probabilities = isRecord(answer.probabilities)
61
+ ? answer.probabilities
62
+ : {};
63
+ const legend = isRecord(answer.legend) ? answer.legend : undefined;
64
+ if (legend) {
65
+ const exact = Object.keys(legend).find(
66
+ (key) => legend[key] === STAKES_RUBRIC[0]
67
+ );
68
+ if (exact) {
69
+ return finiteNumber(probabilities[exact]);
70
+ }
71
+ }
72
+ const numericKeys = Object.keys(probabilities).filter((key) =>
73
+ NUMERIC_KEY_PATTERN.test(key)
74
+ );
75
+ if (numericKeys.length === 0) {
76
+ return undefined;
77
+ }
78
+ const lowest = numericKeys.reduce((left, right) =>
79
+ Number(left) <= Number(right) ? left : right
80
+ );
81
+ return finiteNumber(probabilities[lowest]);
82
+ };
83
+
84
+ const selfAnswerableNoul = (answer: unknown): number | undefined =>
85
+ isRecord(answer) ? finiteNumber(answer.noul) : undefined;
86
+
87
+ /** Pure composition: skip requires the hard conjunction of confident
88
+ * negligible stakes AND confident self-answerability. Any missing, NaN, or
89
+ * malformed input allows. Never a weighted sum. */
90
+ export const composeScreeningVerdict = (
91
+ answers: unknown,
92
+ { noulMargin, skipConfidence }: ScreeningCriteria
93
+ ): ScreeningVerdict => {
94
+ if (!isRecord(answers)) {
95
+ return { skip: false };
96
+ }
97
+ const negligibleMass = lowestStakesProbability(answers.stakes);
98
+ const noul = selfAnswerableNoul(answers.self_answerable);
99
+ if (negligibleMass === undefined || noul === undefined) {
100
+ return { skip: false };
101
+ }
102
+ const confidentlySelfAnswerable = noul >= 0.5 + noulMargin;
103
+ return {
104
+ skip: negligibleMass >= skipConfidence && confidentlySelfAnswerable,
105
+ };
106
+ };
107
+
108
+ /** Confident-true only; any uncertainty means no invocation. */
109
+ export const composeTurnGateVerdict = (
110
+ answers: unknown,
111
+ threshold: number
112
+ ): boolean => {
113
+ if (!isRecord(answers)) {
114
+ return false;
115
+ }
116
+ const answer = answers.should_consult;
117
+ const noul = isRecord(answer) ? finiteNumber(answer.noul) : undefined;
118
+ return noul !== undefined && noul >= threshold;
119
+ };
@@ -0,0 +1,47 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { JsonValue } from "@typesafe-ai/sdk";
3
+ import {
4
+ advisorJevDigestMaxCharsRef,
5
+ advisorRedactSecretsRef,
6
+ } from "../config/state.ts";
7
+ import { recentConversation } from "../conversation.ts";
8
+ import { redactAndCapText } from "../redaction.ts";
9
+
10
+ /** Per-field byte cap for executor question and draft sent to Jev. */
11
+ export const JEV_TEXT_CAP_BYTES = 8 * 1024;
12
+
13
+ export interface JevStateInput {
14
+ draft?: string;
15
+ question?: string;
16
+ }
17
+
18
+ export type JevState = Record<string, JsonValue>;
19
+
20
+ /** Builds the Jev screening state: named fields, redacted and capped through
21
+ * the existing egress pipeline, with the conversation digest as fallback
22
+ * evidence when no explicit request exists. */
23
+ export const buildJevState = (
24
+ ctx: ExtensionContext,
25
+ input: JevStateInput = {}
26
+ ): JevState => {
27
+ const state: JevState = { role: "executor" };
28
+ if (input.question) {
29
+ state.executor_question = redactAndCapText(
30
+ input.question,
31
+ JEV_TEXT_CAP_BYTES,
32
+ advisorRedactSecretsRef
33
+ );
34
+ }
35
+ if (input.draft) {
36
+ state.executor_draft = redactAndCapText(
37
+ input.draft,
38
+ JEV_TEXT_CAP_BYTES,
39
+ advisorRedactSecretsRef
40
+ );
41
+ }
42
+ const digest = recentConversation(ctx, advisorJevDigestMaxCharsRef);
43
+ if (digest) {
44
+ state.recent_conversation = digest;
45
+ }
46
+ return state;
47
+ };
@@ -0,0 +1,58 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { advisorJevTransportRef } from "../config/state.ts";
3
+ import { type JevKeySource, resolveTypeSafeKey } from "./key-store.ts";
4
+
5
+ export type JevTransportKind = "typesafe" | "openrouter";
6
+
7
+ export interface JevCredentials {
8
+ apiKey: string;
9
+ /** Where the TypeSafe key came from; present on the typesafe transport. */
10
+ source?: JevKeySource;
11
+ transport: JevTransportKind;
12
+ }
13
+
14
+ export interface JevTransportDeps {
15
+ /** Reads a pi-stored provider login; injectable for tests. */
16
+ getProviderKey?: (provider: string) => Promise<string | undefined>;
17
+ /** Resolves the TypeSafe key chain; injectable for tests. */
18
+ resolveTypesafe?: () => ReturnType<typeof resolveTypeSafeKey>;
19
+ }
20
+
21
+ const OPENROUTER_PROVIDER = "openrouter";
22
+
23
+ const openRouterKey = async (
24
+ ctx: ExtensionContext | undefined,
25
+ deps: JevTransportDeps
26
+ ): Promise<string | undefined> => {
27
+ const key = deps.getProviderKey
28
+ ? await deps.getProviderKey(OPENROUTER_PROVIDER)
29
+ : await ctx?.modelRegistry?.getApiKeyForProvider(OPENROUTER_PROVIDER);
30
+ return key?.trim() || undefined;
31
+ };
32
+
33
+ /** Resolves how Jev calls authenticate: a dedicated TypeSafe key first, then
34
+ * the existing pi OpenRouter login (reuse per advisorJevTransport). */
35
+ export const resolveJevTransport = async (
36
+ ctx?: ExtensionContext,
37
+ deps: JevTransportDeps = {}
38
+ ): Promise<JevCredentials | undefined> => {
39
+ const preference = advisorJevTransportRef;
40
+ if (preference !== "openrouter") {
41
+ const resolveTypesafe = deps.resolveTypesafe ?? resolveTypeSafeKey;
42
+ const resolution = await resolveTypesafe();
43
+ if (resolution.key) {
44
+ return {
45
+ apiKey: resolution.key,
46
+ ...(resolution.source ? { source: resolution.source } : {}),
47
+ transport: "typesafe",
48
+ };
49
+ }
50
+ }
51
+ if (preference === "typesafe") {
52
+ return undefined;
53
+ }
54
+ const openrouter = await openRouterKey(ctx, deps);
55
+ return openrouter
56
+ ? { apiKey: openrouter, transport: "openrouter" }
57
+ : undefined;
58
+ };
package/src/outcomes.ts CHANGED
@@ -15,7 +15,11 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
 
16
16
  export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
17
17
  export type OutcomeValidation = "passed" | "failed" | "not-run" | "unknown";
18
- type OutcomeTrigger = "manual" | "executor-requested" | "repeated-tool-call";
18
+ type OutcomeTrigger =
19
+ | "manual"
20
+ | "executor-requested"
21
+ | "turn-gate"
22
+ | "repeated-tool-call";
19
23
  export const ADOPTIONS: OutcomeAdoption[] = [
20
24
  "followed",
21
25
  "not-followed",
@@ -1,3 +1,4 @@
1
+ import { AdvisorJevLedgerState, type AdvisorJevUsage } from "./jev/ledger.ts";
1
2
  import {
2
3
  type AdvisorUsageTotals,
3
4
  addAdvisorUsage,
@@ -7,7 +8,7 @@ import {
7
8
  } from "./usage.ts";
8
9
 
9
10
  export type GateDecision = "proceed" | "revise" | "blocked";
10
- export type ConsultationTrigger = "manual" | "executor-requested";
11
+ export type ConsultationTrigger = "manual" | "executor-requested" | "turn-gate";
11
12
  export type GateTrigger =
12
13
  | "repeated-tool-call"
13
14
  | "completion-review"
@@ -121,7 +122,14 @@ interface RepetitionState {
121
122
 
122
123
  interface AdviceLedger {
123
124
  draftConsultations: number;
124
- issued: Map<string, { advice: string; trigger: ConsultationTrigger }>;
125
+ issued: Map<
126
+ string,
127
+ {
128
+ advice: string;
129
+ normalizedQuestion?: string;
130
+ trigger: ConsultationTrigger;
131
+ }
132
+ >;
125
133
  lastAdvice?: string;
126
134
  outcomes: number;
127
135
  pending: Set<string>;
@@ -155,6 +163,9 @@ export class AdvisorSessionState {
155
163
  #ledger = freshAdviceLedger();
156
164
  #usage = freshUsage();
157
165
  #consumedCalls = 0;
166
+ readonly #jev = new AdvisorJevLedgerState();
167
+ #sessionTurnOrdinal = 0;
168
+ #turnsSinceConsultation = 0;
158
169
 
159
170
  resetTask() {
160
171
  this.#repetition = freshRepetition();
@@ -162,6 +173,9 @@ export class AdvisorSessionState {
162
173
  this.#ledger = freshAdviceLedger();
163
174
  this.#usage = freshUsage();
164
175
  this.#consumedCalls = 0;
176
+ this.#jev.reset();
177
+ this.#sessionTurnOrdinal = 0;
178
+ this.#turnsSinceConsultation = 0;
165
179
  }
166
180
 
167
181
  clearBlocked() {
@@ -214,6 +228,26 @@ export class AdvisorSessionState {
214
228
  return this.#consumedCalls;
215
229
  }
216
230
 
231
+ /** Counts one completed executor turn on both turn counters. The ordinal
232
+ * feeds the filter override window and summary positions and is never reset
233
+ * by anything; turnsSinceConsultation resets on every consultation. */
234
+ recordCompletedTurn() {
235
+ this.#sessionTurnOrdinal += 1;
236
+ this.#turnsSinceConsultation += 1;
237
+ }
238
+
239
+ resetTurnsSinceConsultation() {
240
+ this.#turnsSinceConsultation = 0;
241
+ }
242
+
243
+ get sessionTurnOrdinal() {
244
+ return this.#sessionTurnOrdinal;
245
+ }
246
+
247
+ get turnsSinceConsultation() {
248
+ return this.#turnsSinceConsultation;
249
+ }
250
+
217
251
  /** Returns a copy of cumulative direct Advisor usage for this session. */
218
252
  get usageTotals(): AdvisorUsageTotals {
219
253
  return { ...this.#usage.totals };
@@ -232,14 +266,34 @@ export class AdvisorSessionState {
232
266
  id: string,
233
267
  advice: string,
234
268
  trigger: ConsultationTrigger,
235
- draft = false
269
+ draft = false,
270
+ normalizedQuestion?: string
236
271
  ) {
237
- this.#ledger.issued.set(id, { advice, trigger });
272
+ this.#ledger.issued.set(id, {
273
+ advice,
274
+ ...(normalizedQuestion ? { normalizedQuestion } : {}),
275
+ trigger,
276
+ });
238
277
  this.#ledger.lastAdvice = advice;
239
278
  if (draft) {
240
279
  this.#ledger.draftConsultations += 1;
241
280
  }
242
281
  }
282
+
283
+ /** Returns earlier advice for an exactly-matching normalized question. */
284
+ reattachedAdviceFor(
285
+ normalizedQuestion: string | undefined
286
+ ): string | undefined {
287
+ if (!normalizedQuestion) {
288
+ return undefined;
289
+ }
290
+ for (const entry of this.#ledger.issued.values()) {
291
+ if (entry.normalizedQuestion === normalizedQuestion) {
292
+ return entry.advice;
293
+ }
294
+ }
295
+ return undefined;
296
+ }
243
297
  claimTrackedFiles(paths: string[]) {
244
298
  const advice = this.#ledger.lastAdvice;
245
299
  if (!advice || paths.length === 0) {
@@ -324,6 +378,7 @@ export class AdvisorSessionState {
324
378
  [
325
379
  "manual",
326
380
  "executor-requested",
381
+ "turn-gate",
327
382
  "repeated-tool-call",
328
383
  "completion-review",
329
384
  "custom-rule",
@@ -333,9 +388,47 @@ export class AdvisorSessionState {
333
388
  );
334
389
  }
335
390
 
391
+ recordJevFilterAllowed() {
392
+ this.#jev.recordFilterAllowed();
393
+ }
394
+ recordJevFilterSkipped(repeat: boolean, normalizedQuestion?: string) {
395
+ this.#jev.recordFilterSkipped(
396
+ repeat,
397
+ normalizedQuestion,
398
+ this.#sessionTurnOrdinal
399
+ );
400
+ }
401
+ get lastJevSkip() {
402
+ return this.#jev.lastSkip;
403
+ }
404
+ recordJevFilterOverride() {
405
+ this.#jev.recordFilterOverride();
406
+ }
407
+ recordJevFilterFailure() {
408
+ this.#jev.recordFilterFailure();
409
+ }
410
+ recordJevFilterUsage(usage: AdvisorJevUsage) {
411
+ this.#jev.recordFilterUsage(usage);
412
+ }
413
+
414
+ recordJevGateCheck(usage?: AdvisorJevUsage) {
415
+ this.#jev.recordGateCheck(usage);
416
+ }
417
+ recordJevGateConsultation() {
418
+ this.#jev.recordGateConsultation();
419
+ }
420
+ recordJevGateFailure() {
421
+ this.#jev.recordGateFailure();
422
+ }
423
+
336
424
  summary(limit: number | undefined) {
337
425
  const { invocations, totals } = this.#usage;
338
- if (invocations.length === 0 && this.#repetition.interventions === 0) {
426
+ const jevLines = this.#jev.summaryLines(invocations);
427
+ if (
428
+ invocations.length === 0 &&
429
+ this.#repetition.interventions === 0 &&
430
+ jevLines.length === 0
431
+ ) {
339
432
  return;
340
433
  }
341
434
  const markdown = invocations.filter((item) => item.kind === "markdown");
@@ -366,6 +459,7 @@ export class AdvisorSessionState {
366
459
  `Loop matching: normalized tool signatures; ${this.#repetition.interventions} gate intervention${this.#repetition.interventions === 1 ? "" : "s"}`,
367
460
  `Execution effects: ${effects("tool-blocked")} tool blocked, ${effects("session-blocked")} sessions blocked, ${effects("continued")} continued`,
368
461
  `Failures: ${failures.length ? failures.join(", ") : "none"}`,
462
+ ...jevLines,
369
463
  ].join("\n");
370
464
  }
371
465
  }