@fiale-plus/pi-rogue-advisor 0.1.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,21 @@
1
+ /** Lightweight intent classifier for preflight signal enrichment */
2
+ export function classifyIntent(text: string): string {
3
+ const t = ` ${String(text ?? "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ")} `;
4
+ if (/\b(plan|design|architecture|scope|next step|strategy|proposal|should we|what should|tradeoff|decision|path forward|how to approach)\b/.test(t)) return "plan";
5
+ if (/\b(debug|bug|error|fail|broken|crash|stack|traceback|investigate|why (is|was|does|did|are) )/.test(t)) return "debug";
6
+ if (/\b(review|check |verify|validate|look at|diff|pr |pull request|feedback)\b/i.test(t)) return "review";
7
+ if (/\b(research|compare|difference|which (one|model|lib|is better)|how does|documentation|read (about|the)|what is)\b/i.test(t)) return "research";
8
+ if (/\b(implement|build|write|create|add|make|refactor|rename|extract|migrate|fix|patch)\b/i.test(t)) return "implement";
9
+ if (/\b(install|config|setup|run|build|deploy|ssh|status|stats|logs?|theme|terminal|shell|brew|npm|git)\b/i.test(t)) return "ops";
10
+ if (/\b(continue|resume|compact|summarize|after compact|move on)\b/i.test(t)) return "handoff";
11
+ return "";
12
+ }
13
+
14
+ /** Classify prompt as question/command/neutral for signal enrichment */
15
+ export function classifyMode(text: string): string {
16
+ const t = String(text ?? "").trim();
17
+ if (!t) return "";
18
+ if (/^(create|add|make|change|write|fix|update|remove|delete|run|install|set[\s-]?up|build|deploy|check|investigate|debug|review|test|refactor|merge|close|open|start|stop|restart|continue|show|list|compact|setup)\b/i.test(t)) return "command";
19
+ if (t.includes("?") || /^(what|why|how|when|where|who|which|is there|can we|should|does|did|are we|is it|do you|would you|could we)\b/i.test(t)) return "question";
20
+ return "neutral";
21
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatAdvisorDisplay, heuristicRoute, routeNote, shouldQueryClassifier, summarizeRoute, type AdvisorRouteInput } from "./router.js";
3
+
4
+ describe("advisor router heuristics", () => {
5
+ it("keeps tiny edits in continue mode", () => {
6
+ const input: AdvisorRouteInput = { phase: "preflight", text: "fix a typo in README" };
7
+ const route = heuristicRoute(input);
8
+
9
+ expect(route.label).toBe("continue");
10
+ expect(route.preflight).toBe("off");
11
+ expect(route.review).toBe("off");
12
+ expect(route.escalate).toBe(false);
13
+ expect(route.safety).toBe(false);
14
+ expect(shouldQueryClassifier(route)).toBe(false);
15
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: continue, reason: [a-z0-9 ,.'-]+\]$/);
16
+ });
17
+
18
+ it("escalates complex architecture work", () => {
19
+ const input: AdvisorRouteInput = { phase: "preflight", text: "need to refactor the architecture and tradeoffs" };
20
+ const route = heuristicRoute(input);
21
+
22
+ expect(route.label).toBe("escalate_to_advisor");
23
+ expect(route.preflight).toBe("full");
24
+ expect(route.review).toBe("light");
25
+ expect(route.escalate).toBe(true);
26
+ expect(summarizeRoute(route)).toContain("preflight:escalate_to_advisor");
27
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
28
+ });
29
+
30
+ it("escalates strategy and decision prompts", () => {
31
+ const input: AdvisorRouteInput = { phase: "preflight", text: "does it make sense to buy 3x usage 2x higher sustained speed? what would you choose as a strategy" };
32
+ const route = heuristicRoute(input);
33
+
34
+ expect(route.label).toBe("escalate_to_advisor");
35
+ expect(route.escalate).toBe(true);
36
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
37
+ });
38
+
39
+ it("flags safety-sensitive prompts", () => {
40
+ const input: AdvisorRouteInput = { phase: "preflight", text: "run rm -rf on prod" };
41
+ const route = heuristicRoute(input);
42
+
43
+ expect(route.safety).toBe(true);
44
+ expect(route.label).toBe("escalate_to_advisor");
45
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
46
+ });
47
+
48
+ it("reviews incomplete work as not done", () => {
49
+ const input: AdvisorRouteInput = { phase: "review", text: "still incomplete, tests fail", failed: true };
50
+ const route = heuristicRoute(input);
51
+
52
+ expect(route.label).toBe("not_done");
53
+ expect(route.review).toBe("strict");
54
+ expect(route.escalate).toBe(true);
55
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
56
+ });
57
+
58
+ it("abstains when review signal is weak", () => {
59
+ const input: AdvisorRouteInput = { phase: "review", text: "looks okay" };
60
+ const route = heuristicRoute(input);
61
+
62
+ expect(route.label).toBe("abstain");
63
+ expect(route.review).toBe("off");
64
+ expect(shouldQueryClassifier(route)).toBe(true);
65
+ expect(routeNote(route)).toMatch(/^\[advisor:rules: defer, reason: [a-z0-9 ,.'-]+\]$/);
66
+ });
67
+
68
+ it("tags model-routed notes explicitly", () => {
69
+ const input: AdvisorRouteInput = { phase: "preflight", text: "what would you choose as a strategy for this decision" };
70
+ const route = { ...heuristicRoute(input), source: "model" as const };
71
+
72
+ expect(routeNote(route)).toMatch(/^\[advisor:model: review, reason: [a-z0-9 ,.'-]+\]$/);
73
+ });
74
+
75
+ it("formats llm advisor messages with the llm tag", () => {
76
+ expect(formatAdvisorDisplay("advisor:llm", "review", "All set and reviewed")).toBe("[advisor:llm: review, reason: all set and reviewed]");
77
+ });
78
+ });
package/src/router.ts ADDED
@@ -0,0 +1,516 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { appendText, featureFile, truncate } from "./internal.js";
6
+
7
+ export type AdvisorPhase = "preflight" | "review" | "closeout";
8
+ export type PreflightLabel = "continue" | "escalate_to_advisor" | "need_more_context" | "low_confidence";
9
+ export type ReviewLabel = "on_track" | "course_correct" | "not_done" | "abstain";
10
+ export type RouterSource = "heuristic" | "model" | "llm";
11
+ export type PreflightPolicy = "off" | "light" | "full" | "direct";
12
+ export type ReviewPolicy = "off" | "light" | "strict";
13
+
14
+ export interface AdvisorRouteInput {
15
+ phase: AdvisorPhase;
16
+ text: string;
17
+ brief?: string;
18
+ fileChanged?: boolean;
19
+ failed?: boolean;
20
+ }
21
+
22
+ export interface AdvisorRouteDecision {
23
+ phase: AdvisorPhase;
24
+ label: PreflightLabel | ReviewLabel;
25
+ confidence: number;
26
+ reason: string;
27
+ source: RouterSource;
28
+ preflight: PreflightPolicy;
29
+ review: ReviewPolicy;
30
+ escalate: boolean;
31
+ safety: boolean;
32
+ promptHash: string;
33
+ promptSummary: string;
34
+ briefSummary?: string;
35
+ }
36
+
37
+ export interface RouterResponse {
38
+ label: PreflightLabel | ReviewLabel;
39
+ confidence?: number;
40
+ reason?: string;
41
+ }
42
+
43
+ const ROUTER_LOG_PATH = featureFile("advisor", "evals/advisor-router.jsonl");
44
+ const ROUTER_VERSION = 1;
45
+
46
+ // ── Binary gate model (trained from local session data) ──────────────────
47
+ const BINARY_GATE_PATH = featureFile("advisor", "binary-gate-model.json");
48
+ const BINARY_GATE_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../assets/binary-gate-model.json");
49
+ const BINARY_GATE_THRESHOLD = 0.55;
50
+
51
+ interface BinaryGateModel {
52
+ kind: string;
53
+ labels: string[];
54
+ features: string[];
55
+ idf: number[];
56
+ bias: number[];
57
+ weights: number[][];
58
+ }
59
+
60
+ let _binaryGateCache: BinaryGateModel | null | undefined = undefined;
61
+
62
+ function ensureBinaryGateSeeded(): void {
63
+ try {
64
+ if (!existsSync(BINARY_GATE_SOURCE_PATH)) return;
65
+ const sourceStat = statSync(BINARY_GATE_SOURCE_PATH);
66
+ if (existsSync(BINARY_GATE_PATH)) {
67
+ const installedStat = statSync(BINARY_GATE_PATH);
68
+ if (installedStat.mtimeMs >= sourceStat.mtimeMs && installedStat.size === sourceStat.size) return;
69
+ }
70
+ mkdirSync(dirname(BINARY_GATE_PATH), { recursive: true });
71
+ copyFileSync(BINARY_GATE_SOURCE_PATH, BINARY_GATE_PATH);
72
+ } catch {
73
+ // best effort: if the seed copy fails, fall back to the installed path if present
74
+ }
75
+ }
76
+
77
+ function loadBinaryGate(): BinaryGateModel | null {
78
+ if (_binaryGateCache !== undefined) return _binaryGateCache;
79
+ try {
80
+ ensureBinaryGateSeeded();
81
+ if (!existsSync(BINARY_GATE_PATH)) return null;
82
+ _binaryGateCache = JSON.parse(readFileSync(BINARY_GATE_PATH, "utf8")) as BinaryGateModel;
83
+ if (_binaryGateCache.kind !== "binary-logreg-v1") { _binaryGateCache = null; return null; }
84
+ return _binaryGateCache;
85
+ } catch { _binaryGateCache = null; return null; }
86
+ }
87
+
88
+ function binaryGateTokens(text: string): string[] {
89
+ const norm = String(text ?? "").toLowerCase()
90
+ .replace(/https?:\/\/\S+/g, " url ")
91
+ .replace(/[^a-z0-9\s']/g, " ")
92
+ .replace(/\s+/g, " ").trim();
93
+ return norm ? norm.split(" ").filter(Boolean) : [];
94
+ }
95
+
96
+ function binaryGateFeatures(text: string, model: BinaryGateModel) {
97
+ const toks = binaryGateTokens(text);
98
+ const lower = String(text ?? "").toLowerCase()
99
+ .replace(/https?:\/\/\S+/g, " url ")
100
+ .replace(/[^a-z0-9\s']/g, " ")
101
+ .replace(/\s+/g, " ").trim();
102
+ const counts = new Map<string, number>();
103
+ const inc = (k: string, b = 1) => counts.set(k, (counts.get(k) || 0) + b);
104
+ for (const n of [1, 2]) {
105
+ if (toks.length >= n) for (let i = 0; i <= toks.length - n; i++)
106
+ inc(`w${n}:${toks.slice(i, i + n).join("_")}`);
107
+ }
108
+ const norm = ` ${lower} `;
109
+ for (const n of [3, 4]) {
110
+ if (norm.length >= n) for (let i = 0; i <= norm.length - n; i++) {
111
+ const g = norm.slice(i, i + n);
112
+ if (!/^\s+$/.test(g)) inc(`c${n}:${g}`);
113
+ }
114
+ }
115
+ if (toks.length > 0) inc(`pref1:${toks[0]}`);
116
+ if (toks.length > 1) inc(`pref2:${toks.slice(0, 2).join("_")}`);
117
+ if (toks.length > 2) inc(`pref3:${toks.slice(0, 3).join("_")}`);
118
+ if (text.includes("?")) inc("cue:question_mark");
119
+ const cues = ["check","why","what","how","should","status","stats","log","logs","review","diff","pr","build","run","test","deploy","fix","debug","install","configure","plan","continue","resume","compact","research","update","patch","cleanup","remove"];
120
+ const multi = ["what is","what's","safe to use","pull request","model family","how does","next step","path forward","should we","what should"];
121
+ const ts = new Set(toks);
122
+ for (const c of cues) if (ts.has(c)) inc(`cue:${c}`);
123
+ for (const c of multi) if (lower.includes(c)) inc(`cue:${c.replace(/\s+/g,"_")}`);
124
+
125
+ const index = new Map(model.features.map((f, i) => [f, i]));
126
+ const pairs: Array<[number, number]> = [];
127
+ let nrm = 0;
128
+ for (const [feature, tf] of counts) {
129
+ const idx = index.get(feature);
130
+ if (idx === undefined) continue;
131
+ const value = (1 + Math.log(tf)) * model.idf[idx];
132
+ pairs.push([idx, value]); nrm += value * value;
133
+ }
134
+ const scale = nrm > 0 ? 1 / Math.sqrt(nrm) : 1;
135
+ pairs.sort((a, b) => a[0] - b[0]);
136
+ return { I: pairs.map(([i]) => i), V: pairs.map(([, v]) => v * scale) };
137
+ }
138
+
139
+ export function binaryGatePredict(text: string): { decision: "continue" | "escalate"; confidence: number } | null {
140
+ const model = loadBinaryGate();
141
+ if (!model) return null;
142
+ const vec = binaryGateFeatures(text, model);
143
+ const scores = model.bias.slice();
144
+ for (let c = 0; c < model.weights.length; c++) {
145
+ let score = scores[c]; const w = model.weights[c];
146
+ for (let i = 0; i < vec.I.length; i++) score += w[vec.I[i]] * vec.V[i];
147
+ scores[c] = score;
148
+ }
149
+ const maxS = Math.max(...scores);
150
+ const exps = scores.map((v) => Math.exp(v - maxS));
151
+ const sum = exps.reduce((a, b) => a + b, 0) || 1;
152
+ const probs = exps.map((v) => v / sum);
153
+ const idx = probs[0] >= probs[1] ? 0 : 1;
154
+ return { decision: model.labels[idx] as "continue" | "escalate", confidence: probs[idx] };
155
+ }
156
+
157
+ const QUICK_EDIT_RE = /\b(quick edit|small edit|tiny edit|rename|format(?:ting)?|lint|style|doc(?:s)?|comment|typo|readme|spell|spacing|cleanup|one[- ]?liner)\b/i;
158
+ const ROUTINE_CLEANUP_RE = /\b(routine docs?|docs? and formatting|formatting cleanup|generated changes|large diff|docs?\/formatting)\b/i;
159
+ const COMPLEX_RE = /\b(architecture|architectural|refactor|design|trade[- ]?off|concurrency|security|auth|migration|performance|scale|scalability|framework|system design|schema|data model|protocol|advisor routing|advisor flow|router logic|call vs skip|skip vs call|compare|recommend|benchmark|evaluate|experiment|train|strategy|choose|make sense|worth(?: it)?|kpi|kpis|how it works|where it comes from|what would you choose|what do you think|next step|pick between|buy|usage|sustained speed|available models|running model kpis)\b/i;
160
+ const DEBUG_RE = /\b(debug|bug|error|stack trace|traceback|fail(?:ed|ure)?|broken|investigate|why is|cannot|can't|crash|regression)\b/i;
161
+ const CONTEXT_RE = /\b(need more context|missing context|clarify|not enough info|unspecified|unknown|ambiguous)\b/i;
162
+ const SAFETY_RE = /\b(rm\s+-rf|sudo\b|shutdown\b|reboot\b|mkfs(?:\.[\w-]+)?\b|chmod\s+-R\b|chown\b|git\s+push\b[\s\S]*--force(?:-with-lease)?|curl\b[\s\S]*\|\s*(?:sh|bash)\b|wget\b[\s\S]*\|\s*(?:sh|bash)\b|drop\s+table\b|delete\s+database\b|secret\b|token\b|credential\b|password\b)\b/i;
163
+ const COMPACTION_RE = /\b(compact(?:ed|ion)?|missing history|history might flip|prior constraint|resume(?:d)? after compaction)\b/i;
164
+ const REASSURANCE_RE = /\b(reassurance|confidence|increase confidence|already know the likely answer|just for reassurance|main model already gives a solid answer|solid answer)\b/i;
165
+ const CHECKPOINT_RE = /\b(checkpoint|multi-step implementation|clearer boundary|interrupt now|wait until there is a clearer boundary|mid implementation)\b/i;
166
+ const CHEAP_SIGNAL_RE = /\b(cheap extra signal|exact diff plus exact error|exact diff|exact error|recent error in the session history|recent history)\b/i;
167
+ const CLOSEOUT_RE = /\b(closeout|on[- ]?track|course[- ]?correct|not[- ]?done|should this be marked|mostly done|mostly complete|needs changes before closeout|needs changes|needs correction|review judgment)\b/i;
168
+ const REVIEW_NEEDS_WORK_RE = /\b(todo|wip|incomplete|missing|broken|fails?|error|bug|revise|adjust|fix(?:ed)?\s+needed|not done|still open|needs changes|needs correction|course[- ]?correct)\b/i;
169
+ const DONE_RE = /\b(done|complete(?:d)?|fixed|implemented|works?|passing tests?|tests pass|verified|looks good|merged)\b/i;
170
+
171
+ function squish(text: unknown, max = 220): string {
172
+ const value = String(text ?? "").replace(/\s+/g, " ").trim();
173
+ return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
174
+ }
175
+
176
+ function hashText(...parts: string[]): string {
177
+ return createHash("sha256").update(parts.join("||")).digest("hex").slice(0, 16);
178
+ }
179
+
180
+ function clampConfidence(value: unknown, fallback: number): number {
181
+ const n = Number(value);
182
+ if (!Number.isFinite(n)) return fallback;
183
+ return Math.max(0, Math.min(1, n));
184
+ }
185
+
186
+ function normalizePhaseLabel(phase: AdvisorPhase, label: unknown): PreflightLabel | ReviewLabel | null {
187
+ const value = String(label ?? "").trim();
188
+ if (phase === "preflight") {
189
+ return value === "continue" || value === "escalate_to_advisor" || value === "need_more_context" || value === "low_confidence"
190
+ ? value
191
+ : null;
192
+ }
193
+ return value === "on_track" || value === "course_correct" || value === "not_done" || value === "abstain"
194
+ ? value
195
+ : null;
196
+ }
197
+
198
+ function preflightPolicy(label: PreflightLabel): PreflightPolicy {
199
+ switch (label) {
200
+ case "continue": return "off";
201
+ case "escalate_to_advisor": return "full";
202
+ case "need_more_context": return "direct";
203
+ case "low_confidence": return "light";
204
+ }
205
+ }
206
+
207
+ function reviewPolicy(label: ReviewLabel): ReviewPolicy {
208
+ switch (label) {
209
+ case "on_track": return "off";
210
+ case "course_correct": return "light";
211
+ case "not_done": return "strict";
212
+ case "abstain": return "off";
213
+ }
214
+ }
215
+
216
+ function isSafetySensitive(text: string): boolean {
217
+ return SAFETY_RE.test(text);
218
+ }
219
+
220
+ function hasQuickEditSignal(text: string): boolean {
221
+ return QUICK_EDIT_RE.test(text);
222
+ }
223
+
224
+ function hasRoutineCleanupSignal(text: string): boolean {
225
+ return ROUTINE_CLEANUP_RE.test(text);
226
+ }
227
+
228
+ function hasComplexSignal(text: string): boolean {
229
+ return COMPLEX_RE.test(text) || DEBUG_RE.test(text);
230
+ }
231
+
232
+ function hasCompactionLowRiskSignal(text: string): boolean {
233
+ return COMPACTION_RE.test(text) && /\blow[- ]?risk\b/i.test(text);
234
+ }
235
+
236
+ function hasReassuranceOnlySignal(text: string): boolean {
237
+ return REASSURANCE_RE.test(text) && !/\b(material|flip|decision|risk|uncertain|flip the decision)\b/i.test(text);
238
+ }
239
+
240
+ function hasCheckpointSignal(text: string): boolean {
241
+ return CHECKPOINT_RE.test(text) && !/\b(risky|security|irreversible|unknown dependency|hidden dependency)\b/i.test(text);
242
+ }
243
+
244
+ function hasCheapSignalMaterialSignal(text: string): boolean {
245
+ return CHEAP_SIGNAL_RE.test(text) && /\b(materially|flip the decision|decision-changing|materially changes|could change|main model is useless|main model already gives a solid answer|solid answer)\b/i.test(text);
246
+ }
247
+
248
+ function hasCheapSignalIrrelevantSignal(text: string): boolean {
249
+ return CHEAP_SIGNAL_RE.test(text) && /\b(should not change|shouldn'?t change|not materially|already gives a solid answer|solid answer)\b/i.test(text);
250
+ }
251
+
252
+ function needsContext(text: string): boolean {
253
+ return CONTEXT_RE.test(text) || text.trim().length < 18 || text.trim().split(/\s+/).length < 4;
254
+ }
255
+
256
+ function reviewSignals(input: AdvisorRouteInput): { label: ReviewLabel; confidence: number; reason: string } {
257
+ const text = `${input.text}\n${input.brief ?? ""}`.trim();
258
+ if (input.failed) {
259
+ return { label: "not_done", confidence: 0.95, reason: "Turn reported failure." };
260
+ }
261
+ if (input.phase === "closeout" && CLOSEOUT_RE.test(text)) {
262
+ return { label: /\bnot[- ]?done\b/i.test(text) ? "not_done" : "course_correct", confidence: 0.86, reason: "Closeout judgment requested." };
263
+ }
264
+ if (REVIEW_NEEDS_WORK_RE.test(text)) {
265
+ return { label: /\b(not done|incomplete|wip|todo|still open)\b/i.test(text) ? "not_done" : "course_correct", confidence: 0.83, reason: "Needs-work signal detected." };
266
+ }
267
+ if (input.fileChanged && DONE_RE.test(text)) {
268
+ return { label: "on_track", confidence: 0.84, reason: "Change looks complete." };
269
+ }
270
+ if (DONE_RE.test(text)) {
271
+ return { label: "on_track", confidence: 0.72, reason: "Completion signal detected." };
272
+ }
273
+ return { label: "abstain", confidence: 0.56, reason: "Insufficient review signal." };
274
+ }
275
+
276
+ function preflightSignals(input: AdvisorRouteInput): { label: PreflightLabel; confidence: number; reason: string; safety: boolean } {
277
+ const text = `${input.text}\n${input.brief ?? ""}`.trim();
278
+ if (isSafetySensitive(text)) {
279
+ return { label: "escalate_to_advisor", confidence: 0.98, reason: "Safety-sensitive keywords detected.", safety: true };
280
+ }
281
+ if (hasRoutineCleanupSignal(text) || (hasQuickEditSignal(text) && !hasComplexSignal(text))) {
282
+ return { label: "continue", confidence: 0.9, reason: "Small-edit or routine-cleanup signal detected.", safety: false };
283
+ }
284
+ if (hasCompactionLowRiskSignal(text)) {
285
+ return { label: "continue", confidence: 0.84, reason: "Low-risk compaction boundary; advisor not needed.", safety: false };
286
+ }
287
+ if (hasReassuranceOnlySignal(text) || hasCheckpointSignal(text) || hasCheapSignalIrrelevantSignal(text)) {
288
+ return { label: "continue", confidence: 0.82, reason: "Main model should handle this without advisor.", safety: false };
289
+ }
290
+ if (hasCheapSignalMaterialSignal(text) || /\b(advisor-router|advisor flow|advisor routing|router logic|call vs skip|skip vs call|compare|recommend|benchmark|evaluate|experiment|train|research)\b/i.test(text)) {
291
+ return { label: "escalate_to_advisor", confidence: 0.88, reason: "Advisor-specific or decision-changing work detected.", safety: false };
292
+ }
293
+ if (hasComplexSignal(text)) {
294
+ return { label: "escalate_to_advisor", confidence: 0.88, reason: "Complex or high-uncertainty work detected.", safety: false };
295
+ }
296
+ if (needsContext(text)) {
297
+ return { label: "need_more_context", confidence: 0.66, reason: "Prompt is too underspecified.", safety: false };
298
+ }
299
+ return { label: "low_confidence", confidence: 0.54, reason: "No strong routing signal.", safety: false };
300
+ }
301
+
302
+ export function heuristicRoute(input: AdvisorRouteInput): AdvisorRouteDecision {
303
+ if (input.phase === "review" || input.phase === "closeout") {
304
+ const result = reviewSignals(input);
305
+ return {
306
+ phase: input.phase,
307
+ label: result.label,
308
+ confidence: result.confidence,
309
+ reason: result.reason,
310
+ source: "heuristic",
311
+ preflight: "off",
312
+ review: reviewPolicy(result.label),
313
+ escalate: result.label !== "on_track" && result.label !== "abstain",
314
+ safety: false,
315
+ promptHash: hashText(input.phase, input.text, input.brief ?? "", String(input.fileChanged ?? false), String(input.failed ?? false)),
316
+ promptSummary: squish(input.text, 220),
317
+ briefSummary: input.brief ? squish(input.brief, 220) : undefined,
318
+ };
319
+ }
320
+
321
+ const result = preflightSignals(input);
322
+ return {
323
+ phase: input.phase,
324
+ label: result.label,
325
+ confidence: result.confidence,
326
+ reason: result.reason,
327
+ source: "heuristic",
328
+ preflight: preflightPolicy(result.label),
329
+ review: result.label === "continue" ? "off" : result.label === "escalate_to_advisor" ? "light" : "light",
330
+ escalate: result.label === "escalate_to_advisor",
331
+ safety: result.safety,
332
+ promptHash: hashText(input.phase, input.text, input.brief ?? "", String(input.fileChanged ?? false), String(input.failed ?? false)),
333
+ promptSummary: squish(input.text, 220),
334
+ briefSummary: input.brief ? squish(input.brief, 220) : undefined,
335
+ };
336
+ }
337
+
338
+ export function shouldQueryClassifier(route: AdvisorRouteDecision): boolean {
339
+ if (route.safety) return false;
340
+ if (route.phase === "preflight") {
341
+ return route.label === "low_confidence" || route.label === "need_more_context" || route.confidence < 0.7;
342
+ }
343
+ return route.label === "abstain" || route.confidence < 0.68;
344
+ }
345
+
346
+ export function buildRouterPrompt(input: AdvisorRouteInput): string {
347
+ const phase = input.phase;
348
+ const labels = phase === "preflight"
349
+ ? "continue | escalate_to_advisor | need_more_context | low_confidence"
350
+ : "on_track | course_correct | not_done | abstain";
351
+
352
+ return [
353
+ "You are a routing classifier for a coding assistant. Return ONLY valid JSON.",
354
+ `Phase: ${phase}`,
355
+ `Allowed labels: ${labels}`,
356
+ "Format: {\"label\":\"...\",\"confidence\":0-1,\"reason\":\"...\"}",
357
+ `Task: ${squish(input.text, 800)}`,
358
+ input.brief ? `Recent context: ${squish(input.brief, 500)}` : "",
359
+ input.fileChanged !== undefined ? `File changed: ${String(input.fileChanged)}` : "",
360
+ input.failed !== undefined ? `Failed: ${String(input.failed)}` : "",
361
+ phase === "preflight"
362
+ ? [
363
+ "Guidance: continue for tiny edits and direct answers; escalate_to_advisor for architecture, design, tradeoffs, security, or high uncertainty; need_more_context when underspecified; low_confidence when mixed signals.",
364
+ ].join(" ")
365
+ : [
366
+ "Guidance: on_track for clearly complete work; course_correct for partial work that needs changes; not_done when incomplete or failing; abstain when there is not enough signal.",
367
+ ].join(" "),
368
+ ].filter(Boolean).join("\n");
369
+ }
370
+
371
+ export function parseRouterResponse(phase: AdvisorPhase, text: string): RouterResponse | null {
372
+ const raw = String(text ?? "").trim();
373
+ if (!raw) return null;
374
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
375
+ try {
376
+ const parsed = JSON.parse(cleaned) as { label?: unknown; confidence?: unknown; reason?: unknown; decision?: unknown };
377
+ const label = normalizePhaseLabel(phase, parsed.label ?? parsed.decision);
378
+ if (!label) return null;
379
+ return {
380
+ label,
381
+ confidence: clampConfidence(parsed.confidence, 0.5),
382
+ reason: typeof parsed.reason === "string" && parsed.reason.trim() ? parsed.reason.trim() : "Classifier returned no reason.",
383
+ };
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ export function mergeClassifierDecision(input: AdvisorRouteInput, decision: RouterResponse, source: RouterSource): AdvisorRouteDecision {
390
+ if (input.phase === "review" || input.phase === "closeout") {
391
+ const label = normalizePhaseLabel(input.phase, decision.label) as ReviewLabel | null;
392
+ const finalLabel: ReviewLabel = label ?? reviewSignals(input).label;
393
+ const confidence = clampConfidence(decision.confidence, 0.5);
394
+ return {
395
+ phase: input.phase,
396
+ label: finalLabel,
397
+ confidence,
398
+ reason: decision.reason?.trim() || "Classifier returned no reason.",
399
+ source,
400
+ preflight: "off",
401
+ review: reviewPolicy(finalLabel),
402
+ escalate: finalLabel !== "on_track" && finalLabel !== "abstain",
403
+ safety: false,
404
+ promptHash: hashText(input.phase, input.text, input.brief ?? "", String(input.fileChanged ?? false), String(input.failed ?? false)),
405
+ promptSummary: squish(input.text, 220),
406
+ briefSummary: input.brief ? squish(input.brief, 220) : undefined,
407
+ };
408
+ }
409
+
410
+ const label = normalizePhaseLabel(input.phase, decision.label) as PreflightLabel | null;
411
+ const finalLabel: PreflightLabel = label ?? preflightSignals(input).label;
412
+ const confidence = clampConfidence(decision.confidence, 0.5);
413
+ return {
414
+ phase: input.phase,
415
+ label: finalLabel,
416
+ confidence,
417
+ reason: decision.reason?.trim() || "Classifier returned no reason.",
418
+ source,
419
+ preflight: preflightPolicy(finalLabel),
420
+ review: finalLabel === "continue" ? "off" : finalLabel === "escalate_to_advisor" ? "light" : "light",
421
+ escalate: finalLabel === "escalate_to_advisor",
422
+ safety: isSafetySensitive(`${input.text}\n${input.brief ?? ""}`),
423
+ promptHash: hashText(input.phase, input.text, input.brief ?? "", String(input.fileChanged ?? false), String(input.failed ?? false)),
424
+ promptSummary: squish(input.text, 220),
425
+ briefSummary: input.brief ? squish(input.brief, 220) : undefined,
426
+ };
427
+ }
428
+
429
+ export function routeLogEntry(route: AdvisorRouteDecision): Record<string, unknown> {
430
+ return {
431
+ at: new Date().toISOString(),
432
+ version: ROUTER_VERSION,
433
+ phase: route.phase,
434
+ label: route.label,
435
+ confidence: route.confidence,
436
+ reason: truncate(route.reason, 240),
437
+ source: route.source,
438
+ safety: route.safety,
439
+ escalate: route.escalate,
440
+ preflight: route.preflight,
441
+ review: route.review,
442
+ promptHash: route.promptHash,
443
+ prompt: route.promptSummary,
444
+ brief: route.briefSummary,
445
+ };
446
+ }
447
+
448
+ export function appendRouteLog(route: AdvisorRouteDecision): void {
449
+ appendText(ROUTER_LOG_PATH, `${JSON.stringify(routeLogEntry(route))}\n`);
450
+ }
451
+
452
+ export type AdvisorDisplayDecision = "continue" | "review" | "defer";
453
+ export type AdvisorDisplayTag = "advisor:model" | "advisor:rules" | "advisor:llm";
454
+
455
+ function displayDecision(route: AdvisorRouteDecision): AdvisorDisplayDecision {
456
+ if (route.phase === "preflight") {
457
+ switch (route.label as PreflightLabel) {
458
+ case "continue": return "continue";
459
+ case "escalate_to_advisor": return "review";
460
+ case "need_more_context": return "defer";
461
+ case "low_confidence": return "defer";
462
+ }
463
+ }
464
+
465
+ switch (route.label as ReviewLabel) {
466
+ case "on_track": return "continue";
467
+ case "course_correct": return "review";
468
+ case "not_done": return "review";
469
+ case "abstain": return "defer";
470
+ }
471
+ }
472
+
473
+ function displayTag(route: AdvisorRouteDecision | AdvisorDisplayTag): AdvisorDisplayTag {
474
+ if (typeof route === "string") return route;
475
+ switch (route.source) {
476
+ case "model": return "advisor:model";
477
+ case "llm": return "advisor:llm";
478
+ default: return "advisor:rules";
479
+ }
480
+ }
481
+
482
+ export function formatAdvisorDisplay(tag: AdvisorDisplayTag, decision: AdvisorDisplayDecision, explanation: string): string {
483
+ const text = squish(explanation || "no extra detail", 140).toLowerCase();
484
+ return `[${tag}: ${decision}, reason: ${text}]`;
485
+ }
486
+
487
+ export function routeNote(route: AdvisorRouteDecision): string {
488
+ const explanation = route.reason || (route.phase === "preflight"
489
+ ? route.label === "continue"
490
+ ? "routine work can continue without advisor attention"
491
+ : route.label === "escalate_to_advisor"
492
+ ? "complex or high-risk work needs advisor review"
493
+ : route.label === "need_more_context"
494
+ ? "more context is needed before routing confidently"
495
+ : "signal is mixed, so defer the decision"
496
+ : route.label === "on_track"
497
+ ? "work looks on track and can continue"
498
+ : route.label === "course_correct"
499
+ ? "work is progressing but needs review"
500
+ : route.label === "not_done"
501
+ ? "work is incomplete or failing and needs review"
502
+ : "review signal is too weak to decide");
503
+ return formatAdvisorDisplay(displayTag(route), displayDecision(route), explanation);
504
+ }
505
+
506
+ export function mergeReviewPolicy(base: ReviewPolicy, route: ReviewPolicy): ReviewPolicy {
507
+ if (base === "off") return "off";
508
+ if (route === "strict") return "strict";
509
+ if (route === "light") return base === "strict" ? "strict" : "light";
510
+ return base;
511
+ }
512
+
513
+ export function summarizeRoute(route: AdvisorRouteDecision): string {
514
+ const phase = route.phase === "closeout" ? "closeout" : route.phase;
515
+ return `${phase}:${route.label} (${Math.round(route.confidence * 100)}%)`;
516
+ }