@fiale-plus/pi-rogue 0.2.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 (68) hide show
  1. package/README.md +50 -0
  2. package/node_modules/@fiale-plus/pi-core/README.md +13 -0
  3. package/node_modules/@fiale-plus/pi-core/package.json +25 -0
  4. package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +109 -0
  5. package/node_modules/@fiale-plus/pi-core/src/index.ts +5 -0
  6. package/node_modules/@fiale-plus/pi-core/src/paths.ts +36 -0
  7. package/node_modules/@fiale-plus/pi-core/src/risk.test.ts +129 -0
  8. package/node_modules/@fiale-plus/pi-core/src/risk.ts +97 -0
  9. package/node_modules/@fiale-plus/pi-core/src/storage.ts +39 -0
  10. package/node_modules/@fiale-plus/pi-core/src/text.test.ts +36 -0
  11. package/node_modules/@fiale-plus/pi-core/src/text.ts +14 -0
  12. package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +59 -0
  13. package/node_modules/@fiale-plus/pi-rogue-advisor/advisor/index.ts +1 -0
  14. package/node_modules/@fiale-plus/pi-rogue-advisor/assets/binary-gate-model.json +24026 -0
  15. package/node_modules/@fiale-plus/pi-rogue-advisor/package.json +50 -0
  16. package/node_modules/@fiale-plus/pi-rogue-advisor/skills/advisor/SKILL.md +51 -0
  17. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +19 -0
  18. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +248 -0
  19. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate.test.ts +66 -0
  20. package/node_modules/@fiale-plus/pi-rogue-advisor/src/completions.test.ts +28 -0
  21. package/node_modules/@fiale-plus/pi-rogue-advisor/src/completions.ts +79 -0
  22. package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.test.ts +364 -0
  23. package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +1677 -0
  24. package/node_modules/@fiale-plus/pi-rogue-advisor/src/index.ts +3 -0
  25. package/node_modules/@fiale-plus/pi-rogue-advisor/src/internal.ts +63 -0
  26. package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +512 -0
  27. package/node_modules/@fiale-plus/pi-rogue-advisor/src/preflight-signals.test.ts +22 -0
  28. package/node_modules/@fiale-plus/pi-rogue-advisor/src/preflight-signals.ts +21 -0
  29. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +126 -0
  30. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +580 -0
  31. package/node_modules/@fiale-plus/pi-rogue-advisor/src/state-versioning.test.ts +227 -0
  32. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +53 -0
  33. package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +31 -0
  34. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +749 -0
  35. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +818 -0
  36. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +191 -0
  37. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +302 -0
  38. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +369 -0
  39. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +122 -0
  40. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +561 -0
  41. package/node_modules/@fiale-plus/pi-rogue-orchestration/README.md +56 -0
  42. package/node_modules/@fiale-plus/pi-rogue-orchestration/orchestration/index.ts +1 -0
  43. package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +44 -0
  44. package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +44 -0
  45. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.test.ts +142 -0
  46. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.ts +102 -0
  47. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch-state.ts +70 -0
  48. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch.test.ts +143 -0
  49. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch.ts +139 -0
  50. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/completions.test.ts +23 -0
  51. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/completions.ts +53 -0
  52. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/extension.ts +23 -0
  53. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal-resolution.ts +36 -0
  54. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +182 -0
  55. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +232 -0
  56. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/index.ts +1 -0
  57. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/internal.ts +98 -0
  58. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +274 -0
  59. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +35 -0
  60. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +145 -0
  61. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/state.ts +24 -0
  62. package/package.json +51 -0
  63. package/src/context-broker-file.ts +1 -0
  64. package/src/context-broker-sqlite.ts +1 -0
  65. package/src/context-broker.ts +1 -0
  66. package/src/extension.test.ts +68 -0
  67. package/src/extension.ts +27 -0
  68. package/src/index.ts +1 -0
@@ -0,0 +1,1677 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Box, Text } from "@earendil-works/pi-tui";
6
+ import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
7
+ import { Type } from "typebox";
8
+ import { featureFile, readText, truncate, writeText, atomicWriteText } from "./internal.js";
9
+ import { advisorArgumentCompletions, piRogueArgumentCompletions } from "./completions.js";
10
+ import {
11
+ appendRouteLog,
12
+ binaryGatePredict,
13
+ formatAdvisorDisplay,
14
+ heuristicRoute,
15
+ mergeReviewPolicy,
16
+ routeNote,
17
+ summarizeRoute,
18
+ type AdvisorRouteDecision,
19
+ type AdvisorRouteInput,
20
+ type ReviewPolicy,
21
+ } from "./router.js";
22
+ import { classifyIntent, classifyMode } from "./preflight-signals.js";
23
+
24
+ // ── Config: 3 optional fields ────────────────────────────────────────────
25
+
26
+ export interface AdvisorConfig {
27
+ /** "auto" (preflight+post+cache), "manual" (just /advisor), "off" */
28
+ mode: "auto" | "manual" | "off";
29
+ /** "light" (file changes/errors only) | "strict" (every 3 turns) | "off" */
30
+ review: "light" | "strict" | "off";
31
+ /** Opportunistic advisor check-ins during long sessions. */
32
+ checkins: "mid-hour" | "off";
33
+ /** Minutes between check-ins; bounded and cheap-gated by recent activity. */
34
+ checkinIntervalMinutes: number;
35
+ /** Optional start time (ms since epoch) for the active check-in stream. */
36
+ checkinStartedAt?: number;
37
+ /** Optional model override. Auto-detects SOTA (gpt-5.5, claude-opus-4-6…) if unset */
38
+ model?: string;
39
+ }
40
+
41
+ const DEFAULT_CONFIG: AdvisorConfig = {
42
+ mode: "auto",
43
+ review: "light",
44
+ checkins: "off",
45
+ checkinIntervalMinutes: 30,
46
+ };
47
+
48
+ const CONFIG_PATH = featureFile("advisor", "config.json");
49
+ const STATE_PATH = featureFile("advisor", "state.json");
50
+ const CACHE_PATH = featureFile("advisor", "cache.json");
51
+ const CURRENT_PATH = featureFile("advisor", "current.md");
52
+ const HISTORY_PATH = featureFile("advisor", "history.jsonl");
53
+ const ORCHESTRATION_DIR = join(homedir(), ".pi", "agent", "fiale-plus", "orchestration");
54
+
55
+ const MAX_CACHE = 64;
56
+ const MAX_NOTES = 12;
57
+ const MAX_FILES = 8;
58
+ const MAX_ERRORS = 5;
59
+ const MIN_CHECKIN_INTERVAL_MINUTES = 10;
60
+ const MAX_CHECKIN_INTERVAL_MINUTES = 240;
61
+ const STATE_VERSION = 1;
62
+ const checkinLocks = new Set<string>();
63
+
64
+ const REVIEW_TASK_ACTIONS_LIMIT = 2;
65
+ const ADVISORY_SIGNALS_LIMIT = 4;
66
+
67
+ // ── SOTA models (ordered by preference) ───────────────────────────────────
68
+ const SOTA_CHAIN: Array<{ provider: string; model: string; label: string }> = [
69
+ { provider: "openai-codex", model: "gpt-5.5", label: "GPT-5.5 (Codex)" },
70
+ { provider: "anthropic", model: "claude-opus-4-6", label: "Claude Opus 4.6" },
71
+ { provider: "anthropic", model: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
72
+ { provider: "openai-codex", model: "gpt-5.4-mini", label: "GPT-5.4 Mini" },
73
+ ];
74
+
75
+ // ── Internal state ────────────────────────────────────────────────────────
76
+ interface SessionState {
77
+ /** State schema version for migration support */
78
+ _v?: number;
79
+ turns: number;
80
+ lastTask: string;
81
+ notes: string[];
82
+ files: string[];
83
+ errors: string[];
84
+ advisorCalls: number;
85
+ cacheHits: number;
86
+ followUp: string;
87
+ followUpTask?: string;
88
+ reviewSignals: string[];
89
+ reviewSignalsTask?: string;
90
+ router: {
91
+ preflight?: AdvisorRouteDecision;
92
+ review?: AdvisorRouteDecision;
93
+ };
94
+ checkin: {
95
+ lastAt?: string;
96
+ lastTurn?: number;
97
+ lastReason?: string;
98
+ queued?: boolean;
99
+ queuedReason?: string;
100
+ };
101
+ reviewControl: ReviewControlState;
102
+ advisorPauseUntilTurn?: number;
103
+ }
104
+ function defaultReviewControl(): ReviewControlState {
105
+ return {
106
+ status: "idle",
107
+ pending: false,
108
+ consumed: true,
109
+ running: false,
110
+ };
111
+ }
112
+
113
+ function defaultState(): SessionState {
114
+ return {
115
+ turns: 0,
116
+ lastTask: "",
117
+ notes: [],
118
+ files: [],
119
+ errors: [],
120
+ advisorCalls: 0,
121
+ cacheHits: 0,
122
+ followUp: "",
123
+ followUpTask: undefined,
124
+ reviewSignals: [],
125
+ reviewSignalsTask: undefined,
126
+ router: {},
127
+ checkin: { queued: false },
128
+ reviewControl: defaultReviewControl(),
129
+ };
130
+ }
131
+
132
+ // ── File I/O ──────────────────────────────────────────────────────────────
133
+ function readJson<T>(path: string, fallback: T): T {
134
+ try {
135
+ return JSON.parse(readText(path) || "null") ?? fallback;
136
+ } catch {
137
+ return fallback;
138
+ }
139
+ }
140
+
141
+ function writeJson(path: string, v: unknown) {
142
+ writeText(path, JSON.stringify(v, null, 2) + "\n");
143
+ }
144
+
145
+ export function normalizeAdvisorConfig(raw: Partial<AdvisorConfig> = {}): AdvisorConfig {
146
+ const interval = Number(raw.checkinIntervalMinutes ?? DEFAULT_CONFIG.checkinIntervalMinutes);
147
+ const startedAt = Number(raw.checkinStartedAt);
148
+ return {
149
+ mode: (raw.mode === "manual" || raw.mode === "off") ? raw.mode : "auto",
150
+ review: (raw.review === "strict" || raw.review === "off") ? raw.review : "light",
151
+ checkins: raw.checkins === "mid-hour" ? "mid-hour" : DEFAULT_CONFIG.checkins,
152
+ checkinIntervalMinutes: Math.min(
153
+ MAX_CHECKIN_INTERVAL_MINUTES,
154
+ Math.max(
155
+ MIN_CHECKIN_INTERVAL_MINUTES,
156
+ Number.isFinite(interval) ? Math.round(interval) : DEFAULT_CONFIG.checkinIntervalMinutes,
157
+ ),
158
+ ),
159
+ checkinStartedAt: Number.isFinite(startedAt) ? startedAt : undefined,
160
+ model: raw.model || undefined,
161
+ };
162
+ }
163
+
164
+ function loadConfig(): AdvisorConfig {
165
+ return normalizeAdvisorConfig(readJson<Partial<AdvisorConfig>>(CONFIG_PATH, {}));
166
+ }
167
+
168
+ function saveConfig(c: AdvisorConfig) {
169
+ writeJson(CONFIG_PATH, c);
170
+ }
171
+
172
+ function loadState(): SessionState {
173
+ const raw = readJson<Partial<SessionState>>(STATE_PATH, {});
174
+ // Handle state versioning: migrate old versions to current
175
+ const version = raw._v ?? 0;
176
+ if (version < STATE_VERSION) {
177
+ // Migrate: ensure reviewControl has all fields
178
+ if (raw.reviewControl && !raw.reviewControl.lastAppliedAt) {
179
+ (raw.reviewControl as any).lastAppliedAt = new Date().toISOString();
180
+ }
181
+ }
182
+ const control = raw.reviewControl;
183
+ const pauseUntil = Number(raw.advisorPauseUntilTurn);
184
+ return {
185
+ _v: STATE_VERSION,
186
+ turns: raw.turns ?? 0,
187
+ lastTask: raw.lastTask ?? "",
188
+ notes: (raw.notes ?? []).map(noteText).filter(Boolean).slice(-MAX_NOTES),
189
+ files: (raw.files ?? []).slice(-MAX_FILES),
190
+ errors: (raw.errors ?? []).slice(-MAX_ERRORS),
191
+ advisorCalls: raw.advisorCalls ?? 0,
192
+ cacheHits: raw.cacheHits ?? 0,
193
+ followUp: raw.followUp ?? "",
194
+ followUpTask: raw.followUpTask,
195
+ reviewSignals: Array.isArray(raw.reviewSignals) ? raw.reviewSignals.map((line: unknown) => sanitizeAdvisorText(line).trim()).filter(Boolean).slice(-MAX_NOTES) : [],
196
+ reviewSignalsTask: raw.reviewSignalsTask,
197
+ router: {
198
+ preflight: raw.router?.preflight,
199
+ review: raw.router?.review,
200
+ },
201
+ checkin: {
202
+ lastAt: raw.checkin?.lastAt,
203
+ lastTurn: raw.checkin?.lastTurn,
204
+ lastReason: raw.checkin?.lastReason,
205
+ queued: Boolean(raw.checkin?.queued),
206
+ queuedReason: raw.checkin?.queuedReason,
207
+ },
208
+ reviewControl: {
209
+ status: (control?.status === "needed" || control?.status === "running" || control?.status === "consumed" || control?.status === "idle") ? control.status : "idle",
210
+ pending: Boolean(control?.pending),
211
+ consumed: control?.consumed !== false,
212
+ running: Boolean(control?.running),
213
+ lastDecision: control?.lastDecision,
214
+ lastMaterialSignature: control?.lastMaterialSignature,
215
+ lastReason: control?.lastReason,
216
+ lastTrigger: control?.lastTrigger,
217
+ lastAppliedAt: control?.lastAppliedAt,
218
+ },
219
+ advisorPauseUntilTurn: Number.isFinite(pauseUntil) ? pauseUntil : undefined,
220
+ };
221
+ }
222
+
223
+ function saveState(s: SessionState) {
224
+ atomicWriteText(STATE_PATH, JSON.stringify(s, null, 2) + "\n");
225
+ }
226
+
227
+ function loadCache(): Record<string, string> {
228
+ return readJson<Record<string, string>>(CACHE_PATH, {});
229
+ }
230
+
231
+ function saveCache(c: Record<string, string>) {
232
+ const entries = Object.entries(c);
233
+ if (entries.length > MAX_CACHE) {
234
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
235
+ for (const [k] of entries.slice(0, entries.length - MAX_CACHE)) delete c[k];
236
+ }
237
+ atomicWriteText(CACHE_PATH, JSON.stringify(c, null, 2) + "\n");
238
+ }
239
+
240
+ // ── Prompts ───────────────────────────────────────────────────────────────
241
+
242
+ const ADVISOR_SYSTEM = `You are a senior engineering advisor. Use the session brief only. Return terse, specific advice with concrete recommendations. 200 words max.
243
+
244
+ ## Guidance
245
+ - Focus on actionable insights, not summaries of what was done.
246
+ - If no issues found, say so briefly — do not invent problems.
247
+ - Flag security concerns, architecture risks, and test gaps.
248
+ - Reference specific files or lines when possible.`;
249
+
250
+ const REVIEW_SYSTEM = `You are a senior reviewer. An AI agent just completed work. Return ONLY valid JSON.
251
+
252
+ ## Required shape
253
+ {
254
+ "task": "exact active task",
255
+ "verdict": "on_track|course_correct|not_done|skip",
256
+ "task_actions": ["task-critical action"],
257
+ "advisory_signals": ["non-blocking signal"],
258
+ "pivot": {
259
+ "recommended": false,
260
+ "blocking": false,
261
+ "rationale": "why this is a pivot"
262
+ },
263
+ "summary": "short review summary",
264
+ "reason": "same as summary if different",
265
+ "notify": false
266
+ }
267
+
268
+ ## Rules
269
+ - Preserve and prioritize the active task before output decisions.
270
+ - Only list truly required "task_actions" that move the original task forward.
271
+ - Put useful but non-commanding findings in "advisory_signals".
272
+ - Put pivots in "pivot"; only set blocking=true when there is an explicit security/data-loss risk, impossible prerequisite, or clear goal divergence.
273
+ - Non-blocking pivot is not a command to switch tasks. If blocking pivots are recommended, include explicit rationale and require user confirmation before switching.
274
+ `;
275
+
276
+ // ── Helpers ───────────────────────────────────────────────────────────────
277
+
278
+ function hash(...parts: string[]): string {
279
+ return createHash("sha256").update(parts.join("||")).digest("hex").slice(0, 16);
280
+ }
281
+
282
+ function brief(s: SessionState): string {
283
+ const lines: string[] = [];
284
+ if (s.lastTask) lines.push(`Task: ${truncate(sanitizeAdvisorText(s.lastTask), 200)}`);
285
+ if (s.turns) lines.push(`Turns: ${s.turns}`);
286
+ if (s.notes.length) { lines.push("Notes:"); s.notes.slice(-4).forEach(n => lines.push(`- ${truncate(n, 200)}`)); }
287
+ if (s.files.length) lines.push(`Files: ${sanitizeAdvisorText(s.files.slice(-4).join(", "))}`);
288
+ if (s.errors.length) lines.push(`Errors: ${sanitizeAdvisorText(s.errors.slice(-2).join(" | "))}`);
289
+ return lines.join("\n").slice(0, 1200);
290
+ }
291
+
292
+ function contextBrokerBrief(pi: ExtensionAPI): string {
293
+ try {
294
+ const text = (pi as any).__piRogueContextBroker?.renderBrief?.();
295
+ return typeof text === "string" && text.includes("ctx://") ? sanitizeAdvisorText(text).slice(0, 2400) : "";
296
+ } catch {
297
+ return "";
298
+ }
299
+ }
300
+
301
+ const CLIPBOARD_IMAGE_PATH_RE = /(?:\/(?:private\/)?var\/folders\/[^\s"'`<>]+\/T|\/(?:tmp|var\/tmp))\/clipboard-\d{4}-\d{2}-\d{2}-[A-Za-z0-9-]+\.(?:png|jpe?g|gif|webp)\b/g;
302
+
303
+ export function sanitizeAdvisorText(text: unknown): string {
304
+ return String(text ?? "").replace(CLIPBOARD_IMAGE_PATH_RE, "[clipboard image]");
305
+ }
306
+
307
+ function squish(t: unknown, max = 200): string {
308
+ const s = sanitizeAdvisorText(t).replace(/\s+/g, " ").trim();
309
+ return s.length <= max ? s : s.slice(0, max - 1).trimEnd() + "…";
310
+ }
311
+
312
+ function noteText(note: unknown): string {
313
+ const text = contentText(note);
314
+ if (/^\[object Object\](,\[object Object\])*$/.test(text)) return "";
315
+ if (text) return squish(text, 500);
316
+ if (note && typeof note === "object") return squish(JSON.stringify(note), 500);
317
+ return text;
318
+ }
319
+
320
+ function normalizeReviewSignals(materialSignals: string[] = []): string[] {
321
+ return [...new Set(materialSignals.filter(Boolean).map((signal) => squish(signal)))].sort();
322
+ }
323
+
324
+ function normalizeReviewList(values: unknown, limit = 4): string[] {
325
+ if (typeof values === "string") {
326
+ const trimmed = sanitizeAdvisorText(values).trim();
327
+ return trimmed ? [trimmed] : [];
328
+ }
329
+ if (!Array.isArray(values)) return [];
330
+ const out = values
331
+ .map((value) => sanitizeAdvisorText(value).trim())
332
+ .filter((value): value is string => value.length > 0)
333
+ .slice(0, limit);
334
+ return [...new Set(out.map((value) => squish(value, 220)))];
335
+ }
336
+
337
+ function normalizeReviewVerdict(raw: unknown): ReviewVerdict {
338
+ const value = String(raw ?? "").trim().toLowerCase();
339
+ if (value === "on_track" || value === "course_correct" || value === "not_done" || value === "skip") {
340
+ return value as ReviewVerdict;
341
+ }
342
+ return "course_correct";
343
+ }
344
+
345
+ function toBoolean(value: unknown): boolean {
346
+ return value === true || value === "true" || String(value).trim().toLowerCase() === "true";
347
+ }
348
+
349
+ function isBlockingPivotCandidate(raw: { recommended?: unknown; blocking?: unknown; rationale?: unknown }): boolean {
350
+ if (!toBoolean(raw.recommended) || !toBoolean(raw.blocking)) return false;
351
+ const reason = sanitizeAdvisorText(raw.rationale).toLowerCase();
352
+ if (!reason) return false;
353
+ return /(security|data[-_ ]?loss|irreversible|prerequisite|impossible|cannot\s+complete|does not align|goal divergence|clear divergence|risk of data|critical)/.test(reason);
354
+ }
355
+
356
+ function parsedPivot(raw: unknown): ParsedReviewPivot {
357
+ const pivot = (raw && typeof raw === "object") ? raw as Record<string, unknown> : {};
358
+ const rationale = sanitizeAdvisorText(pivot.rationale || pivot.reason || "").trim();
359
+ const blocking = toBoolean(pivot.blocking);
360
+ const candidate = {
361
+ recommended: toBoolean(pivot.recommended) || blocking,
362
+ blocking: false,
363
+ rationale,
364
+ confidence: Number(pivot.confidence),
365
+ requiresConfirmation: true,
366
+ };
367
+ const isAllowedBlock = isBlockingPivotCandidate({
368
+ recommended: pivot.recommended,
369
+ blocking: candidate.recommended && blocking,
370
+ rationale,
371
+ });
372
+ return {
373
+ ...candidate,
374
+ blocking: isAllowedBlock,
375
+ };
376
+ }
377
+
378
+ export function parseReviewPayload(raw: string, activeTask: string): ParsedReviewPayload | null {
379
+ try {
380
+ const text = String(raw || "").trim();
381
+ if (!text) return null;
382
+ const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
383
+ const parsed = JSON.parse(cleaned) as Record<string, unknown>;
384
+ if (!parsed || typeof parsed !== "object") return null;
385
+
386
+ const task = sanitizeAdvisorText(parsed.task || parsed.currentTask || activeTask || "").trim() || sanitizeAdvisorText(activeTask).trim();
387
+ const summary = sanitizeAdvisorText(parsed.summary).trim() || sanitizeAdvisorText(parsed.result).trim();
388
+ const reason = sanitizeAdvisorText(parsed.reason).trim() || sanitizeAdvisorText(parsed.notes).trim() || summary;
389
+ const verdict = normalizeReviewVerdict(parsed.verdict ?? "");
390
+ const taskActions = normalizeReviewList(parsed.task_actions ?? parsed.actions, REVIEW_TASK_ACTIONS_LIMIT);
391
+ const advisorySignals = normalizeReviewList(parsed.advisory_signals ?? [], ADVISORY_SIGNALS_LIMIT);
392
+ const pivot = parsedPivot(parsed.pivot as Record<string, unknown> | undefined);
393
+
394
+ return {
395
+ activeTask: task,
396
+ verdict,
397
+ taskActions,
398
+ advisorySignals,
399
+ pivot,
400
+ summary,
401
+ reason,
402
+ };
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ export function isTaskContinuation(previousTask: string, nextTask: string): boolean {
409
+ const prev = normalizeTask(previousTask);
410
+ const next = normalizeTask(nextTask);
411
+ if (!prev || !next) return true;
412
+ if (prev === next) return true;
413
+ return prev.includes(next) || next.includes(prev);
414
+ }
415
+
416
+ function normalizeTask(task: string): string {
417
+ return squish(task, 200).toLowerCase();
418
+ }
419
+
420
+ function reviewMaterialSignature(state: SessionState, delta: string, meta: ReviewMaterialMeta): string {
421
+ const signals = normalizeReviewSignals(meta.materialSignals);
422
+ return hash(
423
+ "rev",
424
+ state.lastTask || "",
425
+ String(meta.isAgentEnd),
426
+ String(meta.fileChanged),
427
+ String(meta.failed),
428
+ delta || "(none)",
429
+ ...signals,
430
+ );
431
+ }
432
+
433
+ function shouldSkipReview(state: SessionState, signature: string): boolean {
434
+ return Boolean(signature && state.reviewControl.lastMaterialSignature === signature && !state.reviewControl.running);
435
+ }
436
+
437
+ function consumeReviewFollowUp(state: SessionState): void {
438
+ state.followUp = "";
439
+ state.reviewControl = {
440
+ ...state.reviewControl,
441
+ status: "consumed",
442
+ pending: false,
443
+ consumed: true,
444
+ running: false,
445
+ lastAppliedAt: new Date().toISOString(),
446
+ };
447
+ }
448
+
449
+ function markReviewSkipped(state: SessionState, signature: string, trigger: string): void {
450
+ state.reviewControl = {
451
+ ...state.reviewControl,
452
+ status: "consumed",
453
+ running: false,
454
+ consumed: true,
455
+ pending: false,
456
+ lastMaterialSignature: signature,
457
+ lastDecision: "defer",
458
+ lastTrigger: trigger,
459
+ lastReason: "repeated material snapshot",
460
+ lastAppliedAt: new Date().toISOString(),
461
+ };
462
+ }
463
+
464
+ function markReviewRunning(state: SessionState, signature: string, trigger: string): void {
465
+ state.reviewControl = {
466
+ ...state.reviewControl,
467
+ status: "running",
468
+ running: true,
469
+ pending: true,
470
+ consumed: false,
471
+ lastMaterialSignature: signature,
472
+ lastTrigger: trigger,
473
+ };
474
+ }
475
+
476
+ function markReviewApplied(state: SessionState, signature: string, trigger: string, decision: "continue" | "review" | "defer", reason: string, consumed: boolean): void {
477
+ state.reviewControl = {
478
+ ...state.reviewControl,
479
+ status: consumed ? "consumed" : "needed",
480
+ running: false,
481
+ pending: !consumed,
482
+ consumed,
483
+ lastMaterialSignature: signature,
484
+ lastDecision: decision,
485
+ lastTrigger: trigger,
486
+ lastReason: reason,
487
+ lastAppliedAt: new Date().toISOString(),
488
+ };
489
+ }
490
+
491
+ function persistReviewState(state: SessionState, includeReviewRoute: boolean): void {
492
+ const persisted = loadState();
493
+ persisted.reviewControl = state.reviewControl;
494
+ persisted.followUp = state.followUp;
495
+ persisted.followUpTask = state.followUpTask;
496
+ persisted.reviewSignals = state.reviewSignals;
497
+ persisted.reviewSignalsTask = state.reviewSignalsTask;
498
+ persisted.advisorPauseUntilTurn = state.advisorPauseUntilTurn;
499
+ if (includeReviewRoute && state.router.review) {
500
+ persisted.router.review = state.router.review;
501
+ }
502
+ saveState(persisted);
503
+ }
504
+
505
+ function recoverReviewControl(state: SessionState): void {
506
+ if (!state.reviewControl.running) return;
507
+
508
+ const pending = Boolean(state.reviewControl.pending);
509
+ state.reviewControl = {
510
+ ...state.reviewControl,
511
+ running: false,
512
+ status: pending ? "needed" : state.reviewControl.status === "needed" ? "needed" : "idle",
513
+ consumed: !pending,
514
+ lastAppliedAt: new Date().toISOString(),
515
+ };
516
+ }
517
+
518
+ type AdvisorHintDetails = {
519
+ kind?: "handoff" | "answer";
520
+ decision?: "continue" | "review" | "defer";
521
+ reason?: string;
522
+ summary?: string;
523
+ actions?: unknown;
524
+ };
525
+
526
+ type ReviewControlState = {
527
+ status: "idle" | "needed" | "running" | "consumed";
528
+ pending: boolean;
529
+ consumed: boolean;
530
+ running: boolean;
531
+ lastDecision?: "continue" | "review" | "defer";
532
+ lastMaterialSignature?: string;
533
+ lastReason?: string;
534
+ lastTrigger?: string;
535
+ lastAppliedAt?: string;
536
+ };
537
+
538
+ type ReviewMaterialMeta = {
539
+ fileChanged: boolean;
540
+ failed: boolean;
541
+ isAgentEnd: boolean;
542
+ materialSignals?: string[];
543
+ };
544
+
545
+ export type ReviewVerdict = "on_track" | "course_correct" | "not_done" | "skip";
546
+
547
+ export type ParsedReviewPivot = {
548
+ recommended: boolean;
549
+ blocking: boolean;
550
+ rationale: string;
551
+ confidence?: number;
552
+ requiresConfirmation: boolean;
553
+ };
554
+
555
+ export type ParsedReviewPayload = {
556
+ activeTask: string;
557
+ verdict: ReviewVerdict;
558
+ taskActions: string[];
559
+ advisorySignals: string[];
560
+ pivot: ParsedReviewPivot;
561
+ summary: string;
562
+ reason: string;
563
+ };
564
+
565
+ function normalizeAdvisorActions(actions: unknown): string[] {
566
+ const raw = Array.isArray(actions) ? actions : typeof actions === "string" ? [actions] : [];
567
+ return raw.map((action) => squish(action, 200)).filter(Boolean).slice(0, 2);
568
+ }
569
+
570
+ function buildAdvisorySignalsBlock(task: string, advisorySignals: string[], pivot: ParsedReviewPivot): string {
571
+ if (!advisorySignals.length && !pivot.recommended) return "";
572
+ const parts = [
573
+ task ? `Active task: ${sanitizeAdvisorText(task).slice(0, 220)}` : "",
574
+ advisorySignals.length ? `Advisory signals (non-commanding): ${advisorySignals.join("; ")}` : "",
575
+ pivot.recommended
576
+ ? `Pivot (${pivot.blocking ? "blocking" : "non-blocking"}): ${pivot.rationale || "review before task switch"}${pivot.blocking ? " (requires user confirmation)" : ""}`
577
+ : "",
578
+ ].filter(Boolean);
579
+ return parts.join("\n");
580
+ }
581
+
582
+ export function consumeTaskScopedReviewSignals(state: SessionState, task: string): string {
583
+ if (!state.reviewSignals.length) return "";
584
+ const signalTask = state.reviewSignalsTask ?? "";
585
+ if (signalTask && !isTaskContinuation(signalTask, task)) {
586
+ state.reviewSignals = [];
587
+ state.reviewSignalsTask = undefined;
588
+ return "";
589
+ }
590
+ const text = state.reviewSignals.join("\n");
591
+ state.reviewSignals = [];
592
+ state.reviewSignalsTask = undefined;
593
+ return text;
594
+ }
595
+
596
+ export function consumeTaskScopedFollowUp(state: SessionState, task: string): string {
597
+ if (!state.followUp) return "";
598
+ if (!state.followUpTask) {
599
+ const text = state.followUp;
600
+ state.followUp = "";
601
+ return text;
602
+ }
603
+ if (!isTaskContinuation(state.followUpTask, task)) {
604
+ state.followUp = "";
605
+ state.followUpTask = undefined;
606
+ return "";
607
+ }
608
+ const text = state.followUp;
609
+ state.followUp = "";
610
+ state.followUpTask = undefined;
611
+ return text;
612
+ }
613
+
614
+ function comparableAdvisorText(text: string): string {
615
+ return sanitizeAdvisorText(text).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
616
+ }
617
+
618
+ function isRedundantAdvisorSummary(reason: string, summary: string): boolean {
619
+ const r = comparableAdvisorText(reason);
620
+ const s = comparableAdvisorText(summary);
621
+ if (!s) return true;
622
+ if (!r) return false;
623
+ if (r === s) return true;
624
+ if (Math.min(r.length, s.length) >= 60 && (r.includes(s) || s.includes(r))) return true;
625
+
626
+ const rTokens = new Set(r.split(" ").filter((token) => token.length > 2));
627
+ const sTokens = new Set(s.split(" ").filter((token) => token.length > 2));
628
+ if (rTokens.size < 8 || sTokens.size < 8) return false;
629
+ const overlap = [...sTokens].filter((token) => rTokens.has(token)).length;
630
+ return overlap / Math.max(rTokens.size, sTokens.size) >= 0.86;
631
+ }
632
+
633
+ function distinctAdvisorSummary(reason: string, summary: string): string {
634
+ const cleanSummary = sanitizeAdvisorText(summary).trim();
635
+ return isRedundantAdvisorSummary(reason, cleanSummary) ? "" : cleanSummary;
636
+ }
637
+
638
+ function advisorHandoffText(decision: "continue" | "review" | "defer", reason: string, summary: string, actions: unknown = []): string {
639
+ const limitedActions = normalizeAdvisorActions(actions);
640
+ const cleanReason = sanitizeAdvisorText(reason);
641
+ const cleanSummary = distinctAdvisorSummary(cleanReason, summary);
642
+ return [
643
+ `Advisor verdict: ${decision}.`,
644
+ cleanReason ? `Reason: ${cleanReason}` : "",
645
+ cleanSummary ? `Summary: ${cleanSummary}` : "",
646
+ limitedActions.length ? `Actions: ${limitedActions.join("; ")}` : "",
647
+ ].filter(Boolean).join("\n");
648
+ }
649
+
650
+ function sendAdvisorHint(pi: ExtensionAPI, decision: "continue" | "review" | "defer", reason: string, summary: string, actions: unknown = []) {
651
+ const cleanReason = sanitizeAdvisorText(reason);
652
+ const cleanSummary = distinctAdvisorSummary(cleanReason, summary);
653
+ const limitedActions = normalizeAdvisorActions(actions);
654
+ pi.sendMessage(
655
+ {
656
+ customType: "advisor:llm",
657
+ content: advisorHandoffText(decision, cleanReason, cleanSummary, limitedActions),
658
+ display: true,
659
+ details: { kind: "handoff", decision, reason: cleanReason, summary: cleanSummary, actions: limitedActions },
660
+ },
661
+ { deliverAs: "followUp" },
662
+ );
663
+ }
664
+
665
+ function sendAdvisorAnswer(pi: ExtensionAPI, text: string) {
666
+ const cleanText = sanitizeAdvisorText(text);
667
+ pi.sendMessage({
668
+ customType: "advisor:llm",
669
+ content: cleanText,
670
+ display: true,
671
+ details: { kind: "answer", summary: cleanText },
672
+ });
673
+ }
674
+
675
+ function renderAdvisorHint(message: any, options: { expanded?: boolean }, theme: any) {
676
+ const details = (message?.details ?? {}) as AdvisorHintDetails;
677
+ const customType = String(message?.customType ?? "advisor:rules");
678
+ const sourceColor = customType === "advisor:llm" ? "success" : customType === "advisor:model" ? "accent" : "muted";
679
+ const source = theme.bold(theme.fg(sourceColor, `[${customType}]`));
680
+
681
+ if (details.kind === "answer") {
682
+ const body = sanitizeAdvisorText(contentText(message?.content) || details.summary || "No advisor response.");
683
+ const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
684
+ box.addChild(new Text(`${theme.bold(theme.fg("success", "↗"))} ${source} ${theme.bold(theme.fg("success", "answer"))}`, 0, 0));
685
+ box.addChild(new Text(theme.fg("dim", body), 0, 0));
686
+ return box;
687
+ }
688
+
689
+ const decision = details.decision ?? "defer";
690
+ const decisionColor = decision === "review" ? "accent" : decision === "continue" ? "muted" : "dim";
691
+ const verdict = theme.bold(theme.fg(decisionColor, decision));
692
+ const glyph = decision === "review" ? "↗" : decision === "defer" ? "…" : "·";
693
+ const reason = squish(details.reason || contentText(message?.content) || "no extra detail", 180);
694
+ const actions = normalizeAdvisorActions(details.actions);
695
+ const fullHandoff = sanitizeAdvisorText(
696
+ (details.reason || details.summary || actions.length)
697
+ ? advisorHandoffText(decision, details.reason || "", details.summary || "", actions)
698
+ : contentText(message?.content),
699
+ );
700
+
701
+ const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
702
+ box.addChild(new Text(`${theme.bold(theme.fg(decisionColor, glyph))} ${source} ${verdict}`, 0, 0));
703
+
704
+ if (options.expanded) {
705
+ box.addChild(new Text(theme.fg("dim", "full handoff:"), 0, 0));
706
+ box.addChild(new Text(theme.fg("dim", fullHandoff), 0, 0));
707
+ } else {
708
+ box.addChild(new Text(theme.fg("dim", `reason: ${reason}`), 0, 0));
709
+ const summary = distinctAdvisorSummary(details.reason || "", details.summary || "");
710
+ if (summary) {
711
+ box.addChild(new Text(theme.fg("dim", `summary: ${squish(summary, 220)}`), 0, 0));
712
+ }
713
+ if (actions.length) {
714
+ box.addChild(new Text(theme.fg("dim", `actions: ${actions.map((a) => squish(a, 80)).join(" • ")}`), 0, 0));
715
+ }
716
+ if (fullHandoff.split("\n").length > 3) {
717
+ box.addChild(new Text(theme.fg("dim", "Ctrl+O full advisor handoff"), 0, 0));
718
+ }
719
+ }
720
+
721
+ return box;
722
+ }
723
+
724
+ /** Extract readable text from message content (handles strings, blocks, and nested message payloads). */
725
+ export function contentText(content: unknown): string {
726
+ if (typeof content === "string") return sanitizeAdvisorText(content).trim();
727
+ if (content && typeof content === "object" && !Array.isArray(content)) {
728
+ const obj = content as Record<string, unknown>;
729
+ if (typeof obj.text === "string") return sanitizeAdvisorText(obj.text).trim();
730
+ if (obj.content !== undefined) return contentText(obj.content);
731
+ if (obj.message !== undefined) return contentText(obj.message);
732
+ return "";
733
+ }
734
+ if (!Array.isArray(content)) return sanitizeAdvisorText(content).trim();
735
+ const parts: string[] = [];
736
+ for (const item of content) {
737
+ if (!item) continue;
738
+ if (typeof item === "string") { parts.push(item); continue; }
739
+ const obj = item as Record<string, unknown>;
740
+ if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
741
+ else if (typeof obj.text === "string") parts.push(obj.text);
742
+ else if (obj.content !== undefined) {
743
+ const nested = contentText(obj.content);
744
+ if (nested) parts.push(nested);
745
+ }
746
+ else if (obj.message !== undefined) {
747
+ const nested = contentText(obj.message);
748
+ if (nested) parts.push(nested);
749
+ }
750
+ }
751
+ return sanitizeAdvisorText(parts.join("\n")).replace(/\s+/g, " ").trim();
752
+ }
753
+
754
+ /** Check if a tool result or message indicates an actual execution failure */
755
+ function isActualFailure(tool: any): boolean {
756
+ if (tool?.isError === true) return true;
757
+ if (tool?.status === "error" || tool?.status === "failure") return true;
758
+ if (tool?.error && String(tool.error).length > 0) return true;
759
+ return false;
760
+ }
761
+
762
+ function responseText(resp: { content?: Array<{ type?: string; text?: string }> } | null | undefined): string {
763
+ return (resp?.content ?? []).filter((b: any) => b?.type === "text").map((b: any) => b.text).join("\n").trim();
764
+ }
765
+
766
+ function mergeRouteReview(configReview: AdvisorConfig["review"], route?: ReviewPolicy): ReviewPolicy {
767
+ if (configReview === "off") return "off";
768
+ if (!route) return configReview;
769
+ return mergeReviewPolicy(configReview, route);
770
+ }
771
+
772
+ function sessionKey(ctx: any): string {
773
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
774
+ if (!sessionFile) return "session";
775
+ return basename(String(sessionFile)).replace(/\.[^.]+$/, "");
776
+ }
777
+
778
+ type OrchestrationSnapshot = {
779
+ goal: string;
780
+ loop: { enabled?: boolean; interval?: string; instruction?: string };
781
+ research: { instruction?: string; interval?: string; cycles?: number; lastResult?: string };
782
+ };
783
+
784
+ function readOrchestrationSnapshot(ctx: any): OrchestrationSnapshot {
785
+ const dir = join(ORCHESTRATION_DIR, sessionKey(ctx));
786
+ return {
787
+ goal: readText(join(dir, "goal.md")).trim(),
788
+ loop: readJson(join(dir, "loop.json"), {}),
789
+ research: readJson(join(dir, "autoresearch.json"), {}),
790
+ };
791
+ }
792
+
793
+ function orchestrationSnapshotText(ctx: any): string {
794
+ const snapshot = readOrchestrationSnapshot(ctx);
795
+ const goalActive = Boolean(snapshot.goal);
796
+ const loopActive = Boolean(snapshot.loop.enabled && snapshot.loop.instruction);
797
+ const researchActive = Boolean(snapshot.research.instruction);
798
+ const status = goalActive && !loopActive && !researchActive
799
+ ? "setup gap — goal exists but no active autoresearch/loop progression"
800
+ : goalActive
801
+ ? "progression configured"
802
+ : "no active goal";
803
+ return [
804
+ "Orchestration:",
805
+ `- Goal: ${goalActive ? `active — ${truncate(snapshot.goal, 360)}` : "off"}`,
806
+ `- Autoresearch: ${researchActive ? `active — ${truncate(snapshot.research.instruction || "", 240)}; cycles=${snapshot.research.cycles ?? 0}${snapshot.research.lastResult ? `, last=${snapshot.research.lastResult}` : ""}` : "off"}`,
807
+ `- Loop: ${loopActive ? `active every ${snapshot.loop.interval || "?"} — ${truncate(snapshot.loop.instruction || "", 260)}` : "off"}`,
808
+ `- Status: ${status}`,
809
+ ].join("\n");
810
+ }
811
+
812
+ export function buildAdvisorCheckinPrompt(source: string, orchestration: string, sessionBrief: string): string {
813
+ return [
814
+ `Mid-session check-in (${source})`,
815
+ "Role: alignment reviewer for the active work. Do not create a new task, research direction, benchmark, script, artifact, or model switch unless the active goal explicitly asks for it.",
816
+ "Stay anchored to the active goal/autoresearch/loop. If autoresearch is active, preserve its research question and judge whether the latest work is gathering evidence toward that question.",
817
+ "Bad nudge examples: research the existence of weaknesses instead of solving the named weakness; create a script/report about weaknesses when the goal is to fix advisor behavior; swap to a shallower research mode.",
818
+ "Return exactly two short lines:",
819
+ "Status: on_track|stuck|off_track - <why, tied to the active goal>",
820
+ "Nudge: <one concrete next action that continues the active goal>",
821
+ orchestration,
822
+ sessionBrief ? `Session brief:\n${sessionBrief}` : "",
823
+ ].filter(Boolean).join("\n\n");
824
+ }
825
+
826
+ function advisorPauseRemaining(state: SessionState, nowTurns = state.turns): number {
827
+ const until = state.advisorPauseUntilTurn;
828
+ if (until === undefined || Number.isNaN(until)) return 0;
829
+ return Math.max(0, until - nowTurns);
830
+ }
831
+
832
+ function isAdvisorPaused(state: SessionState, nowTurns = state.turns): boolean {
833
+ return advisorPauseRemaining(state, nowTurns) > 0;
834
+ }
835
+
836
+ function isAdvisorAutoRunSuppressed(state: SessionState, nowTurns = state.turns): boolean {
837
+ return isAdvisorPaused(state, nowTurns);
838
+ }
839
+
840
+ function isAdvisorAutoRunSuppressedForTurnContext(state: SessionState, nowTurns = state.turns): boolean {
841
+ return isAdvisorAutoRunSuppressed(state, nowTurns) || isAdvisorAutoRunSuppressed(state, nowTurns - 1);
842
+ }
843
+
844
+ function checkinDescription(config: AdvisorConfig): string {
845
+ if (config.checkins === "off") return "checkins off";
846
+ return `checkins ${config.checkinIntervalMinutes}m`;
847
+ }
848
+
849
+ function setPiRogueStatus(ctx: any, config = loadConfig(), state = loadState()): void {
850
+ const normalized = normalizeAdvisorConfig(config);
851
+ const checkin = checkinDescription(normalized);
852
+ const pause = advisorPauseRemaining(state, state.turns);
853
+ const pauseText = pause > 0 ? ` · pause ${pause} turn${pause === 1 ? "" : "s"}` : "";
854
+ const last = state.checkin.lastAt ? ` · last ${new Date(state.checkin.lastAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
855
+ ctx.ui.setStatus("pi-rogue", `☠︎ advisor ${normalized.mode}/${normalized.review} · ${checkin}${pauseText}${last}`);
856
+ }
857
+
858
+ export function shouldRunCheckin(config: AdvisorConfig, state: SessionState, now = Date.now(), startedAt = now): string | null {
859
+ if (isAdvisorAutoRunSuppressed(state, state.turns)) return null;
860
+ const normalized = normalizeAdvisorConfig(config);
861
+ if (normalized.mode === "off" || normalized.mode === "manual") return null;
862
+ if (normalized.checkins === "off") return null;
863
+ if (state.checkin.queued) {
864
+ return state.checkin.queuedReason || "Queued mid-session check-in.";
865
+ }
866
+ if (!state.lastTask && state.notes.length === 0) return null;
867
+
868
+ const lastTurn = state.checkin.lastTurn ?? 0;
869
+ if (state.turns <= lastTurn) return null;
870
+
871
+ const lastAt = state.checkin.lastAt ? Date.parse(state.checkin.lastAt) : 0;
872
+ const intervalMs = normalized.checkinIntervalMinutes * 60_000;
873
+ const streamStartedAt = Number.isFinite(normalized.checkinStartedAt ?? NaN)
874
+ ? (normalized.checkinStartedAt as number)
875
+ : startedAt;
876
+ const since = Math.max(lastAt, streamStartedAt);
877
+ if (since && now - since < intervalMs) return null;
878
+ return `mid-hour check-in after ${state.turns - lastTurn} new turn(s)`;
879
+ }
880
+
881
+
882
+ function isAdvisorIdle(ctx: any): boolean {
883
+ try {
884
+ return typeof ctx?.isIdle === "function" ? ctx.isIdle() : true;
885
+ } catch {
886
+ return true;
887
+ }
888
+ }
889
+
890
+ export async function requestAdvisorLoopCheckin(pi: ExtensionAPI, ctx: any, source = "loop_tick"): Promise<boolean> {
891
+ return maybeAdvisorCheckin(pi, ctx, source);
892
+ }
893
+
894
+ async function maybeAdvisorCheckin(pi: ExtensionAPI, ctx: any, source: string): Promise<boolean> {
895
+ const key = sessionKey(ctx);
896
+ if (checkinLocks.has(key)) return false;
897
+
898
+ const config = loadConfig();
899
+ const state = loadState();
900
+ const reason = shouldRunCheckin(config, state, Date.now(), Date.now());
901
+ if (!reason) {
902
+ if (state.checkin.queued) {
903
+ state.checkin.queued = false;
904
+ saveState(state);
905
+ setPiRogueStatus(ctx, config, state);
906
+ }
907
+ return false;
908
+ }
909
+
910
+ if (!isAdvisorIdle(ctx)) {
911
+ if (!state.checkin.queued) {
912
+ state.checkin.queued = true;
913
+ state.checkin.queuedReason = reason;
914
+ saveState(state);
915
+ setPiRogueStatus(ctx, config, state);
916
+ }
917
+ return false;
918
+ }
919
+
920
+ checkinLocks.add(key);
921
+ try {
922
+ const prompt = buildAdvisorCheckinPrompt(source, orchestrationSnapshotText(ctx), brief(state));
923
+ const completed = await completeWithHigherAdvisorModel(
924
+ ctx,
925
+ config,
926
+ prompt,
927
+ [
928
+ {
929
+ role: "user",
930
+ content: prompt,
931
+ timestamp: new Date().toISOString(),
932
+ },
933
+ ],
934
+ { maxTokens: 260, reasoning: "low" as ThinkingLevel },
935
+ );
936
+ if (!completed) return false;
937
+
938
+ const next = loadState();
939
+ next.checkin = {
940
+ lastAt: new Date().toISOString(),
941
+ lastTurn: next.turns,
942
+ lastReason: reason,
943
+ queued: false,
944
+ };
945
+ saveState(next);
946
+ setPiRogueStatus(ctx, config, next);
947
+ sendAdvisorHint(pi, "review", "mid-hour check-in", completed.text, [completed.text]);
948
+ return true;
949
+ } finally {
950
+ checkinLocks.delete(key);
951
+ }
952
+ }
953
+
954
+ function piRogueCockpitText(config: AdvisorConfig, state: SessionState, currentNote: string, orchestration = ""): string {
955
+ const normalized = normalizeAdvisorConfig(config);
956
+ const pause = advisorPauseRemaining(state, state.turns);
957
+ return [
958
+ "☠︎ Pi-Rogue cockpit",
959
+ currentNote ? `Advisor: ${truncate(currentNote, 220)}` : "Advisor: no current note",
960
+ `Mode: ${normalized.mode} | Review: ${normalized.review} | Check-ins: ${checkinDescription(normalized)}`,
961
+ pause > 0 ? `Advisor pause: ${pause} turn${pause === 1 ? "" : "s"} remaining` : "Advisor pause: off",
962
+ `Turns: ${state.turns} | Advisor calls: ${state.advisorCalls} | Cache hits: ${state.cacheHits}`,
963
+ state.checkin.lastAt ? `Last check-in: ${new Date(state.checkin.lastAt).toLocaleString()} (${state.checkin.lastReason || "mid-hour"})` : "Last check-in: never",
964
+ state.checkin.queued ? `Queued check-in: ${state.checkin.queuedReason || "due"}` : "",
965
+ orchestration,
966
+ "",
967
+ "Commands: /advisor status · /goal · /loop status · /autoresearch status",
968
+ ].filter(Boolean).join("\n");
969
+ }
970
+
971
+ // ── Model resolution (higher/advanced first, then optional regular fallback) ──
972
+ type ResolvedAdvisorModel = { model: any; auth: any; label: string; fallback?: boolean };
973
+ type ModelResolutionOptions = { allowRegularFallback?: boolean };
974
+
975
+ export async function resolveModelCandidates(ctx: any, config: AdvisorConfig, options: ModelResolutionOptions = {}): Promise<ResolvedAdvisorModel[]> {
976
+ const { allowRegularFallback = true } = options;
977
+ const candidates: ResolvedAdvisorModel[] = [];
978
+ const seen = new Set<string>();
979
+ const add = async (found: any, label: string, fallback = false) => {
980
+ if (!found) return;
981
+ const key = String(found.id || label);
982
+ if (seen.has(key)) return;
983
+ const auth = await ctx.modelRegistry?.getApiKeyAndHeaders(found);
984
+ if (auth?.ok && auth.apiKey) {
985
+ seen.add(key);
986
+ candidates.push({ model: found, auth, label, fallback });
987
+ }
988
+ };
989
+
990
+ // Try configured higher/advanced advisor model first.
991
+ if (config.model && config.model.includes("/")) {
992
+ const [p, ...m] = config.model.split("/");
993
+ await add(ctx.modelRegistry?.find(p, m.join("/")), p + "/" + m.join("/"));
994
+ }
995
+
996
+ // Fall through SOTA chain.
997
+ for (const sota of SOTA_CHAIN) {
998
+ await add(ctx.modelRegistry?.find(sota.provider, sota.model), sota.label);
999
+ }
1000
+
1001
+ if (allowRegularFallback) {
1002
+ // Final fallback: any configured text model, i.e. the regular session-capable model.
1003
+ for (const m of (ctx.modelRegistry?.getAvailable() ?? []).filter((model: any) => model.input?.includes?.("text"))) {
1004
+ await add(m, m.id || "regular model", true);
1005
+ }
1006
+ }
1007
+
1008
+ return candidates;
1009
+ }
1010
+
1011
+ async function resolveModel(ctx: any, config: AdvisorConfig): Promise<ResolvedAdvisorModel | null> {
1012
+ return (await resolveModelCandidates(ctx, config))[0] ?? null;
1013
+ }
1014
+
1015
+ export async function completeWithModelFallback(ctx: any, config: AdvisorConfig, systemPrompt: string, messages: any[], options: { maxTokens: number; reasoning: ThinkingLevel }): Promise<{ text: string; model: string; fallback?: boolean } | null> {
1016
+ let lastError = "";
1017
+ for (const resolved of await resolveModelCandidates(ctx, config)) {
1018
+ try {
1019
+ const resp = await completeSimple(resolved.model, { systemPrompt, messages }, {
1020
+ apiKey: resolved.auth.apiKey,
1021
+ headers: resolved.auth.headers,
1022
+ maxTokens: options.maxTokens,
1023
+ reasoning: options.reasoning,
1024
+ });
1025
+ return { text: responseText(resp) || "(empty)", model: resolved.label, fallback: resolved.fallback };
1026
+ } catch (error) {
1027
+ lastError = error instanceof Error ? error.message : String(error);
1028
+ }
1029
+ }
1030
+ return lastError ? { text: `No advisor/check-in model completed successfully (${lastError}).`, model: "none" } : null;
1031
+ }
1032
+
1033
+ export async function completeWithHigherAdvisorModel(
1034
+ ctx: any,
1035
+ config: AdvisorConfig,
1036
+ systemPrompt: string,
1037
+ messages: any[],
1038
+ options: { maxTokens: number; reasoning: ThinkingLevel; allowRegularFallback?: boolean },
1039
+ ): Promise<{ text: string; model: string } | null> {
1040
+ const { allowRegularFallback = true } = options;
1041
+ for (const resolved of await resolveModelCandidates(ctx, config, { allowRegularFallback })) {
1042
+ try {
1043
+ const resp = await completeSimple(resolved.model, { systemPrompt, messages }, {
1044
+ apiKey: resolved.auth.apiKey,
1045
+ headers: resolved.auth.headers,
1046
+ maxTokens: options.maxTokens,
1047
+ reasoning: options.reasoning,
1048
+ });
1049
+ return { text: responseText(resp) || "(empty)", model: resolved.label };
1050
+ } catch {
1051
+ // keep trying remaining candidates
1052
+ }
1053
+ }
1054
+ return null;
1055
+ }
1056
+
1057
+ async function askAdvisor(pi: ExtensionAPI, ctx: any, question: string, scope: string, includeWork: boolean) {
1058
+ const config = loadConfig();
1059
+ const state = loadState();
1060
+ if (!question.trim()) return { text: "Ask a question.", error: "empty" };
1061
+
1062
+ const brokerBrief = includeWork ? contextBrokerBrief(pi) : "";
1063
+ const ck = hash("adv", config.model ?? "auto", squish(question, 300), includeWork ? brief(state) : "", brokerBrief);
1064
+ const cache = loadCache();
1065
+ if (cache[ck]) { state.cacheHits++; saveState(state); return { text: cache[ck], cached: true }; }
1066
+
1067
+ const msgs = [
1068
+ { role: "user", content: [
1069
+ `Question: ${question}`,
1070
+ scope ? `Scope: ${scope}` : "",
1071
+ includeWork && brief(state) ? `Session:\n${brief(state)}` : "",
1072
+ brokerBrief ? `Context broker brief:\n${brokerBrief}` : "",
1073
+ ].filter(Boolean).join("\n"), timestamp: new Date().toISOString() },
1074
+ ] as any[];
1075
+
1076
+ const completed = await completeWithModelFallback(ctx, config, ADVISOR_SYSTEM, msgs, { maxTokens: 600, reasoning: "medium" as ThinkingLevel });
1077
+ if (!completed) return { text: "No model available. Install one via pi config.", error: "no_model" };
1078
+ const text = completed.text;
1079
+ if (text && text !== "(empty)") { cache[ck] = text; saveCache(cache); }
1080
+ state.advisorCalls++;
1081
+ saveState(state);
1082
+ return { text, model: completed.model, fallback: completed.fallback };
1083
+ }
1084
+
1085
+ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: string, meta: ReviewMaterialMeta) {
1086
+ const config = loadConfig();
1087
+ if (config.review === "off") return;
1088
+ const state = loadState();
1089
+
1090
+ const signature = reviewMaterialSignature(state, delta, meta);
1091
+ if (state.reviewControl.running) {
1092
+ return;
1093
+ }
1094
+ if (shouldSkipReview(state, signature)) {
1095
+ markReviewSkipped(state, signature, trigger);
1096
+ persistReviewState(state, false);
1097
+ return;
1098
+ }
1099
+
1100
+ markReviewRunning(state, signature, trigger);
1101
+ persistReviewState(state, false);
1102
+
1103
+ let finalized = false;
1104
+ let finalDecision: "continue" | "review" | "defer" = "defer";
1105
+ let finalReason = "pending review";
1106
+
1107
+ try {
1108
+ const phase: AdvisorRouteInput["phase"] = meta.isAgentEnd ? "closeout" : "review";
1109
+ const reviewInput: AdvisorRouteInput = {
1110
+ phase,
1111
+ text: delta || "(none)",
1112
+ brief: brief(state),
1113
+ fileChanged: meta.fileChanged,
1114
+ failed: meta.failed,
1115
+ };
1116
+ const reviewHeuristic = heuristicRoute(reviewInput);
1117
+ const gatePrediction = binaryGatePredict(reviewInput.text);
1118
+ let reviewRoute = reviewHeuristic;
1119
+ if (gatePrediction && gatePrediction.confidence >= 0.55 && !reviewHeuristic.safety) {
1120
+ const gateContinues = gatePrediction.decision === "continue";
1121
+ reviewRoute = {
1122
+ ...reviewHeuristic,
1123
+ label: gateContinues ? "abstain" : reviewHeuristic.label,
1124
+ confidence: gatePrediction.confidence,
1125
+ source: "model",
1126
+ reason: gateContinues
1127
+ ? "local gate predicts continue"
1128
+ : "local gate predicts review",
1129
+ review: gateContinues ? "off" as const : reviewHeuristic.review,
1130
+ escalate: gateContinues ? false : reviewHeuristic.escalate,
1131
+ };
1132
+ }
1133
+ appendRouteLog(reviewRoute);
1134
+ state.router.review = reviewRoute;
1135
+ persistReviewState(state, true);
1136
+
1137
+ if (gatePrediction && gatePrediction.confidence >= 0.55 && gatePrediction.decision === "continue" && !reviewHeuristic.safety) {
1138
+ finalDecision = "continue";
1139
+ finalReason = "local gate continue";
1140
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1141
+ persistReviewState(state, true);
1142
+ finalized = true;
1143
+ return;
1144
+ }
1145
+
1146
+ const effectiveReview = mergeRouteReview(config.review, state.router.preflight?.review);
1147
+ const finalReview = mergeReviewPolicy(effectiveReview, reviewRoute.review);
1148
+ if (finalReview === "off") {
1149
+ finalDecision = "continue";
1150
+ finalReason = "review disabled";
1151
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1152
+ persistReviewState(state, true);
1153
+ finalized = true;
1154
+ return;
1155
+ }
1156
+
1157
+ const shouldRun =
1158
+ finalReview === "strict"
1159
+ ? meta.isAgentEnd || meta.fileChanged || meta.failed || reviewRoute.label !== "abstain" || state.turns % 3 === 0
1160
+ : meta.fileChanged || meta.failed;
1161
+ if (!shouldRun) {
1162
+ finalDecision = "defer";
1163
+ finalReason = "no material signal";
1164
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1165
+ persistReviewState(state, true);
1166
+ finalized = true;
1167
+ return;
1168
+ }
1169
+
1170
+ const b = brief(state);
1171
+ const brokerBrief = contextBrokerBrief(pi);
1172
+ if (!b && !brokerBrief) {
1173
+ finalDecision = "defer";
1174
+ finalReason = "missing brief context";
1175
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1176
+ persistReviewState(state, true);
1177
+ finalized = true;
1178
+ return;
1179
+ }
1180
+
1181
+ const rk = hash("rev", trigger, b, brokerBrief, delta, String(meta.fileChanged), String(meta.failed), String(meta.isAgentEnd), String(reviewRoute.label), signature);
1182
+ const cache = loadCache();
1183
+ if (cache[rk]) {
1184
+ finalDecision = "defer";
1185
+ finalReason = "cached verdict";
1186
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1187
+ persistReviewState(state, true);
1188
+ finalized = true;
1189
+ return;
1190
+ }
1191
+
1192
+ const msgs = [
1193
+ { role: "user", content: [
1194
+ `Trigger: ${trigger}`,
1195
+ `Task: ${state.lastTask || "(unknown)"}`,
1196
+ `Delta: ${delta || "(none)"}`,
1197
+ `Files: ${meta.fileChanged} Errors: ${meta.failed}`,
1198
+ `Route: ${summarizeRoute(reviewRoute)}`,
1199
+ b ? `Brief:\n${b}` : "",
1200
+ brokerBrief ? `Context broker brief:\n${brokerBrief}` : "",
1201
+ ].join("\n"), timestamp: new Date().toISOString() },
1202
+ ] as any[];
1203
+ const completed = await completeWithModelFallback(ctx, config, REVIEW_SYSTEM, msgs, { maxTokens: 400, reasoning: "low" as ThinkingLevel });
1204
+ const raw = completed?.text;
1205
+ if (!raw) {
1206
+ finalDecision = "defer";
1207
+ finalReason = "empty verdict";
1208
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1209
+ persistReviewState(state, true);
1210
+ finalized = true;
1211
+ return;
1212
+ }
1213
+
1214
+ cache[rk] = raw;
1215
+ saveCache(cache);
1216
+
1217
+ const parsed = parseReviewPayload(raw, state.lastTask);
1218
+ if (!parsed) {
1219
+ finalDecision = "defer";
1220
+ finalReason = "unparseable verdict";
1221
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1222
+ persistReviewState(state, true);
1223
+ finalized = true;
1224
+ return;
1225
+ }
1226
+
1227
+ if (parsed.verdict === "skip") {
1228
+ finalDecision = "defer";
1229
+ finalReason = "explicit skip";
1230
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1231
+ persistReviewState(state, true);
1232
+ finalized = true;
1233
+ return;
1234
+ }
1235
+
1236
+ if (parsed.verdict === "on_track") {
1237
+ finalDecision = "continue";
1238
+ finalReason = parsed.reason || parsed.summary || "review result";
1239
+ finalReason = finalReason.slice(0, 120);
1240
+ state.followUp = "";
1241
+ state.followUpTask = undefined;
1242
+ state.reviewSignals = [];
1243
+ state.reviewSignalsTask = undefined;
1244
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
1245
+ persistReviewState(state, true);
1246
+ finalized = true;
1247
+ return;
1248
+ }
1249
+
1250
+ const decision = parsed.verdict === "course_correct" || parsed.verdict === "not_done" ? "review" : "defer";
1251
+ finalDecision = decision;
1252
+ finalReason = (parsed.reason || parsed.summary || "review result").slice(0, 120);
1253
+
1254
+ const display = formatAdvisorDisplay("advisor:llm", decision, finalReason);
1255
+ writeText(CURRENT_PATH, `${display}\n`);
1256
+
1257
+ const reviewTask = parsed.activeTask || state.lastTask || "";
1258
+ const hasTaskActions = parsed.taskActions.length > 0;
1259
+ if (hasTaskActions) {
1260
+ state.followUp = [sanitizeAdvisorText(parsed.summary), ...parsed.taskActions].filter(Boolean).join(" — ");
1261
+ state.followUpTask = reviewTask;
1262
+ sendAdvisorHint(pi, decision, finalReason, parsed.summary || "", parsed.taskActions);
1263
+ } else {
1264
+ state.followUp = "";
1265
+ state.followUpTask = undefined;
1266
+ }
1267
+
1268
+ const advisoryText = buildAdvisorySignalsBlock(reviewTask, parsed.advisorySignals, parsed.pivot);
1269
+ if (advisoryText) {
1270
+ state.reviewSignals = [advisoryText];
1271
+ state.reviewSignalsTask = reviewTask;
1272
+ sendAdvisorAnswer(pi, advisoryText);
1273
+ } else {
1274
+ state.reviewSignals = [];
1275
+ state.reviewSignalsTask = undefined;
1276
+ }
1277
+
1278
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, !hasTaskActions);
1279
+ persistReviewState(state, true);
1280
+ finalized = true;
1281
+ } finally {
1282
+ if (!finalized) {
1283
+ markReviewApplied(state, signature, trigger, finalDecision, finalReason, false);
1284
+ persistReviewState(state, true);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // ── Extension entry point ──────────────────────────────────────────────────
1290
+
1291
+ export function registerAdvisor(pi: ExtensionAPI): void {
1292
+ const p = pi as any;
1293
+ if (p.__piRogueAdvisorRegistered) return;
1294
+ p.__piRogueAdvisorRegistered = true;
1295
+
1296
+ for (const customType of ["advisor:model", "advisor:rules", "advisor:llm"] as const) {
1297
+ pi.registerMessageRenderer(customType, renderAdvisorHint);
1298
+ }
1299
+
1300
+ pi.on("session_start", (_event, ctx) => {
1301
+ const key = sessionKey(ctx);
1302
+ checkinLocks.delete(key);
1303
+ const state = loadState();
1304
+ recoverReviewControl(state);
1305
+ saveState(state);
1306
+ setPiRogueStatus(ctx, loadConfig(), state);
1307
+ // No timer is owned by advisor itself anymore; check-ins are triggered
1308
+ // from active goal/loop/autoresearch flow progression.
1309
+ });
1310
+
1311
+ pi.on("session_shutdown", (_event, ctx) => {
1312
+ const key = sessionKey(ctx);
1313
+ checkinLocks.delete(key);
1314
+ ctx.ui.setStatus("pi-rogue", undefined);
1315
+ });
1316
+
1317
+ // ── Tool ───────────────────────────────────────────────────────────────
1318
+ pi.registerTool({
1319
+ name: "advisor",
1320
+ label: "Advisor",
1321
+ description: "Strategic advisor. Call before architecture/refactor/tradeoff decisions. Uses best available model (default gpt-5.5).",
1322
+ parameters: Type.Object({
1323
+ question: Type.String({ description: "1 concise question" }),
1324
+ scope: Type.Optional(Type.String({ description: "architecture|implementation|debug|review|planning" })),
1325
+ includeRecentWork: Type.Optional(Type.Boolean({ description: "default: true" })),
1326
+ }),
1327
+ async execute(_id, params, _signal, onUpdate, ctx) {
1328
+ const r = await askAdvisor(pi, ctx, String(params.question || ""), String(params.scope || ""), params.includeRecentWork !== false);
1329
+ onUpdate?.({ content: [{ type: "text", text: r.cached ? "(cached)" : r.model ? `Consulting ${r.model}…` : "" }], details: {} });
1330
+ return { content: [{ type: "text", text: r.text }], details: { cached: r.cached, error: r.error } };
1331
+ },
1332
+ });
1333
+
1334
+ // ── Preflight (heuristics only — no LLM call, <1ms) ──────────────────
1335
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
1336
+ const cfg = loadConfig();
1337
+ const state = loadState();
1338
+ const hasFollowUp = Boolean(state.followUp);
1339
+ if ((isAdvisorAutoRunSuppressed(state, state.turns) && !hasFollowUp) || cfg.mode === "off" || cfg.mode === "manual") {
1340
+ return { systemPrompt: event.systemPrompt };
1341
+ }
1342
+ setPiRogueStatus(ctx, cfg, state);
1343
+ const prompt = typeof event.prompt === "string" && event.prompt.trim() ? squish(event.prompt, 1000) : "";
1344
+ if (prompt) state.lastTask = prompt;
1345
+ const currentTask = state.lastTask || "";
1346
+ const briefText = brief(state);
1347
+ const brokerBrief = contextBrokerBrief(pi);
1348
+ const intent = prompt ? classifyIntent(prompt) : "";
1349
+ const mode = prompt ? classifyMode(prompt) : "";
1350
+ const intentTag = intent ? `Intent: ${intent}` : "";
1351
+ const modeTag = mode ? `Mode: ${mode}` : "";
1352
+ // Enrich preflight text with session context so the binary gate has more signal
1353
+ const enrichedText = [prompt, event.systemPrompt || "", briefText ? `Brief: ${briefText}` : "", brokerBrief ? `Context broker: ${brokerBrief}` : "", intentTag, modeTag].filter(Boolean).join(" ");
1354
+ const routeInput: AdvisorRouteInput = { phase: "preflight", text: enrichedText || prompt || event.systemPrompt || briefText || brokerBrief || intentTag || modeTag || "", brief: [briefText, brokerBrief].filter(Boolean).join("\n\n") };
1355
+
1356
+ // Binary gate model — fast local classifier for continue/escalate decisions
1357
+ const gatePrediction = binaryGatePredict(routeInput.text);
1358
+ const heuristic = heuristicRoute(routeInput);
1359
+ let route: AdvisorRouteDecision;
1360
+ if (gatePrediction && gatePrediction.confidence >= 0.55) {
1361
+ const binLabel = gatePrediction.decision === "continue" ? "continue" as const : "escalate_to_advisor" as const;
1362
+ if (heuristic.safety) {
1363
+ route = heuristic;
1364
+ } else {
1365
+ route = {
1366
+ ...heuristic,
1367
+ label: binLabel,
1368
+ confidence: gatePrediction.confidence,
1369
+ reason: gatePrediction.decision === "continue"
1370
+ ? "local gate predicts continue"
1371
+ : "local gate predicts review",
1372
+ source: "model",
1373
+ preflight: binLabel === "continue" ? "off" as const : "full" as const,
1374
+ escalate: binLabel === "escalate_to_advisor",
1375
+ };
1376
+ }
1377
+ } else {
1378
+ route = heuristic;
1379
+ }
1380
+ appendRouteLog(route);
1381
+ state.router.preflight = route;
1382
+
1383
+ const hadFollowUp = Boolean(state.followUp);
1384
+ const follow = consumeTaskScopedFollowUp(state, currentTask);
1385
+ const reviewSignals = consumeTaskScopedReviewSignals(state, currentTask);
1386
+ if (hadFollowUp) {
1387
+ consumeReviewFollowUp(state);
1388
+ }
1389
+ saveState(state);
1390
+
1391
+ const note = routeNote(route);
1392
+ const control = state.reviewControl;
1393
+ const controlTag = control.status === "needed" || control.status === "running" ? `Review-control: ${control.status}${control.lastDecision ? ` (${control.lastDecision})` : ""}` : "";
1394
+ writeText(CURRENT_PATH, `${note}\n`);
1395
+ return {
1396
+ systemPrompt: [
1397
+ event.systemPrompt,
1398
+ follow ? `Advisor follow-up:\n${follow}` : "",
1399
+ note,
1400
+ reviewSignals ? `Advisor signals (non-commanding):\n${reviewSignals}` : "",
1401
+ controlTag,
1402
+ briefText ? `Brief (cache-aware):\n${briefText}` : "",
1403
+ brokerBrief ? `Context broker brief (lookup-first):\n${brokerBrief}` : "",
1404
+ ].filter(Boolean).join("\n\n"),
1405
+ };
1406
+ });
1407
+
1408
+ // ── Post-review (turn_end) ─────────────────────────────────────────────
1409
+ pi.on("turn_end", async (event: any, ctx: any) => {
1410
+ const cfg = loadConfig();
1411
+ if (cfg.mode === "off") return;
1412
+ const state = loadState();
1413
+ const suppressedThisTurn = isAdvisorAutoRunSuppressedForTurnContext(state, state.turns);
1414
+ const tools = (event.toolResults || []).map((t: any) => String(t?.toolName || t?.name || "tool"));
1415
+ const fileChanged = tools.some((t: string) => /^(edit|write)$/i.test(t));
1416
+ const failed = (event.toolResults || []).some((t: any) => isActualFailure(t));
1417
+ const text = squish(contentText(event.message?.content));
1418
+ if (text && text !== state.notes[state.notes.length - 1]) state.notes.push(text);
1419
+ state.turns++;
1420
+ if (state.advisorPauseUntilTurn && isAdvisorPaused(state, state.turns) === false) {
1421
+ state.advisorPauseUntilTurn = undefined;
1422
+ }
1423
+ saveState(state);
1424
+ setPiRogueStatus(ctx, cfg, state);
1425
+ if (cfg.review !== "off" && !suppressedThisTurn) {
1426
+ await doReview(pi, ctx, `turn-${state.turns}`, text, {
1427
+ fileChanged,
1428
+ failed,
1429
+ isAgentEnd: false,
1430
+ materialSignals: tools,
1431
+ });
1432
+ }
1433
+
1434
+ const post = loadState();
1435
+ if (!isAdvisorAutoRunSuppressed(post, post.turns)) {
1436
+ void maybeAdvisorCheckin(pi, ctx, "turn_end");
1437
+ }
1438
+ });
1439
+
1440
+ // ── Post-review (agent_end) ────────────────────────────────────────────
1441
+ pi.on("agent_end", async (event: any, ctx: any) => {
1442
+ const cfg = loadConfig();
1443
+ if (cfg.mode === "off") return;
1444
+ const state = loadState();
1445
+ const suppressed = isAdvisorAutoRunSuppressedForTurnContext(state, state.turns);
1446
+ if (cfg.review === "off" || suppressed) {
1447
+ if (!suppressed) {
1448
+ void maybeAdvisorCheckin(pi, ctx, "agent_end");
1449
+ }
1450
+ return;
1451
+ }
1452
+
1453
+ const msgs = (event.messages || []).filter((m: any) => m.role === "assistant" || m.role === "toolResult");
1454
+ const last = msgs[msgs.length - 1];
1455
+ const delta = contentText(last?.content) || "(none)";
1456
+ const fileChanged = msgs.some((m: any) => /(?:write|edit)/i.test(JSON.stringify(m)));
1457
+ const failed = msgs.some((m: any) => isActualFailure(m));
1458
+ const signals = msgs.map((m: any) => {
1459
+ const sig = contentText(m?.content);
1460
+ return `${m?.role || "msg"}: ${sig ? squish(sig, 120) : "(empty)"}`;
1461
+ });
1462
+ await doReview(pi, ctx, "agent-end", delta, {
1463
+ fileChanged,
1464
+ failed,
1465
+ isAgentEnd: true,
1466
+ materialSignals: signals,
1467
+ });
1468
+
1469
+ const post = loadState();
1470
+ if (!isAdvisorAutoRunSuppressed(post, post.turns)) {
1471
+ void maybeAdvisorCheckin(pi, ctx, "agent_end");
1472
+ }
1473
+ });
1474
+
1475
+ // ── /pi-rogue cockpit ──────────────────────────────────────────────────
1476
+ pi.registerCommand("pi-rogue", {
1477
+ description: "Show Pi-Rogue cockpit: advisor and orchestration command pointers",
1478
+ getArgumentCompletions: (prefix: string) => piRogueArgumentCompletions(prefix),
1479
+ handler: async (args, ctx) => {
1480
+ const cfg = loadConfig();
1481
+ const state = loadState();
1482
+ const arg = String(args ?? "").trim().toLowerCase();
1483
+ setPiRogueStatus(ctx, cfg, state);
1484
+
1485
+ if (!arg || arg === "status" || arg === "help") {
1486
+ ctx.ui.notify(piRogueCockpitText(cfg, state, readText(CURRENT_PATH).trim(), orchestrationSnapshotText(ctx)), "info");
1487
+ return;
1488
+ }
1489
+
1490
+ if (arg.startsWith("advisor")) {
1491
+ ctx.ui.notify([
1492
+ "Advisor surface:",
1493
+ " /advisor status",
1494
+ " /advisor config",
1495
+ " /advisor <question>",
1496
+ "",
1497
+ "Check-ins are orchestration-managed: set /goal or /loop to activate them.",
1498
+ ].join("\n"), "info");
1499
+ return;
1500
+ }
1501
+
1502
+ if (arg.startsWith("orchestration")) {
1503
+ ctx.ui.notify([
1504
+ "Orchestration surface:",
1505
+ " /goal show|clear|list|set <text>",
1506
+ " /loop status|off|clear|stop|<interval> <instruction>",
1507
+ " /autoresearch status|clear|<instruction>",
1508
+ " /autoresearch-lab status|clear|<instruction>",
1509
+ ].join("\n"), "info");
1510
+ return;
1511
+ }
1512
+
1513
+ if (arg.startsWith("checkins")) {
1514
+ ctx.ui.notify([
1515
+ `Check-ins: ${checkinDescription(cfg)}`,
1516
+ "Managed by orchestration: /goal or /loop activates them; stopping or clearing either disables them.",
1517
+ orchestrationSnapshotText(ctx),
1518
+ ].join("\n"), "info");
1519
+ return;
1520
+ }
1521
+
1522
+ ctx.ui.notify(piRogueCockpitText(cfg, state, readText(CURRENT_PATH).trim(), orchestrationSnapshotText(ctx)), "info");
1523
+ },
1524
+ });
1525
+
1526
+ // ── /advisor command ───────────────────────────────────────────────────
1527
+ pi.registerCommand("advisor", {
1528
+ description: "Senior engineering advisor. Usage: /advisor [on|off|status|config|pause|unpause|question]",
1529
+ getArgumentCompletions: (prefix: string) => advisorArgumentCompletions(prefix),
1530
+ handler: async (args, ctx) => {
1531
+ const a = String(args ?? "").trim().toLowerCase();
1532
+ const [cmd, ...rest] = a.split(/\s+/);
1533
+ const cfg = loadConfig();
1534
+ const state = loadState();
1535
+
1536
+ if (!a || cmd === "status") {
1537
+ const note = readText(CURRENT_PATH).trim();
1538
+ const resolved = await resolveModel(ctx, cfg);
1539
+ const route = state.router.review ?? state.router.preflight;
1540
+ const pause = advisorPauseRemaining(state, state.turns);
1541
+ ctx.ui.notify([
1542
+ note ? `🧭 ${truncate(note, 200)}` : "",
1543
+ route ? `Router: ${summarizeRoute(route)}${route.safety ? " · safety" : ""}` : "",
1544
+ "",
1545
+ `Mode: ${cfg.mode} | Review: ${cfg.review} | Check-ins: ${checkinDescription(cfg)} (orchestration-managed) | Model: ${resolved?.label || cfg.model || "auto"}`,
1546
+ pause > 0 ? `Advisor pause: ${pause} turn${pause === 1 ? "" : "s"} remaining` : "Advisor pause: off",
1547
+ `Turns: ${state.turns} | Calls: ${state.advisorCalls} | Cache hits: ${state.cacheHits}`,
1548
+ state.checkin.lastAt ? `Last check-in: ${new Date(state.checkin.lastAt).toLocaleString()} (${state.checkin.lastReason || "mid-hour"})` : "Last check-in: never",
1549
+ state.checkin.queued ? `Queued check-in: ${state.checkin.queuedReason || "due"}` : "",
1550
+ orchestrationSnapshotText(ctx),
1551
+ "",
1552
+ "Commands: /advisor on|off | /advisor status | /advisor config | /advisor pause <n turns> | <question>",
1553
+ "Tip: SOTA models auto-detected. No config needed.",
1554
+ ].filter(Boolean).join("\n"), "info");
1555
+ return;
1556
+ }
1557
+
1558
+ if (cmd === "on" && cfg.mode === "off") {
1559
+ const next = { ...cfg, mode: "auto" as const };
1560
+ saveConfig(next);
1561
+ setPiRogueStatus(ctx, next, state);
1562
+ ctx.ui.notify("Advisor enabled (auto mode).", "info");
1563
+ return;
1564
+ }
1565
+ if (cmd === "off") {
1566
+ const next = { ...cfg, mode: "off" as const };
1567
+ saveConfig(next);
1568
+ setPiRogueStatus(ctx, next, state);
1569
+ ctx.ui.notify("Advisor disabled.", "info");
1570
+ return;
1571
+ }
1572
+ if (cmd === "mode") {
1573
+ const v = rest[0];
1574
+ if (v === "auto" || v === "manual") {
1575
+ const next: AdvisorConfig = { ...cfg, mode: v };
1576
+ saveConfig(next);
1577
+ setPiRogueStatus(ctx, next, state);
1578
+ ctx.ui.notify(`Mode set to ${v}.`, "info");
1579
+ return;
1580
+ }
1581
+ if (v === "off") {
1582
+ const next = { ...cfg, mode: "off" as const };
1583
+ saveConfig(next);
1584
+ setPiRogueStatus(ctx, next, state);
1585
+ ctx.ui.notify("Advisor disabled.", "info");
1586
+ return;
1587
+ }
1588
+ ctx.ui.notify("Usage: /advisor mode auto|manual|off", "error");
1589
+ return;
1590
+ }
1591
+ if (cmd === "model") {
1592
+ const v = rest.join("/").trim();
1593
+ if (!v || !v.includes("/")) {
1594
+ const resolved = await resolveModel(ctx, cfg);
1595
+ ctx.ui.notify([
1596
+ `Current: ${resolved?.label || "auto"}`,
1597
+ "",
1598
+ "Usage: /advisor model <provider>/<model>",
1599
+ '(e.g. "openai-codex/gpt-5.5" or "anthropic/claude-opus-4-6")',
1600
+ "Run /advisor status for SOTA options.",
1601
+ ].join("\n"), "info");
1602
+ return;
1603
+ }
1604
+ saveConfig({ ...cfg, model: v });
1605
+ ctx.ui.notify(`Model set to ${v}. Remove field to auto-detect.`, "info");
1606
+ return;
1607
+ }
1608
+ if (cmd === "config") {
1609
+ const pause = advisorPauseRemaining(state, state.turns);
1610
+ ctx.ui.notify([
1611
+ "Advisor config (check-ins are orchestration-managed):",
1612
+ ` mode: "${cfg.mode}" — auto (preflight+post+cache) | manual | off`,
1613
+ ` review: "${cfg.review}" — light (changes/errors) | strict (every 3) | off`,
1614
+ ` checkins: "${cfg.checkins}" — set by active /goal or /loop lifecycle`,
1615
+ ` checkinIntervalMinutes: ${cfg.checkinIntervalMinutes}`,
1616
+ pause > 0 ? ` advisorPauseUntilTurn: ${pause} turn${pause === 1 ? "" : "s"} remaining` : " advisorPauseUntilTurn: off",
1617
+ ` model: "${cfg.model || "auto"}" — optional override for higher/advanced advisor model`,
1618
+ "",
1619
+ "Router logs: evals/advisor-router.jsonl",
1620
+ "Run /advisor <question> for immediate advice.",
1621
+ ].join("\n"), "info");
1622
+ return;
1623
+ }
1624
+ if (cmd === "review") {
1625
+ const v = rest[0];
1626
+ if (v === "light" || v === "strict" || v === "off") { const next: AdvisorConfig = { ...cfg, review: v }; saveConfig(next); setPiRogueStatus(ctx, next, state); ctx.ui.notify(`Review set to ${v}.`, "info"); return; }
1627
+ ctx.ui.notify("Usage: /advisor review light|strict|off", "error");
1628
+ return;
1629
+ }
1630
+ if (cmd === "checkins" || cmd === "checkin") {
1631
+ ctx.ui.notify([
1632
+ "Advisor check-ins are orchestration-managed now.",
1633
+ `Current: ${checkinDescription(cfg)}`,
1634
+ "Create or resume /goal or /loop to activate scheduled higher-model check-ins; stop or clear either to disable them.",
1635
+ orchestrationSnapshotText(ctx),
1636
+ ].join("\n"), "info");
1637
+ return;
1638
+ }
1639
+
1640
+ if (cmd === "pause") {
1641
+ const value = rest[0];
1642
+ const turns = Number.parseInt(String(value || ""), 10);
1643
+ if (!Number.isFinite(turns) || turns <= 0) {
1644
+ if (value === "off" || value === "cancel" || value === "clear") {
1645
+ state.advisorPauseUntilTurn = undefined;
1646
+ saveState(state);
1647
+ ctx.ui.notify("Advisor pause cleared.", "info");
1648
+ return;
1649
+ }
1650
+ ctx.ui.notify("Usage: /advisor pause <turns> (or /advisor pause off)", "error");
1651
+ return;
1652
+ }
1653
+ state.advisorPauseUntilTurn = state.turns + turns;
1654
+ saveState(state);
1655
+ ctx.ui.notify(`Advisor pause enabled for next ${turns} turn${turns === 1 ? "" : "s"}.`, "info");
1656
+ return;
1657
+ }
1658
+
1659
+ if (cmd === "unpause") {
1660
+ state.advisorPauseUntilTurn = undefined;
1661
+ saveState(state);
1662
+ ctx.ui.notify("Advisor pause cleared.", "info");
1663
+ return;
1664
+ }
1665
+
1666
+ // Anything else: treat as a question to the advisor
1667
+ const r = await askAdvisor(pi, ctx, a, "slash", true);
1668
+ if (r.error) {
1669
+ ctx.ui.notify(r.text, "warning");
1670
+ return;
1671
+ }
1672
+ sendAdvisorAnswer(pi, r.text);
1673
+ },
1674
+ });
1675
+ }
1676
+
1677
+ export default function advisorExtension(pi: ExtensionAPI) { registerAdvisor(pi); }