pi-advisor-flow 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.
Files changed (93) hide show
  1. package/CHANGELOG.md +34 -17
  2. package/README.md +10 -8
  3. package/dist/index.js +2852 -868
  4. package/extensions/index.ts +2 -1
  5. package/package.json +55 -40
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +103 -79
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +64 -2
  21. package/src/commands/types.ts +2 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +83 -1
  25. package/src/config/schema.ts +152 -15
  26. package/src/config/state.ts +80 -2
  27. package/src/config/storage.ts +38 -22
  28. package/src/config/types.ts +45 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +338 -0
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +267 -0
  40. package/src/jev/ledger.ts +228 -0
  41. package/src/jev/questions.ts +122 -0
  42. package/src/jev/state.ts +50 -0
  43. package/src/jev/transport.ts +63 -0
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +20 -12
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +148 -39
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +204 -0
  62. package/src/tools/jev-turn-gate.ts +203 -0
  63. package/src/tools/loop-gate.ts +32 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/outage-notifier.ts +30 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +73 -17
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +149 -73
  71. package/src/tools/registration.ts +15 -0
  72. package/src/tools/render-advisor-result.ts +77 -35
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +11 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +350 -0
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +62 -0
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +200 -2
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +169 -47
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +25 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +29 -20
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import type { Message } from "@earendil-works/pi-ai/compat";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
4
6
  import {
5
7
  advisorEffortRef,
6
8
  advisorRedactSecretsRef,
@@ -14,6 +16,7 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
14
16
  import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
15
17
  import { assembleConsultationContext } from "./consult-context.ts";
16
18
  import { parseAutomaticDecision } from "./gate-protocol.ts";
19
+ import { advisorModelAccessReason } from "./model-access.ts";
17
20
  import {
18
21
  ADVISOR_DECISION_SYSTEM,
19
22
  ADVISOR_SYSTEM,
@@ -21,7 +24,7 @@ import {
21
24
  } from "./prompts.ts";
22
25
  import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.ts";
23
26
 
24
- // biome-ignore lint/performance/noBarrelFile: preserves the tools facade's historical re-export of the moved curation entry point.
27
+ // Re-export preserves the tools facade's historical curation entry point.
25
28
  export { curateAdvisorConversation } from "../scout-curation.ts";
26
29
 
27
30
  /** Thrown when the Advisor produced an empty response body. */
@@ -54,6 +57,10 @@ const collectAdvisorResponse = async (
54
57
  ) => {
55
58
  const { ctx, question, signal, systemPrompt } = options;
56
59
  loadConfig(ctx);
60
+ const accessReason = advisorModelAccessReason(ctx);
61
+ if (accessReason) {
62
+ throw new Error(accessReason);
63
+ }
57
64
  const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
58
65
  const context = await assembleConsultationContext(options);
59
66
 
@@ -95,9 +102,9 @@ const collectAdvisorResponse = async (
95
102
  if (!markdown.trim()) {
96
103
  throw new AdvisorNoAdviceError();
97
104
  }
98
- return {
105
+ const response: Omit<AdvisorConsultationResult, "adviceId" | "trigger"> = {
99
106
  draftBytes: context.draftText
100
- ? Buffer.byteLength(context.draftText, "utf8")
107
+ ? Buffer.byteLength(context.draftText, "utf-8")
101
108
  : undefined,
102
109
  markdown,
103
110
  model: advisorRef,
@@ -108,8 +115,11 @@ const collectAdvisorResponse = async (
108
115
  untrackedBytes:
109
116
  context.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
110
117
  usage: streamed.usage,
111
- ...(context.scout ? { scout: context.scout } : {}),
112
118
  };
119
+ if (context.scout) {
120
+ response.scout = context.scout;
121
+ }
122
+ return response;
113
123
  };
114
124
 
115
125
  export const consultAdvisor = async (
@@ -3,6 +3,7 @@ import type {
3
3
  ToolCallEvent,
4
4
  ToolCallEventResult,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+
6
7
  import {
7
8
  advisorBlockOnBlockedRef,
8
9
  getAdvisorMaxCallsPerSession,
@@ -1,20 +1,30 @@
1
1
  import type { GateDecision } from "../session-state.ts";
2
2
  import type { AdvisorGateFailure, AdvisorGateResult } from "./types.ts";
3
3
 
4
- const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
5
- const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
6
- const LINE_BREAK = /\r?\n/;
4
+ /** Named-group accessor; exec matches of these regexes always populate groups. */
5
+ const namedGroups = (match: RegExpExecArray): Record<string, string> =>
6
+ match.groups ?? {};
7
+
8
+ const DECISION_LINE =
9
+ /^Decision\s*:\s*(?<decision>proceed|revise|blocked)\s*$/iu;
10
+ const CODE_FENCE = /^ {0,3}(?<marker>`{3,}|~{3,})(?<suffix>.*)$/u;
11
+ const LINE_BREAK = /\r?\n/u;
7
12
 
8
13
  interface FenceState {
9
14
  character: string;
10
15
  length: number;
11
16
  }
12
17
 
18
+ interface FenceAdvance {
19
+ closed: boolean;
20
+ openingFence: FenceState | undefined;
21
+ }
22
+
13
23
  const advanceFence = (
14
24
  openingFence: FenceState | undefined,
15
25
  marker: string,
16
26
  suffix: string
17
- ): { closed: boolean; openingFence: FenceState | undefined } => {
27
+ ): FenceAdvance => {
18
28
  if (!openingFence) {
19
29
  if (marker[0] === "`" && suffix.includes("`")) {
20
30
  return { closed: false, openingFence: undefined };
@@ -59,7 +69,8 @@ export const parseAutomaticDecision = (
59
69
  ok: false,
60
70
  };
61
71
  }
62
- const decision = match[1].toLowerCase() as GateDecision;
72
+ // SAFETY: DECISION_LINE's decision group only matches proceed, revise, or blocked.
73
+ const decision = namedGroups(match).decision.toLowerCase() as GateDecision;
63
74
  let openingFence: FenceState | undefined;
64
75
  const decisions: string[] = [];
65
76
  let pendingFencedDecisions: string[] = [];
@@ -72,10 +83,11 @@ export const parseAutomaticDecision = (
72
83
  // open.
73
84
  const fence = CODE_FENCE.exec(line);
74
85
  if (fence) {
86
+ const groups = namedGroups(fence);
75
87
  const { closed, openingFence: nextOpeningFence } = advanceFence(
76
88
  openingFence,
77
- fence[1],
78
- fence[2]
89
+ groups.marker,
90
+ groups.suffix
79
91
  );
80
92
  openingFence = nextOpeningFence;
81
93
  if (closed) {
@@ -87,7 +99,7 @@ export const parseAutomaticDecision = (
87
99
  if (!subsequent) {
88
100
  continue;
89
101
  }
90
- const repeated = subsequent[1].trim().toLowerCase();
102
+ const repeated = namedGroups(subsequent).decision.trim().toLowerCase();
91
103
  if (openingFence) {
92
104
  pendingFencedDecisions.push(repeated);
93
105
  } else {
@@ -97,21 +109,22 @@ export const parseAutomaticDecision = (
97
109
  if (openingFence) {
98
110
  decisions.push(...pendingFencedDecisions);
99
111
  }
100
- for (const repeated of decisions) {
101
- if (repeated === decision) {
102
- return {
103
- category: "duplicate-decision",
104
- markdown: text,
105
- message: "Advisor gate response contains duplicate decision lines.",
106
- ok: false,
107
- };
108
- }
109
- return {
110
- category: "contradictory-decision",
111
- markdown: text,
112
- message: "Advisor gate response contains contradictory decision lines.",
113
- ok: false,
114
- };
112
+ const [repeated] = decisions;
113
+ if (repeated !== undefined) {
114
+ return repeated === decision
115
+ ? {
116
+ category: "duplicate-decision",
117
+ markdown: text,
118
+ message: "Advisor gate response contains duplicate decision lines.",
119
+ ok: false,
120
+ }
121
+ : {
122
+ category: "contradictory-decision",
123
+ markdown: text,
124
+ message:
125
+ "Advisor gate response contains contradictory decision lines.",
126
+ ok: false,
127
+ };
115
128
  }
116
129
  return {
117
130
  decision,
@@ -0,0 +1,204 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Fetch } from "@typesafe-ai/sdk";
3
+
4
+ import {
5
+ advisorJevFilterEnabledRef,
6
+ advisorJevFilterNoulMarginRef,
7
+ advisorJevFilterOverrideWindowRef,
8
+ advisorJevFilterSkipConfidenceRef,
9
+ isSimpleMode,
10
+ } from "../config/state.ts";
11
+ import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
12
+ import { consumePlaintextKeyWarning } from "../jev/key-store.ts";
13
+ import {
14
+ composeScreeningVerdict,
15
+ screeningQuestions,
16
+ } from "../jev/questions.ts";
17
+ import { buildJevState } from "../jev/state.ts";
18
+ import { resolveJevTransport } from "../jev/transport.ts";
19
+ import type { JevCredentials } from "../jev/transport.ts";
20
+ import type { AdvisorSessionState } from "../session-state.ts";
21
+ import { createOutageNotifier } from "./outage-notifier.ts";
22
+
23
+ export type JevSkipKind = "screened" | "repeat";
24
+
25
+ export type ScreeningOutcome =
26
+ | { decision: "allow" }
27
+ | {
28
+ decision: "skip";
29
+ kind: JevSkipKind;
30
+ reason: string;
31
+ reattachedAdvice?: string;
32
+ };
33
+
34
+ export interface ScreenConsultationOptions {
35
+ draft?: string;
36
+ force?: boolean;
37
+ question?: string;
38
+ signal?: AbortSignal;
39
+ }
40
+
41
+ export interface ScreeningDeps {
42
+ fetch?: Fetch;
43
+ resolveTransport?: () => Promise<JevCredentials | undefined>;
44
+ }
45
+
46
+ export const normalizeScreeningQuestion = (question: string | undefined) =>
47
+ question?.trim().toLowerCase().replaceAll(/\s+/gu, " ") || undefined;
48
+
49
+ const REATTACHED_ADVICE_CAP_BYTES = 4 * 1024;
50
+
51
+ const SCREENED_SKIP_TEXT =
52
+ "Advisor consultation skipped (screened out): the stakes are low and you can resolve this yourself with available tools and context. Proceed on your own judgment with what you already have.";
53
+
54
+ const repeatSkipText = (advice: string) =>
55
+ `Advisor consultation skipped (already answered): this question was answered earlier in this session; the earlier advice is reattached below. Consult again only if the situation has materially changed.\n\n${advice}`;
56
+
57
+ const outageNotifier = createOutageNotifier(
58
+ (category, message) =>
59
+ `Advisor Jev filter failed (${category}); allowing consultations. ${message}`
60
+ );
61
+
62
+ const notifyOutageOnce = outageNotifier.notify;
63
+
64
+ /** Test-only: re-arms the once-per-outage notification. */
65
+ export const resetJevOutageNotification = outageNotifier.reset;
66
+
67
+ const allow = (): ScreeningOutcome => ({ decision: "allow" });
68
+
69
+ const bypassOutcome = (
70
+ session: AdvisorSessionState,
71
+ options: ScreenConsultationOptions,
72
+ normalizedQuestion: string | undefined
73
+ ): ScreeningOutcome | undefined => {
74
+ const lastSkip = session.lastJevSkip;
75
+ if (options.force) {
76
+ if (
77
+ lastSkip?.normalizedQuestion !== undefined &&
78
+ lastSkip.normalizedQuestion === normalizedQuestion
79
+ ) {
80
+ session.recordJevFilterOverride();
81
+ }
82
+ return allow();
83
+ }
84
+ if (
85
+ normalizedQuestion !== undefined &&
86
+ lastSkip?.normalizedQuestion === normalizedQuestion &&
87
+ session.sessionTurnOrdinal - lastSkip.turn <=
88
+ advisorJevFilterOverrideWindowRef
89
+ ) {
90
+ session.recordJevFilterOverride();
91
+ return allow();
92
+ }
93
+ return undefined;
94
+ };
95
+
96
+ const screenWithJev = async (
97
+ ctx: ExtensionContext,
98
+ session: AdvisorSessionState,
99
+ options: ScreenConsultationOptions,
100
+ deps: ScreeningDeps,
101
+ normalizedQuestion: string | undefined
102
+ ): Promise<ScreeningOutcome> => {
103
+ const credentials = await (deps.resolveTransport ?? resolveJevTransport)(ctx);
104
+ if (!credentials) {
105
+ session.recordJevFilterFailure();
106
+ notifyOutageOnce(
107
+ ctx,
108
+ "missing-key",
109
+ "No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
110
+ );
111
+ return allow();
112
+ }
113
+ if (credentials.source === "advisor-json") {
114
+ const warning = consumePlaintextKeyWarning();
115
+ if (warning && ctx.hasUI) {
116
+ ctx.ui.notify(warning, "warning");
117
+ }
118
+ }
119
+
120
+ const client = jevClientFromCredentials(credentials, deps.fetch);
121
+ try {
122
+ const result = await client.ask(
123
+ buildJevState(ctx, options),
124
+ screeningQuestions,
125
+ options.signal
126
+ );
127
+ session.recordJevFilterUsage(result.usage);
128
+ const verdict = composeScreeningVerdict(result.answers, {
129
+ noulMargin: advisorJevFilterNoulMarginRef,
130
+ skipConfidence: advisorJevFilterSkipConfidenceRef,
131
+ });
132
+ if (verdict.skip) {
133
+ session.recordJevFilterSkipped(false, normalizedQuestion);
134
+ return {
135
+ decision: "skip",
136
+ kind: "screened",
137
+ reason: "low stakes and resolvable without a consultation",
138
+ };
139
+ }
140
+ session.recordJevFilterAllowed();
141
+ return allow();
142
+ } catch (error) {
143
+ session.recordJevFilterFailure();
144
+ if (error instanceof JevFailure) {
145
+ notifyOutageOnce(ctx, error.category, error.message);
146
+ } else if (options.signal?.aborted) {
147
+ throw error;
148
+ } else {
149
+ notifyOutageOnce(
150
+ ctx,
151
+ "error",
152
+ error instanceof Error ? error.message : String(error)
153
+ );
154
+ }
155
+ return allow();
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
161
+ * simple mode, missing credentials, and every Jev failure allow the
162
+ * consultation; only the hard conjunction (negligible-mass ≥ skip confidence
163
+ * AND confidently self-answerable) or an exact repeat skips.
164
+ */
165
+ export const screenConsultation = (
166
+ ctx: ExtensionContext,
167
+ session: AdvisorSessionState,
168
+ options: ScreenConsultationOptions,
169
+ deps: ScreeningDeps = {}
170
+ ): Promise<ScreeningOutcome> => {
171
+ if (isSimpleMode()) {
172
+ return Promise.resolve(allow());
173
+ }
174
+ // Repeat reattachment and its force/passthrough escape hatch are code-side
175
+ // and free: they run regardless of the Jev enable flag, which only gates
176
+ // the (paid, network) Jev screening call.
177
+ const normalizedQuestion = normalizeScreeningQuestion(options.question);
178
+ const bypass = bypassOutcome(session, options, normalizedQuestion);
179
+ if (bypass) {
180
+ return Promise.resolve(bypass);
181
+ }
182
+ const reattached = session.reattachedAdviceFor(normalizedQuestion);
183
+ if (reattached) {
184
+ session.recordJevFilterSkipped(true, normalizedQuestion);
185
+ return Promise.resolve({
186
+ decision: "skip",
187
+ kind: "repeat",
188
+ reason: "already answered earlier in this session",
189
+ reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
190
+ });
191
+ }
192
+ if (!advisorJevFilterEnabledRef) {
193
+ return Promise.resolve(allow());
194
+ }
195
+ return screenWithJev(ctx, session, options, deps, normalizedQuestion);
196
+ };
197
+
198
+ export const screeningSkipText = (outcome: {
199
+ kind: JevSkipKind;
200
+ reattachedAdvice?: string;
201
+ }) =>
202
+ outcome.kind === "repeat" && outcome.reattachedAdvice
203
+ ? repeatSkipText(outcome.reattachedAdvice)
204
+ : SCREENED_SKIP_TEXT;
@@ -0,0 +1,203 @@
1
+ import type {
2
+ ExtensionContext,
3
+ TurnEndEvent,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { Fetch } from "@typesafe-ai/sdk";
6
+
7
+ import {
8
+ advisorJevTurnGateEveryTurnsRef,
9
+ advisorJevTurnGateNoulThresholdRef,
10
+ getAdvisorMaxCallsPerSession,
11
+ isSimpleMode,
12
+ } from "../config/state.ts";
13
+ import { herdrAdvisorActivity } from "../herdr.ts";
14
+ import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
15
+ import { composeTurnGateVerdict } from "../jev/questions.ts";
16
+ import { buildJevState } from "../jev/state.ts";
17
+ import { resolveJevTransport } from "../jev/transport.ts";
18
+ import type { JevCredentials } from "../jev/transport.ts";
19
+ import type { AdvisorSessionState } from "../session-state.ts";
20
+ import { advisorUsageCost } from "../usage.ts";
21
+ import type { consultAdvisor } from "./consultation.ts";
22
+ import { updateAdvisorUsageStatus } from "./gate-policy.ts";
23
+ import { advisorModelIsAllowed } from "./model-access.ts";
24
+ import { createOutageNotifier } from "./outage-notifier.ts";
25
+
26
+ export const turnGateQuestion = {
27
+ criteria: {
28
+ false:
29
+ "The executor is progressing soundly on work that matches the user's intent; interrupting would add nothing material.",
30
+ true: "The executor is approaching a material decision, repeating a failure, about to claim success without validation, or drifting from the user's intent; a second opinion now would change what happens next.",
31
+ },
32
+ instructions:
33
+ "Should a senior engineering advisor be consulted right now, before the executor continues? Judge from `recent_conversation`.",
34
+ type: "noul",
35
+ } as const;
36
+
37
+ export interface JevTurnGateDeps {
38
+ consult?: typeof consultAdvisor;
39
+ fetch?: Fetch;
40
+ resolveTransport?: () => Promise<JevCredentials | undefined>;
41
+ }
42
+
43
+ export interface TurnGateCallDetails {
44
+ question: string;
45
+ turn: number;
46
+ }
47
+
48
+ export interface TurnGateResultDetails {
49
+ advisor: string;
50
+ text: string;
51
+ usage?: unknown;
52
+ }
53
+
54
+ export interface JevTurnGateRegistration {
55
+ activeTools: () => string[];
56
+ consult: typeof consultAdvisor;
57
+ deps?: JevTurnGateDeps;
58
+ send: (message: {
59
+ content: string;
60
+ customType: string;
61
+ details: TurnGateCallDetails | TurnGateResultDetails;
62
+ display: boolean;
63
+ }) => void;
64
+ session: AdvisorSessionState;
65
+ }
66
+
67
+ const outageNotifier = createOutageNotifier(
68
+ (category, message) =>
69
+ `Advisor Jev turn gate failed (${category}); continuing without a proactive consultation. ${message}`
70
+ );
71
+
72
+ const notifyFailureOnce = outageNotifier.notify;
73
+
74
+ /** Test-only: re-arms the once-per-outage notification. */
75
+ export const resetJevTurnGateNotification = outageNotifier.reset;
76
+
77
+ /**
78
+ * turn_end handler: counts turns without a consultation and, every Nth such
79
+ * turn, asks Jev whether the Advisor should proactively weigh in. Confident
80
+ * true runs a real consultation delivered as a steer message; everything else
81
+ * keeps the status quo.
82
+ */
83
+ export const handleJevTurnEnd = async (
84
+ registration: JevTurnGateRegistration,
85
+ ctx: ExtensionContext
86
+ ): Promise<void> => {
87
+ const { session } = registration;
88
+ session.recordCompletedTurn();
89
+ const interval = advisorJevTurnGateEveryTurnsRef;
90
+ if (
91
+ interval <= 0 ||
92
+ session.turnsSinceConsultation <= 0 ||
93
+ session.turnsSinceConsultation % interval !== 0
94
+ ) {
95
+ return;
96
+ }
97
+ if (
98
+ isSimpleMode() ||
99
+ session.blocked ||
100
+ !registration.activeTools().includes("ask_advisor") ||
101
+ !advisorModelIsAllowed(ctx) ||
102
+ !session.canConsult(getAdvisorMaxCallsPerSession())
103
+ ) {
104
+ return;
105
+ }
106
+
107
+ const consult = registration.deps?.consult ?? registration.consult;
108
+ const deps = registration.deps ?? {};
109
+ try {
110
+ const credentials = await (deps.resolveTransport ?? resolveJevTransport)(
111
+ ctx
112
+ );
113
+ if (!credentials) {
114
+ session.recordJevGateFailure();
115
+ notifyFailureOnce(
116
+ ctx,
117
+ "missing-key",
118
+ "No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
119
+ );
120
+ return;
121
+ }
122
+ const client = jevClientFromCredentials(credentials, deps.fetch);
123
+ const result = await client.ask(buildJevState(ctx, {}), {
124
+ should_consult: turnGateQuestion,
125
+ });
126
+ session.recordJevGateCheck(result.usage);
127
+ const shouldConsult = composeTurnGateVerdict(
128
+ result.answers,
129
+ advisorJevTurnGateNoulThresholdRef
130
+ );
131
+ if (!shouldConsult) {
132
+ return;
133
+ }
134
+
135
+ session.consumeCall();
136
+ herdrAdvisorActivity.start();
137
+ registration.send({
138
+ content: "Proactive Advisor turn review",
139
+ customType: "advisor-turn-gate-call",
140
+ details: {
141
+ question: `Turn gate: ${session.turnsSinceConsultation} turns without a consultation`,
142
+ turn: session.sessionTurnOrdinal,
143
+ },
144
+ display: true,
145
+ });
146
+ try {
147
+ const consulted = await consult(
148
+ ctx,
149
+ undefined,
150
+ ctx.signal,
151
+ undefined,
152
+ "turn-gate"
153
+ );
154
+ session.recordJevGateConsultation();
155
+ session.resetTurnsSinceConsultation();
156
+ session.recordInvocation({
157
+ cost: advisorUsageCost(consulted.usage),
158
+ executionEffect: "continued",
159
+ kind: "markdown",
160
+ model: consulted.model,
161
+ trigger: "turn-gate",
162
+ usage: consulted.usage,
163
+ });
164
+ updateAdvisorUsageStatus(ctx, session);
165
+ registration.send({
166
+ content: consulted.markdown,
167
+ customType: "advisor-turn-gate-result",
168
+ details: {
169
+ advisor: consulted.model,
170
+ text: consulted.markdown,
171
+ usage: consulted.usage,
172
+ },
173
+ display: true,
174
+ });
175
+ } finally {
176
+ herdrAdvisorActivity.finish();
177
+ }
178
+ } catch (error) {
179
+ session.recordJevGateFailure();
180
+ if (error instanceof JevFailure) {
181
+ notifyFailureOnce(ctx, error.category, error.message);
182
+ } else if (!ctx.signal?.aborted) {
183
+ notifyFailureOnce(
184
+ ctx,
185
+ "error",
186
+ error instanceof Error ? error.message : String(error)
187
+ );
188
+ }
189
+ }
190
+ };
191
+
192
+ type TurnEndRegistrar = (
193
+ event: "turn_end",
194
+ handler: (event: TurnEndEvent, ctx: ExtensionContext) => void | Promise<void>
195
+ ) => void;
196
+
197
+ /** Wires the turn_end handler on an ExtensionAPI-like surface. */
198
+ export const registerJevTurnGate = (
199
+ on: TurnEndRegistrar,
200
+ registration: JevTurnGateRegistration
201
+ ): void => {
202
+ on("turn_end", (_event, ctx) => handleJevTurnEnd(registration, ctx));
203
+ };
@@ -4,6 +4,7 @@ import type {
4
4
  ToolCallEvent,
5
5
  ToolCallEventResult,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+
7
8
  import {
8
9
  advisorAutoLoopGateRef,
9
10
  advisorFailureModeRef,
@@ -15,8 +16,9 @@ import {
15
16
  } from "../config/state.ts";
16
17
  import type { GateFailureMode } from "../config/types.ts";
17
18
  import { herdrAdvisorActivity } from "../herdr.ts";
18
- import type { AdvisorSessionState } from "../session-state.ts";
19
+ import type { AdvisorSessionState, GateDecision } from "../session-state.ts";
19
20
  import { advisorUsageCost, snapshotAdvisorUsage } from "../usage.ts";
21
+ import type { AdvisorUsageSnapshot } from "../usage.ts";
20
22
  import type { runAdvisorGate } from "./consultation.ts";
21
23
  import {
22
24
  blockedDecisionEffect,
@@ -26,10 +28,9 @@ import {
26
28
  updateAdvisorUsageStatus,
27
29
  } from "./gate-policy.ts";
28
30
  import { adviceForGateText } from "./gate-protocol.ts";
29
- import {
30
- appendScoutLifecycleEntry,
31
- type ScoutStatusManager,
32
- } from "./scout-status.ts";
31
+ import { advisorModelIsAllowed } from "./model-access.ts";
32
+ import { appendScoutLifecycleEntry } from "./scout-status.ts";
33
+ import type { ScoutStatusManager } from "./scout-status.ts";
33
34
  import type { AdvisorGateResult, ScoutToolDetails } from "./types.ts";
34
35
 
35
36
  const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
@@ -46,20 +47,27 @@ const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
46
47
  );
47
48
  };
48
49
 
50
+ interface LoopResultDetails {
51
+ advisor?: string;
52
+ decision?: GateDecision;
53
+ text: string;
54
+ usage?: AdvisorUsageSnapshot;
55
+ }
56
+
49
57
  const sendAutomaticGateFailure = (
50
58
  pi: ExtensionAPI,
51
59
  markdown: string,
52
- usage?: unknown
60
+ usage?: AdvisorUsageSnapshot
53
61
  ) => {
54
- const normalizedUsage = snapshotAdvisorUsage(usage);
62
+ const details: LoopResultDetails = { text: markdown };
63
+ if (usage) {
64
+ details.usage = usage;
65
+ }
55
66
  pi.sendMessage(
56
67
  {
57
68
  content: markdown,
58
69
  customType: "advisor-loop-result",
59
- details: {
60
- text: markdown,
61
- ...(normalizedUsage ? { usage: normalizedUsage } : {}),
62
- },
70
+ details,
63
71
  display: true,
64
72
  },
65
73
  { deliverAs: "steer" }
@@ -70,18 +78,20 @@ const sendAutomaticGateResult = (
70
78
  pi: ExtensionAPI,
71
79
  result: AdvisorGateResult
72
80
  ) => {
81
+ const details: LoopResultDetails = {
82
+ advisor: result.model,
83
+ decision: result.decision,
84
+ text: result.markdown,
85
+ };
86
+ const normalizedUsage = snapshotAdvisorUsage(result.usage);
87
+ if (normalizedUsage) {
88
+ details.usage = normalizedUsage;
89
+ }
73
90
  pi.sendMessage(
74
91
  {
75
92
  content: adviceForGateText(result),
76
93
  customType: "advisor-loop-result",
77
- details: {
78
- advisor: result.model,
79
- decision: result.decision,
80
- text: result.markdown,
81
- ...(snapshotAdvisorUsage(result.usage)
82
- ? { usage: snapshotAdvisorUsage(result.usage) }
83
- : {}),
84
- },
94
+ details,
85
95
  display: true,
86
96
  },
87
97
  { deliverAs: "steer" }
@@ -118,7 +128,7 @@ const applyGateDecision = (
118
128
  sendAutomaticGateFailure(
119
129
  pi,
120
130
  `**Advisor gate failure (${result.category}):** ${result.message}`,
121
- result.usage
131
+ snapshotAdvisorUsage(result.usage)
122
132
  );
123
133
  return failure.block
124
134
  ? { block: true, reason: `${reason}\n${failure.reason}` }
@@ -158,6 +168,7 @@ export const handleAutomaticGate = async (
158
168
  if (
159
169
  isSimpleMode() ||
160
170
  event.toolName === "ask_advisor" ||
171
+ !advisorModelIsAllowed(ctx) ||
161
172
  !advisorAutoLoopGateRef ||
162
173
  !session.recordToolCall(
163
174
  event.toolName,
@@ -180,6 +191,7 @@ export const handleAutomaticGate = async (
180
191
  return failure.block ? { block: true, reason: failure.reason } : undefined;
181
192
  }
182
193
  session.consumeCall();
194
+ session.resetTurnsSinceConsultation();
183
195
  herdrAdvisorActivity.start();
184
196
  let scoutDetails: ScoutToolDetails | undefined;
185
197
  const scoutStatusToken = Symbol("automatic-gate-scout");