pi-advisor-flow 0.5.1 → 0.5.3

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 +21 -0
  2. package/README.md +3 -2
  3. package/extensions/index.ts +7 -7
  4. package/package.json +2 -1
  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 -1259
  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 -814
  28. package/src/conversation.ts +4 -2
  29. package/src/herdr.ts +1 -1
  30. package/src/model-stream.ts +15 -1
  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 +247 -0
  39. package/src/tools/register-lifecycle.ts +119 -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 -1865
  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;
@@ -218,9 +218,23 @@ export const collectTextStream = async (
218
218
  }
219
219
 
220
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
+ }
221
226
  const lastAssistant = [response].find(
222
227
  (message): message is AssistantMessage => message.role === "assistant"
223
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
+ }
224
238
  const finalText =
225
239
  lastAssistant?.content
226
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}`;