pi-advisor-flow 0.5.0 → 0.5.2

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 (59) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +37 -71
  3. package/extensions/index.ts +7 -7
  4. package/package.json +19 -6
  5. package/src/commands/activation-preparation.ts +117 -0
  6. package/src/commands/activation.ts +135 -0
  7. package/src/commands/lifecycle.ts +83 -0
  8. package/src/commands/manual-command.ts +107 -0
  9. package/src/commands/manual-consultation.ts +144 -0
  10. package/src/commands/manual-progress.ts +93 -0
  11. package/src/commands/model-commands.ts +66 -0
  12. package/src/commands/model-options.ts +181 -0
  13. package/src/commands/model-picker.ts +110 -0
  14. package/src/commands/registration.ts +27 -0
  15. package/src/commands/renderers.ts +76 -0
  16. package/src/commands/runtime.ts +114 -0
  17. package/src/commands/settings-commands.ts +80 -0
  18. package/src/commands/settings-persistence.ts +81 -0
  19. package/src/commands/types.ts +78 -0
  20. package/src/commands.ts +9 -946
  21. package/src/config/args.ts +37 -0
  22. package/src/config/defaults.ts +190 -0
  23. package/src/config/state.ts +199 -0
  24. package/src/config/storage.ts +224 -0
  25. package/src/config/types.ts +63 -0
  26. package/src/config/validation.ts +234 -0
  27. package/src/config.ts +104 -791
  28. package/src/conversation.ts +4 -2
  29. package/src/herdr.ts +1 -1
  30. package/src/model-stream.ts +25 -2
  31. package/src/scout-context.ts +2 -2
  32. package/src/scout.ts +1 -1
  33. package/src/tools/consultation.ts +337 -0
  34. package/src/tools/gate-policy.ts +132 -0
  35. package/src/tools/gate-protocol.ts +127 -0
  36. package/src/tools/loop-gate.ts +210 -0
  37. package/src/tools/prompts.ts +159 -0
  38. package/src/tools/register-ask-advisor.ts +225 -0
  39. package/src/tools/register-lifecycle.ts +89 -0
  40. package/src/tools/register-outcome.ts +75 -0
  41. package/src/tools/register-renderers.ts +88 -0
  42. package/src/tools/registration.ts +35 -0
  43. package/src/tools/render-advisor-result.ts +168 -0
  44. package/src/tools/render-common.ts +120 -0
  45. package/src/tools/scout-status.ts +206 -0
  46. package/src/tools/session.ts +3 -0
  47. package/src/tools/types.ts +119 -0
  48. package/src/tools.ts +42 -1811
  49. package/src/ui/manual-dialog-render.ts +150 -0
  50. package/src/ui/manual-dialog.ts +305 -0
  51. package/src/ui/model-selector.ts +166 -0
  52. package/src/ui/settings-formatting.ts +133 -0
  53. package/src/ui/settings-items.ts +323 -0
  54. package/src/ui/settings-list-adapter.ts +103 -0
  55. package/src/ui/settings-mutations.ts +80 -0
  56. package/src/ui/settings-selector.ts +161 -0
  57. package/src/ui/text-setting-submenu.ts +66 -0
  58. package/src/ui/types.ts +111 -0
  59. package/src/ui.ts +9 -1418
@@ -1,13 +1,15 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
- type AdvisorToolPolicies,
4
3
  advisorRedactSecretsRef,
5
4
  advisorToolPoliciesRef,
6
5
  advisorToolResultMaxBytesRef,
7
6
  advisorToolResultMaxLinesRef,
7
+ } from "./config/state.js";
8
+ import type { AdvisorToolPolicies } from "./config/types.js";
9
+ import {
8
10
  DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
9
11
  DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
10
- } from "./config.js";
12
+ } from "./config/types.js";
11
13
 
12
14
  type RecordValue = Record<string, unknown>;
13
15
 
package/src/herdr.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import net from "node:net";
2
- import { getAdvisorSettings } from "./config.js";
2
+ import { getAdvisorSettings } from "./config/state.js";
3
3
  import { redactSecrets } from "./conversation.js";
4
4
 
5
5
  // Keep the JSON-RPC method components separate from Socket's URL-string heuristic.
@@ -6,7 +6,7 @@ import {
6
6
  stream,
7
7
  } from "@earendil-works/pi-ai/compat";
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { splitRef } from "./config.js";
9
+ import { splitRef } from "./config/state.js";
10
10
 
11
11
  export interface ResolvedConfiguredModel {
12
12
  apiKey: string;
@@ -18,9 +18,12 @@ export interface ResolvedConfiguredModel {
18
18
 
19
19
  export const resolveConfiguredModel = async (
20
20
  ctx: ExtensionContext,
21
- ref: string,
21
+ ref: string | undefined,
22
22
  label: string
23
23
  ): Promise<ResolvedConfiguredModel> => {
24
+ if (!ref) {
25
+ throw new Error(`${label} model not configured`);
26
+ }
24
27
  const [provider, modelId] = splitRef(ref);
25
28
  const model = ctx.modelRegistry.find(provider, modelId);
26
29
  if (!model) {
@@ -193,7 +196,13 @@ export const collectTextStream = async (
193
196
  apiKey: resolved.apiKey,
194
197
  env: resolved.env,
195
198
  headers: resolved.headers,
199
+ // `stream()` uses the provider-facing name while the extension's public
200
+ // option keeps the Pi-facing `reasoning` name. Preserve both so the
201
+ // configured effort reaches providers that serialize reasoning_effort.
196
202
  reasoning: options.reasoning as never,
203
+ ...(options.reasoning === undefined
204
+ ? {}
205
+ : { reasoningEffort: options.reasoning as never }),
197
206
  signal: options.signal,
198
207
  }
199
208
  );
@@ -209,9 +218,23 @@ export const collectTextStream = async (
209
218
  }
210
219
 
211
220
  const response = await eventStream.result();
221
+ if (options.signal?.aborted) {
222
+ throw options.signal.reason instanceof Error
223
+ ? options.signal.reason
224
+ : new Error("Advisor operation cancelled.");
225
+ }
212
226
  const lastAssistant = [response].find(
213
227
  (message): message is AssistantMessage => message.role === "assistant"
214
228
  );
229
+ if (
230
+ lastAssistant?.stopReason === "error" ||
231
+ lastAssistant?.stopReason === "aborted"
232
+ ) {
233
+ throw new Error(
234
+ lastAssistant.errorMessage ??
235
+ `Advisor stream ended with ${lastAssistant.stopReason}.`
236
+ );
237
+ }
215
238
  const finalText =
216
239
  lastAssistant?.content
217
240
  .filter(
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- type AdvisorToolPolicies,
5
4
  advisorRedactSecretsRef,
6
5
  advisorToolPoliciesRef,
7
6
  advisorToolResultMaxBytesRef,
8
7
  advisorToolResultMaxLinesRef,
9
- } from "./config.js";
8
+ } from "./config/state.js";
9
+ import type { AdvisorToolPolicies } from "./config/types.js";
10
10
  import { conversationEntry, textFrom } from "./conversation.js";
11
11
 
12
12
  export const SCOUT_MANIFEST_MAX_BYTES = 64 * 1024;
package/src/scout.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Message } from "@earendil-works/pi-ai/compat";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { executorEffortRef, executorRef } from "./config.js";
3
+ import { executorEffortRef, executorRef } from "./config/state.js";
4
4
  import {
5
5
  type CollectedTextStream,
6
6
  collectTextStream,
@@ -0,0 +1,337 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Message } from "@earendil-works/pi-ai/compat";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ advisorEffortRef,
6
+ advisorGitContextMaxCharsRef,
7
+ advisorGitContextRef,
8
+ advisorRedactSecretsRef,
9
+ advisorRef,
10
+ advisorScoutEnabledRef,
11
+ advisorTrackedFileContentRef,
12
+ advisorUntrackedContentRef,
13
+ contextMaxCharsRef,
14
+ executorRef,
15
+ } from "../config/state.js";
16
+ import { loadConfig } from "../config/storage.js";
17
+ import { redactAndCapText, redactSecrets } from "../conversation.js";
18
+ import {
19
+ clampGitContextLevel,
20
+ collectGitContext,
21
+ type GitContextLevel,
22
+ } from "../git.js";
23
+ import { collectTextStream, resolveConfiguredModel } from "../model-stream.js";
24
+ import { readProjectPreferences } from "../preferences.js";
25
+ import {
26
+ runAdvisorScout,
27
+ type ScoutLifecycleEvent,
28
+ type ScoutOutcome,
29
+ } from "../scout.js";
30
+ import {
31
+ buildScoutManifest,
32
+ reconstructScoutConversation,
33
+ SCOUT_MANIFEST_MAX_BYTES,
34
+ } from "../scout-context.js";
35
+ import type { ConsultationTrigger, GateTrigger } from "../session-state.js";
36
+ import { readTrackedFiles, readUntrackedFiles } from "../untracked.js";
37
+ import { parseAutomaticDecision } from "./gate-protocol.js";
38
+ import {
39
+ ADVISOR_DECISION_SYSTEM,
40
+ ADVISOR_SYSTEM,
41
+ advisorGitContextBudget,
42
+ advisorMessageText,
43
+ advisorRepositoryContext,
44
+ advisorRequestConversation,
45
+ } from "./prompts.js";
46
+ import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.js";
47
+
48
+ export const curateAdvisorConversation = async (
49
+ ctx: ExtensionContext,
50
+ legacyConversation: string,
51
+ signal?: AbortSignal,
52
+ onScout?: (event: ScoutLifecycleEvent) => void,
53
+ enabled = advisorScoutEnabledRef,
54
+ runScout: typeof runAdvisorScout = runAdvisorScout,
55
+ currentInvocationId?: string,
56
+ maxChars?: number
57
+ ): Promise<{
58
+ conversation: string;
59
+ scout?: Exclude<ScoutOutcome, { cancelled: true }>;
60
+ }> => {
61
+ if (!enabled) {
62
+ return { conversation: legacyConversation };
63
+ }
64
+ if (maxChars !== undefined && maxChars <= 0) {
65
+ return { conversation: "" };
66
+ }
67
+ const built = buildScoutManifest(ctx, {
68
+ currentInvocationId,
69
+ maxConversationChars: maxChars,
70
+ maxManifestBytes: SCOUT_MANIFEST_MAX_BYTES,
71
+ });
72
+ if (!built.ok) {
73
+ const scout: Exclude<ScoutOutcome, { cancelled: true }> = {
74
+ category: built.reason,
75
+ message: built.message,
76
+ metrics: {
77
+ availableCount: 0,
78
+ inputBytes: 0,
79
+ latencyMs: 0,
80
+ omittedBeforeScout: 0,
81
+ selectedCount: 0,
82
+ },
83
+ model: executorRef,
84
+ ok: false,
85
+ };
86
+ onScout?.({ outcome: scout, type: "fallback" });
87
+ return { conversation: legacyConversation, scout };
88
+ }
89
+ const outcome = await runScout(
90
+ ctx,
91
+ built.manifest,
92
+ signal,
93
+ onScout,
94
+ undefined,
95
+ undefined
96
+ );
97
+ if (!outcome.ok && outcome.cancelled) {
98
+ throw signal?.reason instanceof Error
99
+ ? signal.reason
100
+ : new Error("Advisor operation cancelled during Scout.");
101
+ }
102
+ let conversation = legacyConversation;
103
+ if (outcome.ok) {
104
+ conversation =
105
+ maxChars === undefined
106
+ ? outcome.conversation
107
+ : reconstructScoutConversation(
108
+ built.manifest,
109
+ outcome.selection.selectedIds,
110
+ outcome.selection.synthesis,
111
+ maxChars
112
+ );
113
+ }
114
+ return { conversation, scout: outcome };
115
+ };
116
+
117
+ const collectAdvisorResponse = async (
118
+ ctx: ExtensionContext,
119
+ systemPrompt: string,
120
+ question: string | undefined,
121
+ signal: AbortSignal | undefined,
122
+ onChunk: ((thinking: string, text: string) => void) | undefined,
123
+ gitContext?: GitContextLevel,
124
+ draft?: string,
125
+ includeUntracked?: string[],
126
+ includeTracked?: string[],
127
+ onScout?: (event: ScoutLifecycleEvent) => void,
128
+ currentInvocationId?: string
129
+ ) => {
130
+ loadConfig(ctx);
131
+ const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
132
+
133
+ // The user setting is the ceiling; the Executor may only narrow it.
134
+ const allowed = advisorGitContextRef;
135
+ const level = clampGitContextLevel(gitContext ?? allowed, allowed);
136
+ const gitBudget = advisorGitContextBudget(
137
+ contextMaxCharsRef,
138
+ advisorGitContextMaxCharsRef
139
+ );
140
+ const changes = collectGitContext(
141
+ ctx.cwd,
142
+ level,
143
+ gitBudget,
144
+ advisorRedactSecretsRef ? redactSecrets : undefined
145
+ );
146
+ // The note is placed first so a cap can never drop the statement that the
147
+ // Advisor's view of the repository is limited.
148
+ // The disclosure warning is control metadata, not repository payload. Keep it
149
+ // outside the zero-byte Git budget so disabling disclosure cannot erase it.
150
+ const changeText = advisorRepositoryContext(
151
+ changes,
152
+ gitContext ?? allowed,
153
+ level,
154
+ gitBudget
155
+ );
156
+ // Repository context spends part of the shared budget, so a large patch
157
+ // cannot silently push the conversation past the model's context window.
158
+ const conversationBudget = Math.max(
159
+ 0,
160
+ contextMaxCharsRef - changeText.length
161
+ );
162
+ const legacyConversation = advisorRequestConversation(
163
+ ctx,
164
+ conversationBudget
165
+ );
166
+ const curated = await curateAdvisorConversation(
167
+ ctx,
168
+ legacyConversation,
169
+ signal,
170
+ onScout,
171
+ advisorScoutEnabledRef,
172
+ runAdvisorScout,
173
+ currentInvocationId,
174
+ conversationBudget
175
+ );
176
+ const { conversation, scout } = curated;
177
+ const preferences = await readProjectPreferences(
178
+ ctx,
179
+ 8 * 1024,
180
+ advisorRedactSecretsRef
181
+ );
182
+ const draftText = draft
183
+ ? redactAndCapText(draft, 8 * 1024, advisorRedactSecretsRef)
184
+ : undefined;
185
+ const untracked = await readUntrackedFiles(
186
+ ctx.cwd,
187
+ includeUntracked ?? [],
188
+ advisorUntrackedContentRef,
189
+ advisorRedactSecretsRef
190
+ );
191
+ const tracked = await readTrackedFiles(
192
+ ctx.cwd,
193
+ includeTracked ?? [],
194
+ advisorTrackedFileContentRef,
195
+ advisorRedactSecretsRef,
196
+ Math.max(
197
+ 0,
198
+ 24 * 1024 - untracked.reduce((sum, item) => sum + item.bytes, 0)
199
+ )
200
+ );
201
+ // Keep the original question available to local UI callers, but never send
202
+ // its credential-shaped values to the provider when redaction is enabled.
203
+ const outboundQuestion =
204
+ advisorRedactSecretsRef && question !== undefined
205
+ ? redactSecrets(question)
206
+ : question;
207
+ const messages: Message[] = [
208
+ {
209
+ content: [
210
+ {
211
+ text: advisorMessageText(
212
+ conversation,
213
+ outboundQuestion,
214
+ changeText,
215
+ draftText,
216
+ preferences?.text,
217
+ untracked.map(
218
+ (item) =>
219
+ `<file path=${JSON.stringify(item.path)}>\n${item.text}\n</file>`
220
+ ),
221
+ tracked.map(
222
+ (item) =>
223
+ `<file path=${JSON.stringify(item.path)}>\n${item.text}\n</file>`
224
+ )
225
+ ),
226
+ type: "text",
227
+ },
228
+ ],
229
+ role: "user",
230
+ timestamp: Date.now(),
231
+ },
232
+ ];
233
+
234
+ const streamed = await collectTextStream(resolved, {
235
+ messages,
236
+ onChunk,
237
+ reasoning: advisorEffortRef,
238
+ signal,
239
+ systemPrompt,
240
+ });
241
+ const markdown = streamed.text;
242
+ if (!markdown.trim()) {
243
+ throw new Error("Advisor returned no advice.");
244
+ }
245
+ return {
246
+ draftBytes: draftText ? Buffer.byteLength(draftText, "utf8") : undefined,
247
+ markdown,
248
+ model: advisorRef,
249
+ preferenceBytes: preferences?.bytes,
250
+ thinkingText: streamed.thinking,
251
+ trackedBytes:
252
+ tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
253
+ untrackedBytes:
254
+ untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
255
+ usage: streamed.usage,
256
+ ...(scout ? { scout } : {}),
257
+ };
258
+ };
259
+
260
+ export const consultAdvisor = async (
261
+ ctx: ExtensionContext,
262
+ question?: string,
263
+ signal?: AbortSignal,
264
+ onChunk?: (thinking: string, text: string) => void,
265
+ trigger: ConsultationTrigger = "executor-requested",
266
+ gitContext?: GitContextLevel,
267
+ draft?: string,
268
+ includeUntracked?: string[],
269
+ includeTracked?: string[],
270
+ onScout?: (event: ScoutLifecycleEvent) => void,
271
+ currentInvocationId?: string
272
+ ): Promise<AdvisorConsultationResult> => {
273
+ const result = await collectAdvisorResponse(
274
+ ctx,
275
+ ADVISOR_SYSTEM,
276
+ question,
277
+ signal,
278
+ onChunk,
279
+ gitContext,
280
+ draft,
281
+ includeUntracked,
282
+ includeTracked,
283
+ onScout,
284
+ currentInvocationId
285
+ );
286
+ return { ...result, adviceId: randomUUID(), trigger };
287
+ };
288
+
289
+ export const runAdvisorGate = async (
290
+ ctx: ExtensionContext,
291
+ question: string,
292
+ trigger: GateTrigger = "repeated-tool-call",
293
+ signal?: AbortSignal,
294
+ onChunk?: (thinking: string, text: string) => void,
295
+ onScout?: (event: ScoutLifecycleEvent) => void,
296
+ currentInvocationId?: string
297
+ ): Promise<AdvisorGateOutcome> => {
298
+ try {
299
+ const result = await collectAdvisorResponse(
300
+ ctx,
301
+ ADVISOR_DECISION_SYSTEM,
302
+ question,
303
+ signal,
304
+ onChunk,
305
+ undefined,
306
+ undefined,
307
+ undefined,
308
+ undefined,
309
+ onScout,
310
+ currentInvocationId
311
+ );
312
+ const parsed = parseAutomaticDecision(result.markdown);
313
+ if (!parsed.ok) {
314
+ return { ...parsed, usage: result.usage };
315
+ }
316
+ return {
317
+ ...parsed,
318
+ model: result.model,
319
+ thinkingText: result.thinkingText,
320
+ trigger,
321
+ usage: result.usage,
322
+ };
323
+ } catch (error) {
324
+ if (signal?.aborted) {
325
+ throw error;
326
+ }
327
+ const message = error instanceof Error ? error.message : String(error);
328
+ return {
329
+ category:
330
+ message === "Advisor returned no advice."
331
+ ? "empty-response"
332
+ : "provider-error",
333
+ message,
334
+ ok: false,
335
+ };
336
+ }
337
+ };
@@ -0,0 +1,132 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ToolCallEvent,
4
+ ToolCallEventResult,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ advisorBlockOnBlockedRef,
8
+ getAdvisorMaxCallsPerSession,
9
+ getAdvisorSettings,
10
+ isSimpleMode,
11
+ } from "../config/state.js";
12
+ import type { GateFailureMode } from "../config/types.js";
13
+ import { herdrAdvisorBlock, notifyHerdrAdvisorFailure } from "../herdr.js";
14
+ import type { AdvisorSessionState } from "../session-state.js";
15
+ import type { AdvisorGateResult, GateFailureCategory } from "./types.js";
16
+
17
+ export const updateAdvisorUsageStatus = (
18
+ ctx: ExtensionContext,
19
+ session: AdvisorSessionState
20
+ ) => {
21
+ if (ctx.hasUI) {
22
+ ctx.ui.setStatus(
23
+ "advisor-usage",
24
+ getAdvisorSettings().showUsageFooter ? session.usageStatus() : undefined
25
+ );
26
+ }
27
+ };
28
+
29
+ export const notifyLocalFailure = (
30
+ ctx: ExtensionContext,
31
+ message: string,
32
+ sessionBlocked = false
33
+ ) => {
34
+ if (ctx.hasUI) {
35
+ ctx.ui.notify(
36
+ `Advisor ${sessionBlocked ? "gate failure; session blocked" : "consultation failed"}: ${message}`,
37
+ "error"
38
+ );
39
+ }
40
+ };
41
+
42
+ export const gateFailureEffectForMode = (mode: GateFailureMode) => {
43
+ if (mode === "warn-and-continue") {
44
+ return "continued" as const;
45
+ }
46
+ return mode === "block-tool"
47
+ ? ("tool-blocked" as const)
48
+ : ("session-blocked" as const);
49
+ };
50
+
51
+ export const gateDecisionEffect = (
52
+ decision: AdvisorGateResult["decision"],
53
+ failureMode: GateFailureMode
54
+ ) => {
55
+ if (decision === "proceed") {
56
+ return "continued" as const;
57
+ }
58
+ return decision === "blocked"
59
+ ? gateFailureEffectForMode(failureMode)
60
+ : ("tool-blocked" as const);
61
+ };
62
+
63
+ export const failureEffect = (
64
+ category: GateFailureCategory,
65
+ message: string,
66
+ ctx: ExtensionContext,
67
+ session: AdvisorSessionState,
68
+ failureMode: GateFailureMode
69
+ ) => {
70
+ const reason = `Advisor gate ${category}: ${message}`;
71
+ notifyLocalFailure(ctx, message, failureMode === "block-session");
72
+ notifyHerdrAdvisorFailure("Advisor gate failure", reason);
73
+ if (failureMode === "warn-and-continue") {
74
+ return { block: false, effect: "continued" as const, reason };
75
+ }
76
+ if (failureMode === "block-tool") {
77
+ return { block: true, effect: "tool-blocked" as const, reason };
78
+ }
79
+ session.block(reason);
80
+ herdrAdvisorBlock.set(reason);
81
+ if (advisorBlockOnBlockedRef) {
82
+ ctx.abort();
83
+ }
84
+ return { block: true, effect: "session-blocked" as const, reason };
85
+ };
86
+
87
+ export const blockedDecisionEffect = (
88
+ reason: string,
89
+ ctx: ExtensionContext,
90
+ session: AdvisorSessionState,
91
+ failureMode: GateFailureMode
92
+ ) => {
93
+ if (failureMode === "warn-and-continue") {
94
+ if (ctx.hasUI) {
95
+ ctx.ui.notify(
96
+ "Advisor gate returned blocked; continuing by configuration.",
97
+ "warning"
98
+ );
99
+ }
100
+ return { block: false, effect: "continued" as const, reason };
101
+ }
102
+ if (failureMode === "block-tool") {
103
+ return { block: true, effect: "tool-blocked" as const, reason };
104
+ }
105
+ session.block(reason);
106
+ herdrAdvisorBlock.set(reason);
107
+ if (advisorBlockOnBlockedRef) {
108
+ ctx.abort();
109
+ }
110
+ return { block: true, effect: "session-blocked" as const, reason };
111
+ };
112
+
113
+ export const reserveAdvisorCall = (
114
+ event: ToolCallEvent,
115
+ ctx: ExtensionContext,
116
+ session: AdvisorSessionState,
117
+ reservedCalls: Set<string>
118
+ ): ToolCallEventResult | undefined => {
119
+ if (event.toolName !== "ask_advisor" || isSimpleMode()) {
120
+ return;
121
+ }
122
+ if (!session.canConsult(getAdvisorMaxCallsPerSession())) {
123
+ const message = "Advisor call budget exhausted for this session.";
124
+ if (ctx.hasUI) {
125
+ ctx.ui.notify(message, "warning");
126
+ }
127
+ notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
128
+ return { block: true, reason: message };
129
+ }
130
+ reservedCalls.add(event.toolCallId);
131
+ return {};
132
+ };
@@ -0,0 +1,127 @@
1
+ import type { GateDecision } from "../session-state.js";
2
+ import type { AdvisorGateFailure, AdvisorGateResult } from "./types.js";
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/;
7
+
8
+ interface FenceState {
9
+ character: string;
10
+ length: number;
11
+ }
12
+
13
+ const advanceFence = (
14
+ openingFence: FenceState | undefined,
15
+ marker: string,
16
+ suffix: string
17
+ ): { closed: boolean; openingFence: FenceState | undefined } => {
18
+ if (!openingFence) {
19
+ if (marker[0] === "`" && suffix.includes("`")) {
20
+ return { closed: false, openingFence: undefined };
21
+ }
22
+ return {
23
+ closed: false,
24
+ openingFence: { character: marker[0], length: marker.length },
25
+ };
26
+ }
27
+ if (
28
+ suffix.trim().length > 0 ||
29
+ marker[0] !== openingFence.character ||
30
+ marker.length < openingFence.length
31
+ ) {
32
+ return { closed: false, openingFence };
33
+ }
34
+ return { closed: true, openingFence: undefined };
35
+ };
36
+
37
+ export const parseAutomaticDecision = (
38
+ text: string
39
+ ): AdvisorGateResult | AdvisorGateFailure => {
40
+ const lines = text.split(LINE_BREAK);
41
+ const nonEmpty = lines.findIndex((line) => line.trim().length > 0);
42
+ if (nonEmpty === -1) {
43
+ return {
44
+ category: "empty-response",
45
+ message: "Advisor returned an empty gate response.",
46
+ ok: false,
47
+ };
48
+ }
49
+ const first = lines[nonEmpty].trim();
50
+ const match = DECISION_LINE.exec(first);
51
+ if (!match) {
52
+ return {
53
+ category: first.toLowerCase().startsWith("decision:")
54
+ ? "malformed-decision"
55
+ : "missing-decision",
56
+ markdown: text,
57
+ message:
58
+ "Advisor gate response must begin with Decision: proceed, Decision: revise, or Decision: blocked.",
59
+ ok: false,
60
+ };
61
+ }
62
+ const decision = match[1].toLowerCase() as GateDecision;
63
+ let openingFence: FenceState | undefined;
64
+ const decisions: string[] = [];
65
+ let pendingFencedDecisions: string[] = [];
66
+ for (const line of lines.slice(nonEmpty + 1)) {
67
+ const trimmed = line.trim();
68
+ // Decisions in a balanced fenced example are illustrative. Remember the
69
+ // opening delimiter so mismatched, undersized, or annotated fences cannot
70
+ // close it and hide a contradictory verdict. If the fence never closes,
71
+ // retain its decisions so malformed Markdown cannot make the gate fail
72
+ // open.
73
+ const fence = CODE_FENCE.exec(line);
74
+ if (fence) {
75
+ const { closed, openingFence: nextOpeningFence } = advanceFence(
76
+ openingFence,
77
+ fence[1],
78
+ fence[2]
79
+ );
80
+ openingFence = nextOpeningFence;
81
+ if (closed) {
82
+ pendingFencedDecisions = [];
83
+ }
84
+ continue;
85
+ }
86
+ const subsequent = DECISION_LINE.exec(trimmed);
87
+ if (!subsequent) {
88
+ continue;
89
+ }
90
+ const repeated = subsequent[1].trim().toLowerCase();
91
+ if (openingFence) {
92
+ pendingFencedDecisions.push(repeated);
93
+ } else {
94
+ decisions.push(repeated);
95
+ }
96
+ }
97
+ if (openingFence) {
98
+ decisions.push(...pendingFencedDecisions);
99
+ }
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
+ };
115
+ }
116
+ return {
117
+ decision,
118
+ markdown: text,
119
+ model: "",
120
+ ok: true,
121
+ thinkingText: "",
122
+ trigger: "repeated-tool-call",
123
+ };
124
+ };
125
+
126
+ export const adviceForGateText = (result: AdvisorGateResult) =>
127
+ `**Decision: ${result.decision}**\n\n${result.markdown}`;