diff --git a/README.md b/README.md index 15ca009..73a1a1b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ Specialists are read/search-only and suggest-only by default. They can return fi /pi-rogue-advisor /pi-rogue-advisor settings /pi-rogue-advisor model list +/pi-rogue-advisor board watch status +/pi-rogue-advisor board watch intervene +/pi-rogue-advisor board watch head on /pi-rogue-advisor board specialist ask reviewer inspect the proposed change for regressions /pi-rogue-advisor board head ask what decision is safest before merging ``` @@ -76,9 +79,11 @@ Advisor model selection is separate from Pi's active model. The user-visible con `null` selects one authenticated compatible text model using the bounded preference for that role. `/pi-rogue-advisor model list [advisor|specialist|head]` shows available candidates, role recommendations, and declared reasoning/context/cost facts without making an LLM call. The explainable policy is quality-balanced Advisor, efficient specialists, and reasoning/context-oriented Head—not a universal quality ranking. An explicit `/` value overrides discovery and is warned about if unavailable. Resolution attempts the configured model and at most one preferred fallback—never an unbounded provider loop. Model selection never changes Pi's global active model. +The **main Pi model is separate and Pi-owned**. Pi-Rogue never changes it. For a cheap/fast default, start Pi with an authenticated registry entry such as `pi --model openrouter/deepseek/deepseek-v4-flash`. Equivalent provider IDs may be available (`opencode-go/deepseek-v4-flash`, `ollama-cloud/deepseek-v4-flash`); Pi's `/list-models` output is authoritative for the current host. DeepSeek's official pricing page currently lists V4 Flash as an efficiency-oriented option; prices, limits, availability, and provider markups change, so verify them before budgeting. Treat Luna/Kimi high-end variants as quality/escalation choices rather than assuming they are cheaper. The active model is shown by `/pi-rogue-advisor status`. + ## Explicit-only safety boundary -Normal session lifecycle events do not call models. Pi-Rogue has no automatic review, check-in, routing, model switching, prompt rewriting, context database, Fusion/panel calls, orchestration loops, or background workers. An explicit Advisor, specialist, or Head call is bounded by its configured token/time/call limits and fails closed for unavailable models, oversized or unsanitized input, disallowed roles, and mutating tools. Results are suggestions only; they do not suppress work, retry themselves, or trigger another call. +Normal session lifecycle events do not call models. The deterministic Board watcher runs in `shadow` mode by default to record compact risks without model calls. `intervene` mode explicitly queues a visible, non-binding next-turn suggestion; it does not trigger a turn, switch models, or mutate anything. Head escalation is separately off by default and can be enabled with `/pi-rogue-advisor board watch head on`. Pi-Rogue has no automatic review, model switching, prompt rewriting, or worker mutation. Explicit Advisor, specialist, and Head calls remain bounded and fail closed. ## Package and development diff --git a/packages/advisor/README.md b/packages/advisor/README.md index 0ae47c0..3966ea7 100644 --- a/packages/advisor/README.md +++ b/packages/advisor/README.md @@ -44,6 +44,9 @@ The leaf package is private and is not an independent user install target. | `/pi-rogue-advisor settings` | Show the same local configuration without starting an advisory call | | `/pi-rogue-advisor model list [advisor\|specialist\|head]` | Inspect available models and role recommendations without an LLM call | | `/pi-rogue-advisor model [advisor\|specialist\|head] /\|null` | Set or clear one model-map slot | +| `/pi-rogue-advisor board watch status` | Show deterministic watcher mode, risk runs, interventions, and suppression counts | +| `/pi-rogue-advisor board watch off\|shadow\|intervene` | Disable, record-only, or queue non-binding next-turn Board suggestions | +| `/pi-rogue-advisor board watch head status\|on\|off` | Inspect or enable bounded automatic Head escalation | | `/pi-rogue-advisor board specialist status` | Show specialist mode, limits, and call counts | | `/pi-rogue-advisor board specialist suggest` | Return a local suggestion about which static role may help; does not call a model | | `/pi-rogue-advisor board specialist ask ` | Make one explicit bounded specialist call | @@ -76,6 +79,8 @@ The user-visible configuration is intentionally small: `null` uses bounded role-appropriate selection from compatible text models. Use `model list` to see authenticated text models, the selected/recommended candidate for each role, and facts such as reasoning support, context window, token limit, and declared input/output cost. The role policy is intentionally explainable rather than a universal quality claim: Advisor balances quality, specialists prefer efficiency, and Head prefers reasoning/context. Explicit values use `/` and take precedence; unavailable or unauthenticated overrides are retained but shown with a warning. Resolution attempts the configured candidate and at most one preferred fallback. Inspection never calls a model, changes Pi's global active model, or persists config. +The active **main Pi model is separate from all three Pi-Rogue role slots**. Pi-Rogue reports it but never changes it. For a cheap/fast starting point, use a currently authenticated registry entry such as `pi --model openrouter/deepseek/deepseek-v4-flash`; verify current IDs and pricing with Pi's model list and the provider's pricing page. Luna/Kimi high-end variants should be treated as quality-oriented escalation choices, not assumed to be cheaper. + Specialists default to `suggest` mode and are limited to three calls per session. Board inputs are compact, bounded, sanitized ledger data rather than raw transcripts. Disallowed roles/tools, missing evidence, oversized input, unavailable models, rate limits, and malformed responses fail closed with visible metadata. Suggestions cannot suppress the user's task, edit files, execute commands, or trigger a specialist or Head call. ## Session closeout @@ -94,4 +99,4 @@ Each fixture compares a no-Advisor baseline with an explicit Advisor/Board obser ## Explicit-only guarantee -There is no automatic preflight, review, check-in, route decision, model switch, prompt rewrite, context database, orchestration loop, Fusion/panel call, background worker, or lifecycle model work. Pi-Rogue makes zero model calls during ordinary session startup, turn-end, and agent-end handling. Only an explicit Advisor, specialist, or Head invocation can perform bounded model work. +The deterministic Board watcher may run during ordinary lifecycle events in `shadow` mode without model work. `intervene` mode queues a visible, non-binding `nextTurn` Board suggestion with no automatic turn, model switch, prompt rewrite, or mutation. Head escalation is off by default and, when explicitly enabled, is bounded, deduplicated, read-only, and fail-closed. The regular Advisor and specialist calls remain on-demand. Pi-Rogue makes zero model calls during ordinary startup, turn-end, and agent-end handling unless the user explicitly enabled Board-to-Head escalation. diff --git a/packages/advisor/src/board-head.test.ts b/packages/advisor/src/board-head.test.ts index fc8ec9a..a8574e8 100644 --- a/packages/advisor/src/board-head.test.ts +++ b/packages/advisor/src/board-head.test.ts @@ -89,6 +89,16 @@ describe("head-of-board adapter", () => { expect(request.messages[0]?.content).toContain("decision_needed"); }); + it("derives automatic escalation reason from the material risk", () => { + const ledger = ledgerFrom([ + { type: "tool_failure", tool: "bash", key: "npm-test", message: "failed", turn: 1 }, + { type: "tool_failure", tool: "bash", key: "npm-test", message: "failed", turn: 2 }, + { type: "tool_failure", tool: "bash", key: "npm-test", message: "failed", turn: 3 }, + ]); + const request = buildHeadOfBoardRequest({ ledger, decision: decideBoardAction(ledger), question: "What next?" }); + expect(request.escalation.reason).toBe("repeated_failure"); + }); + it("preserves promoted shadow risks when rebuilding a head-of-board ledger", () => { const ledger = ledgerFrom([{ type: "turn", turn: 3, progress: true }]); const risk = { id: "repeated_failure:npm-test", type: "repeated_failure" as const, severity: "important" as const, evidence: "npm test failed repeatedly", evidencePointers: ["failure:npm-test"] }; @@ -114,15 +124,22 @@ describe("head-of-board adapter", () => { const ledger = ledgerFrom([{ type: "file_changed", path: "packages/advisor/src/board-head.ts", turn: 4 }]); const request = buildHeadOfBoardRequest({ ledger, - decision: { action: "would_whisper", severity: "important", reason: "rerun with Authorization: Bearer abcdef1234567890 token=abcd1234 AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP", riskIds: ["risk:token=abcd1234"] }, - question: "Assess release readiness with MY_SECRET=shhhhhhh", + decision: { action: "would_whisper", severity: "important", reason: "rerun with Authorization: Bearer abcdef1234567890 Authorization: Basic dXNlcjpwYXNz token=abcd1234 AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP ASIAABCDEFGHIJKLMNOP", riskIds: ["risk:token=abcd1234"] }, + question: "Assess release readiness with MY_SECRET=shhhhhhh and {\"apiKey\": \"json-secret-value\", \"AWS_SECRET_ACCESS_KEY\": \"json-aws-secret-value\", \"SecretAccessKey\": \"camel-aws-secret\", \"SessionToken\": \"camel-session-token\"}", }); const payload = JSON.stringify({ content: request.messages[0]?.content, escalation: request.escalation }); expect(payload).not.toContain("abcd1234"); expect(payload).not.toContain("abcdef1234567890"); expect(payload).not.toContain("AKIAABCDEFGHIJKLMNOP"); + expect(payload).not.toContain("ASIAABCDEFGHIJKLMNOP"); + expect(payload).not.toContain("EFGH"); expect(payload).not.toContain("shhhhhhh"); + expect(payload).not.toContain("dXNlcjpwYXNz"); + expect(payload).not.toContain("json-secret-value"); + expect(payload).not.toContain("json-aws-secret-value"); + expect(payload).not.toContain("camel-aws-secret"); + expect(payload).not.toContain("camel-session-token"); expect(payload).toContain("[secret]"); }); diff --git a/packages/advisor/src/board-head.ts b/packages/advisor/src/board-head.ts index b19e723..bc4a8d3 100644 --- a/packages/advisor/src/board-head.ts +++ b/packages/advisor/src/board-head.ts @@ -82,9 +82,10 @@ export interface HeadOfBoardResult { export type HeadOfBoardComplete = (systemPrompt: string, messages: Array<{ role: "user"; content: string }>, options: { maxTokens: number; reasoning: ThinkingLevel }) => Promise; -const SECRET_RE = /\b(?:(?:sk|ghp|gho|github_pat|xox[abprs]|hf)[-_][A-Za-z0-9_\-]{8,}|AKIA[A-Z0-9]{12,})\b/g; -const KEYED_SECRET_RE = /\b(?:api[_-]?key|token|secret|password|authorization)\b\s*[:=]\s*["']?[^\s"',;}]{4,}/gi; -const NAMED_SECRET_ASSIGNMENT_RE = /\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*\s*=\s*[^\s"',;}]{4,}/gi; +const SECRET_RE = /\b(?:(?:sk|ghp|gho|github_pat|xox[abprs]|hf)[-_][A-Za-z0-9_\-]{8,}|(?:AKIA|ASIA)[A-Z0-9]{16})\b/g; +const KEYED_SECRET_RE = /(["']?\b(?:api[_-]?key|token|secret|password|authorization)\b["']?)\s*[:=]\s*["']?[^\s"',;}]{4,}/gi; +const BASIC_AUTH_RE = /(["']?authorization["']?\s*[:=]\s*["']?)Basic\s+[A-Za-z0-9+/=]+/gi; +const NAMED_SECRET_ASSIGNMENT_RE = /(["']?\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key|access[_-]?key)[A-Za-z0-9_-]*["']?)\s*[:=]\s*["']?[^\s"',;}]{4,}/gi; const BARE_BEARER_RE = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi; export function defaultHeadOfBoardConfig(): HeadOfBoardConfig { @@ -125,9 +126,10 @@ export function normalizeHeadOfBoardConfig(raw: unknown): HeadOfBoardConfig { function cleanText(value: unknown, max = 500): string { return String(value ?? "") .replace(BARE_BEARER_RE, "Bearer [secret]") + .replace(BASIC_AUTH_RE, "$1[secret]") .replace(SECRET_RE, "[secret]") - .replace(KEYED_SECRET_RE, (match) => `${match.split(/[:=]/, 1)[0]}=[secret]`) - .replace(NAMED_SECRET_ASSIGNMENT_RE, (match) => `${match.split(/=/, 1)[0].trim()}=[secret]`) + .replace(KEYED_SECRET_RE, (_match, key: string) => `${key}=[secret]`) + .replace(NAMED_SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=[secret]`) .replace(/\s+/g, " ") .trim() .slice(0, max); diff --git a/packages/advisor/src/board-watcher.test.ts b/packages/advisor/src/board-watcher.test.ts index 1ef7c0e..d27bbdc 100644 --- a/packages/advisor/src/board-watcher.test.ts +++ b/packages/advisor/src/board-watcher.test.ts @@ -12,8 +12,9 @@ function riskyLedger(turn = 1) { describe("Board watcher", () => { it("defaults to deterministic shadow mode", () => { - expect(defaultBoardWatchConfig()).toEqual({ mode: "shadow", cooldownTurns: 3, maxInterventions: 4 }); - expect(normalizeBoardWatchConfig({ mode: "intervene", cooldownTurns: 999, maxInterventions: -1 })).toEqual({ mode: "intervene", cooldownTurns: 100, maxInterventions: 0 }); + expect(defaultBoardWatchConfig()).toEqual({ mode: "shadow", cooldownTurns: 3, maxInterventions: 4, headEscalation: "off", headMaxCalls: 1 }); + expect(defaultBoardWatchState()).toEqual({ runs: 0, interventions: 0, headAttempts: 0, suppressed: 0 }); + expect(normalizeBoardWatchConfig({ mode: "intervene", cooldownTurns: 999, maxInterventions: -1, headEscalation: "enabled", headMaxCalls: 99 })).toEqual({ mode: "intervene", cooldownTurns: 100, maxInterventions: 0, headEscalation: "enabled", headMaxCalls: 4 }); }); it("records material risks without calling a model or queuing advice in shadow mode", () => { @@ -40,6 +41,7 @@ describe("Board watcher", () => { const first = runBoardWatch(config, defaultBoardWatchState(), riskyLedger(1), 1); const changed = runBoardWatch(config, first.state, riskyLedger(2), 2); expect(changed.advice).toBeUndefined(); + expect(changed.riskFingerprint).toBeDefined(); expect(changed.skipped).toBe("cooldown"); const consecutive = runBoardWatch(config, changed.state, riskyLedger(3), 3); expect(consecutive.advice).toBeUndefined(); diff --git a/packages/advisor/src/board-watcher.ts b/packages/advisor/src/board-watcher.ts index cbb7234..3ff341f 100644 --- a/packages/advisor/src/board-watcher.ts +++ b/packages/advisor/src/board-watcher.ts @@ -7,16 +7,20 @@ export interface BoardWatchConfig { mode: BoardWatchMode; cooldownTurns: number; maxInterventions: number; + headEscalation: "off" | "enabled"; + headMaxCalls: number; } export interface BoardWatchState { runs: number; interventions: number; + headAttempts: number; suppressed: number; lastAt?: string; lastTurn?: number; lastInterventionTurn?: number; lastRiskFingerprint?: string; + lastEscalatedRiskFingerprint?: string; lastDecision?: BoardDecision; } @@ -42,7 +46,7 @@ export interface BoardWatchResult { } export function defaultBoardWatchConfig(): BoardWatchConfig { - return { mode: "shadow", cooldownTurns: 3, maxInterventions: 4 }; + return { mode: "shadow", cooldownTurns: 3, maxInterventions: 4, headEscalation: "off", headMaxCalls: 1 }; } export function normalizeBoardWatchConfig(raw: unknown): BoardWatchConfig { @@ -57,11 +61,13 @@ export function normalizeBoardWatchConfig(raw: unknown): BoardWatchConfig { mode: record.mode === "off" || record.mode === "intervene" ? record.mode : "shadow", cooldownTurns: bounded(record.cooldownTurns, defaults.cooldownTurns, 0, 100), maxInterventions: bounded(record.maxInterventions, defaults.maxInterventions, 0, 32), + headEscalation: record.headEscalation === "enabled" ? "enabled" : "off", + headMaxCalls: bounded(record.headMaxCalls, defaults.headMaxCalls, 0, 4), }; } export function defaultBoardWatchState(): BoardWatchState { - return { runs: 0, interventions: 0, suppressed: 0 }; + return { runs: 0, interventions: 0, headAttempts: 0, suppressed: 0 }; } export function normalizeBoardWatchState(raw: unknown): BoardWatchState { @@ -71,11 +77,13 @@ export function normalizeBoardWatchState(raw: unknown): BoardWatchState { return { runs: count(record.runs), interventions: count(record.interventions), + headAttempts: count(record.headAttempts), suppressed: count(record.suppressed), lastAt: typeof record.lastAt === "string" ? record.lastAt : undefined, lastTurn: Number.isFinite(Number(record.lastTurn)) ? Math.max(0, Math.floor(Number(record.lastTurn))) : undefined, lastInterventionTurn: Number.isFinite(Number(record.lastInterventionTurn)) ? Math.max(0, Math.floor(Number(record.lastInterventionTurn))) : undefined, lastRiskFingerprint: typeof record.lastRiskFingerprint === "string" ? record.lastRiskFingerprint : undefined, + lastEscalatedRiskFingerprint: typeof record.lastEscalatedRiskFingerprint === "string" ? record.lastEscalatedRiskFingerprint : undefined, lastDecision: record.lastDecision as BoardDecision | undefined, }; } @@ -99,6 +107,10 @@ function adviceText(decision: Extract= config.maxInterventions) { + if (config.mode === "intervene" && prior.interventions >= config.maxInterventions) { state.suppressed += 1; return { state, decision, riskFingerprint: id, skipped: "limit" }; } diff --git a/packages/advisor/src/completions.test.ts b/packages/advisor/src/completions.test.ts index 9c2cead..726f09b 100644 --- a/packages/advisor/src/completions.test.ts +++ b/packages/advisor/src/completions.test.ts @@ -16,6 +16,8 @@ describe("advisor completions", () => { for (const leaf of ADVISOR_CANONICAL_CONTROL_LEAVES) { expect(readme, `README: ${leaf}`).toContain(`/pi-rogue-advisor ${leaf}`); } + expect(readme).toContain("/pi-rogue-advisor board watch status"); + expect(readme).toContain("/pi-rogue-advisor board watch head status\\|on\\|off"); expect(readme).toContain("/pi-rogue-advisor board specialist ask"); expect(readme).toContain("/pi-rogue-advisor board head ask"); expect(agents).toContain("/pi-rogue-advisor"); @@ -29,7 +31,8 @@ describe("advisor completions", () => { it("offers explicit Board controls and roles", () => { expect(advisorArgumentCompletions("board ")?.map((i) => i.value)).toEqual(["watch", "specialist", "head"]); - expect(advisorArgumentCompletions("board watch ")?.map((i) => i.value)).toEqual(["status", "off", "shadow", "intervene"]); + expect(advisorArgumentCompletions("board watch ")?.map((i) => i.value)).toEqual(["status", "off", "shadow", "intervene", "head"]); + expect(advisorArgumentCompletions("board watch head ")?.map((i) => i.value)).toEqual(["status", "on", "off"]); expect(advisorArgumentCompletions("review ")).toBeNull(); expect(advisorArgumentCompletions("profile ")).toBeNull(); }); diff --git a/packages/advisor/src/completions.ts b/packages/advisor/src/completions.ts index 0c4d8d5..d3da61d 100644 --- a/packages/advisor/src/completions.ts +++ b/packages/advisor/src/completions.ts @@ -47,7 +47,8 @@ const advisorNested: Record> = { model: [["list", "inspect available role candidates"], ["advisor"], ["specialist"], ["head"], ["null"]], "model list": [["advisor"], ["specialist"], ["head"]], board: [["watch"], ["specialist"], ["head"]], - "board watch": [["status"], ["off"], ["shadow"], ["intervene"]], + "board watch": [["status"], ["off"], ["shadow"], ["intervene"], ["head"]], + "board watch head": [["status"], ["on"], ["off"]], }; const piRogueTopLevel: Array<[string, string?]> = [ diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index 837ca15..963241f 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { advisorBoardWatchConfigPath, advisorModelInspectionText, inspectAdvisorModels, advisorSessionStatePath, normalizeAdvisorConfig, rankAvailableAdvisorModels, registerAdvisor, resolveModelCandidates, type AdvisorConfig } from "./extension.js"; @@ -259,7 +259,77 @@ describe("Advisor PR1 lifecycle", () => { expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ nonBinding: true, readOnly: true }) }), { triggerTurn: false, deliverAs: "nextTurn" }); expect(vi.mocked(completeSimple)).not.toHaveBeenCalled(); } finally { - unlinkSync(advisorBoardWatchConfigPath()); + if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); + } + }); + + it("escalates one material Board risk to a bounded read-only Head", async () => { + const handlers = new Map void>(); + const sendMessage = vi.fn(); + const pi = { + on: (event: string, handler: (event: unknown, ctx: any) => void) => { handlers.set(event, handler); }, + registerMessageRenderer: vi.fn(), + registerTool: vi.fn(), + registerCommand: vi.fn(), + sendMessage, + } as unknown as ExtensionAPI; + vi.mocked(completeSimple).mockResolvedValue({ content: [{ type: "text", text: "Validate the changed file before proceeding." }] } as any); + writeFileSync(advisorBoardWatchConfigPath(), JSON.stringify({ mode: "intervene", cooldownTurns: 0, maxInterventions: 4, headEscalation: "enabled", headMaxCalls: 1 })); + try { + registerAdvisor(pi); + const ctx = { + session: { id: `advisor-head-watch-${Date.now()}-${Math.random()}` }, + cwd: process.cwd(), + ui: { setStatus: vi.fn() }, + modelRegistry: { + find: (provider: string, id: string) => provider === "openai-codex" && id === "gpt-5.5" ? { provider, id, input: ["text"] } : undefined, + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key" }), + }, + }; + handlers.get("session_start")?.({}, ctx); + handlers.get("turn_end")?.({ turnIndex: 0, toolResults: [{ toolName: "edit", input: { path: "packages/advisor/src/head-watched.ts" }, status: "success" }] }, ctx); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ readOnly: true }) }), expect.objectContaining({ triggerTurn: false, deliverAs: "nextTurn" })); + expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(1); + handlers.get("turn_end")?.({ turnIndex: 1, toolResults: [{ toolName: "edit", input: { path: "packages/advisor/src/another-head-watched.ts" }, status: "success" }] }, ctx); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(1); + } finally { + if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); + } + }); + + it("can escalate a shadow risk to Head without enabling Board intervention", async () => { + const handlers = new Map void>(); + const sendMessage = vi.fn(); + const pi = { + on: (event: string, handler: (event: unknown, ctx: any) => void) => { handlers.set(event, handler); }, + registerMessageRenderer: vi.fn(), + registerTool: vi.fn(), + registerCommand: vi.fn(), + sendMessage, + } as unknown as ExtensionAPI; + const callsBefore = vi.mocked(completeSimple).mock.calls.length; + vi.mocked(completeSimple).mockResolvedValue({ content: [{ type: "text", text: "Validate before continuing." }] } as any); + writeFileSync(advisorBoardWatchConfigPath(), JSON.stringify({ mode: "shadow", maxInterventions: 0, headEscalation: "enabled", headMaxCalls: 1 })); + try { + registerAdvisor(pi); + const ctx = { + session: { id: `advisor-shadow-head-${Date.now()}-${Math.random()}` }, + cwd: process.cwd(), + ui: { setStatus: vi.fn() }, + modelRegistry: { + find: (provider: string, id: string) => provider === "openai-codex" && id === "gpt-5.5" ? { provider, id, input: ["text"] } : undefined, + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key" }), + }, + }; + handlers.get("turn_end")?.({ turnIndex: 0, toolResults: [{ toolName: "edit", input: { path: "packages/advisor/src/shadow-head.ts" }, status: "success" }] }, ctx); + handlers.get("turn_end")?.({ turnIndex: 1, toolResults: [{ toolName: "edit", input: { path: "packages/advisor/src/shadow-head-2.ts" }, status: "success" }] }, ctx); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(callsBefore + 1); + expect(sendMessage).not.toHaveBeenCalled(); + } finally { + if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); } }); @@ -281,6 +351,26 @@ describe("Advisor PR1 lifecycle", () => { expect(state.boardEvents.filter((item: any) => item.type === "tool_failure")).toHaveLength(3); }); + it("reports Pi's active main model without changing it", async () => { + const commands = new Map(); + const notifications: string[] = []; + const pi = { + on: vi.fn(), + registerMessageRenderer: vi.fn(), + registerTool: vi.fn(), + registerCommand: (name: string, command: any) => commands.set(name, command), + } as unknown as ExtensionAPI; + registerAdvisor(pi); + await commands.get("pi-rogue-advisor").handler("status", { + model: { provider: "openrouter", id: "deepseek/deepseek-v4-flash" }, + modelRegistry: { getAvailable: () => [] }, + session: { id: `advisor-main-model-${Date.now()}-${Math.random()}` }, + cwd: process.cwd(), + ui: { notify: (text: string) => notifications.push(text), setStatus: vi.fn() }, + }); + expect(notifications[0]).toContain("Pi main model: openrouter/deepseek/deepseek-v4-flash"); + }); + it("increments turns on turn_end only, keeping cooldown turn accounting stable", () => { const handlers = new Map void>(); const pi = { diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index 7b515c6..fcb6475 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -23,7 +23,7 @@ import { type SpecialistCallState, } from "./board-specialist.js"; import { loadBoardRoleBody, loadBoardRoleCatalog } from "./board-roles.js"; -import { defaultBoardWatchConfig, normalizeBoardWatchConfig, normalizeBoardWatchState, runBoardWatch, type BoardWatchConfig, type BoardWatchState } from "./board-watcher.js"; +import { boardWatchRiskFingerprint, defaultBoardWatchConfig, normalizeBoardWatchConfig, normalizeBoardWatchState, runBoardWatch, type BoardWatchConfig, type BoardWatchState } from "./board-watcher.js"; // ── Explicit-only configuration ───────────────────────────────────────── @@ -1844,6 +1844,86 @@ function collectLifecycleBoardEvents(state: SessionState, toolResults: unknown[] state.boardEvents = [...state.boardEvents, ...events].slice(-MAX_BOARD_EVENTS); } +function reserveBoardHeadEscalation(config: BoardWatchConfig, state: SessionState, riskFingerprint: string): boolean { + if (config.headEscalation !== "enabled" || config.headMaxCalls <= 0) return false; + if (state.boardWatch.lastEscalatedRiskFingerprint === riskFingerprint || state.boardWatch.headAttempts >= config.headMaxCalls) return false; + state.boardWatch.headAttempts += 1; + state.boardWatch.lastEscalatedRiskFingerprint = riskFingerprint; + saveState(state); + return true; +} + +async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, state: SessionState, riskFingerprint: string): Promise { + const watch = loadBoardWatchConfig(); + const headConfig = { + ...defaultHeadOfBoardConfig(), + mode: "enabled" as const, + maxTokens: cfg.board.headMaxTokens, + maxCallsPerSession: watch.headMaxCalls, + callsUsed: state.headOfBoard?.calls ?? 0, + }; + try { + const ledger = currentBoardLedger(ctx, state); + const decision = decideBoardAction(ledger); + const result = await callHeadOfBoardAdapter(headConfig, { + ledger, + decision, + question: "Review this material Board risk and recommend the next safe step. Do not request or perform mutations.", + }, async (systemPrompt, messages, options) => completeWithHigherAdvisorModel(ctx, cfg, systemPrompt, messages, { + ...options, + role: "head", + maxAttempts: 1, + })); + state.headOfBoard = state.headOfBoard ?? { calls: 0 }; + if (result.skipped) { + const latest = loadState(ctx); + latest.headOfBoard = { ...(latest.headOfBoard ?? { calls: 0 }), lastSkipped: result.skipped }; + latest.boardWatch.headAttempts = Math.max(latest.boardWatch.headAttempts, state.boardWatch.headAttempts); + saveState(latest); + return; + } + if (!result.response) { + const latest = loadState(ctx); + latest.headOfBoard = { ...(latest.headOfBoard ?? { calls: 0 }), lastSkipped: "no_response" }; + latest.boardWatch.headAttempts = Math.max(latest.boardWatch.headAttempts, state.boardWatch.headAttempts); + saveState(latest); + return; + } + const latest = loadState(ctx); + const latestLedger = currentBoardLedger(ctx, latest); + const latestDecision = decideBoardAction(latestLedger); + if (boardWatchRiskFingerprint(latestLedger, latestDecision) !== riskFingerprint) { + latest.headOfBoard = { ...(latest.headOfBoard ?? { calls: 0 }), lastSkipped: "stale_risk" }; + latest.boardWatch.headAttempts = Math.max(latest.boardWatch.headAttempts, state.boardWatch.headAttempts); + saveState(latest); + return; + } + latest.headOfBoard = { + ...(latest.headOfBoard ?? { calls: 0 }), + calls: (latest.headOfBoard?.calls ?? 0) + result.accounting.headOfBoardCalls, + lastAt: new Date().toISOString(), + lastModel: result.response.model, + lastSkipped: undefined, + }; + latest.boardWatch.headAttempts = Math.max(latest.boardWatch.headAttempts, state.boardWatch.headAttempts); + saveState(latest); + if (typeof pi.sendMessage === "function") { + pi.sendMessage({ + customType: "advisor:board", + content: `Head-of-Board advice (read-only, non-binding; model ${result.response.model}):\n${String(result.response.text).slice(0, 1800)}`, + display: true, + details: { kind: "board-head", decision: "would_whisper", severity: latestDecision.action === "would_whisper" ? latestDecision.severity : "important", reason: "Board escalation", nonBinding: true, readOnly: true }, + }, { triggerTurn: false, deliverAs: "nextTurn" }); + } + } catch (error) { + const latest = loadState(ctx); + latest.headOfBoard = { ...(latest.headOfBoard ?? { calls: 0 }), lastSkipped: "error" }; + latest.boardWatch.headAttempts = Math.max(latest.boardWatch.headAttempts, state.boardWatch.headAttempts); + saveState(latest); + appendAdvisorDiagnostic("board_head_escalation_failed", { error: error instanceof Error ? error.message : String(error) }); + } +} + function recordBoardWatchIfEnabled(pi: ExtensionAPI, ctx: any, state: SessionState): void { const config = loadBoardWatchConfig(); if (config.mode === "off") return; @@ -1857,6 +1937,9 @@ function recordBoardWatchIfEnabled(pi: ExtensionAPI, ctx: any, state: SessionSta details: result.advice.details, }, { triggerTurn: false, deliverAs: "nextTurn" }); } + if (result.decision.action === "would_whisper" && result.riskFingerprint && reserveBoardHeadEscalation(config, state, result.riskFingerprint)) { + void escalateBoardHead(pi, ctx, loadConfig(), state, result.riskFingerprint); + } } function collectLifecycleEvidence(event: unknown, ctx: any, agentEnd: boolean, pi: ExtensionAPI): void { @@ -2036,11 +2119,15 @@ export function registerAdvisor(pi: ExtensionAPI): void { } if (!rawArg || command === "status" || command === "settings" || command === "config") { + const activeMainModel = modelId(ctx?.model) || "none reported by Pi"; + const watch = loadBoardWatchConfig(); ctx.ui.notify([ + `Pi main model: ${activeMainModel}`, advisorModelStatusText(cfg, availableAdvisorModels(ctx)), `Board: specialists=${cfg.board.specialists}, maxSpecialistCalls=${cfg.board.maxSpecialistCalls}, specialistMaxTokens=${cfg.board.specialistMaxTokens}, headMaxTokens=${cfg.board.headMaxTokens}`, + `Board watcher: ${watch.mode}; Head escalation: ${watch.headEscalation}`, `Explicit calls: ${state.advisorCalls} advisor, ${state.specialistDispatch?.calls ?? 0} specialist, ${state.headOfBoard?.calls ?? 0} head`, - "Use /pi-rogue-advisor model list for role recommendations and available model facts.", + "Pi-Rogue never changes the Pi main model. Use /pi-rogue-advisor model list for role recommendations and available model facts.", ].join("\n"), "info"); return; } @@ -2076,8 +2163,9 @@ export function registerAdvisor(pi: ExtensionAPI): void { if (action === "status") { ctx.ui.notify([ `Board watcher: ${current.mode}`, - `Runs: ${state.boardWatch.runs}; interventions: ${state.boardWatch.interventions}; suppressed: ${state.boardWatch.suppressed}`, + `Runs: ${state.boardWatch.runs}; interventions: ${state.boardWatch.interventions}; Head attempts: ${state.boardWatch.headAttempts}; suppressed: ${state.boardWatch.suppressed}`, `Cooldown: ${current.cooldownTurns} turn(s); max interventions: ${current.maxInterventions}`, + `Head escalation: ${current.headEscalation}; max calls: ${current.headMaxCalls}`, "Watcher is deterministic and read-only; interventions are non-binding next-turn advice.", ].join("\n"), "info"); return; @@ -2087,7 +2175,19 @@ export function registerAdvisor(pi: ExtensionAPI): void { ctx.ui.notify(`Board watcher mode set to ${action}.`, "info"); return; } - ctx.ui.notify("Usage: board watch status|off|shadow|intervene", "error"); + if (action === "head") { + const headAction = String(parts[3] ?? "status").toLowerCase(); + if (headAction === "status") { + ctx.ui.notify(`Board Head escalation: ${current.headEscalation} (max ${current.headMaxCalls} call(s)).`, "info"); + return; + } + if (headAction === "on" || headAction === "off") { + saveBoardWatchConfig({ ...current, headEscalation: headAction === "on" ? "enabled" : "off" }); + ctx.ui.notify(`Board Head escalation ${headAction === "on" ? "enabled" : "disabled"}.`, "info"); + return; + } + } + ctx.ui.notify("Usage: board watch status|off|shadow|intervene|head status|on|off", "error"); return; } if (area === "head") { diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 84c3a2e..023d93c 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -39,6 +39,8 @@ The `advisor` tool is also explicit. Registering the bundle does not start model /pi-rogue-advisor settings /pi-rogue-advisor model list [advisor|specialist|head] /pi-rogue-advisor model [advisor|specialist|head] /|null +/pi-rogue-advisor board watch status|off|shadow|intervene +/pi-rogue-advisor board watch head status|on|off /pi-rogue-advisor board specialist status /pi-rogue-advisor board specialist suggest /pi-rogue-advisor board specialist ask @@ -71,9 +73,11 @@ Advisor configuration keeps three independent model slots: `null` means bounded role-appropriate selection from authenticated compatible text models. `model list` shows the available catalog, selected/recommended candidate per role, and model facts including reasoning support, context window, token limit, and declared input/output cost—without making an LLM call. The policy is explainable rather than a universal quality claim: Advisor balances quality, specialists prefer efficiency, and Head prefers reasoning/context. Explicit values override discovery; unavailable or unauthenticated overrides are retained but warned about. Resolution stops after the explicit model and at most one preferred fallback. It never scans an unbounded provider list and never changes Pi's global active model. +The active **main Pi model is owned by Pi**, not this package. A cheap/fast starting point is often `pi --model openrouter/deepseek/deepseek-v4-flash` when that provider is authenticated; check `/list-models` for the current registry. Provider prices and model IDs change. `/pi-rogue-advisor status` reports the active Pi model alongside the independent Advisor, specialist, and Head slots. + ## Zero-background-call guarantee -The bundle has no automatic review, preflight, check-in, router, model-switch, prompt-rewrite, context-storage, panel/fusion, orchestration, loop, or worker behavior. Closeout lifecycle collection only snapshots bounded local facts when a user has explicitly started a closeout; it makes no model calls. Normal Pi lifecycle events make zero Pi-Rogue model calls. A model call occurs only after the user explicitly invokes the Advisor tool or command, a specialist `ask`, or a Head `ask`. Explicit failures are visible and fail closed; no result silently retries, suppresses the user's task, or triggers another call. +The deterministic Board watcher runs in `shadow` mode by default and makes zero model calls. `intervene` mode can queue a visible, non-binding next-turn Board suggestion without triggering a turn, changing the active model, or mutating the workspace. Head escalation is separately disabled by default and can be enabled explicitly. Closeout lifecycle collection snapshots bounded local facts only after a user starts a closeout. The regular Advisor, specialists, and explicit Head remain on-demand; automatic Head escalation is bounded, read-only, deduplicated, and fail-closed. ## Release status diff --git a/packages/bundle/src/extension.test.ts b/packages/bundle/src/extension.test.ts index 6b0b1f7..a39b254 100644 --- a/packages/bundle/src/extension.test.ts +++ b/packages/bundle/src/extension.test.ts @@ -45,6 +45,9 @@ describe("bundle extension", () => { expect(rootReadme).not.toContain("/pi-rogue-context"); expect(bundleReadme).toContain("## Supported surface"); expect(bundleReadme).toContain("/pi-rogue-advisor model list"); + expect(bundleReadme).toContain("board watch status|off|shadow|intervene"); + expect(rootReadme).toContain("openrouter/deepseek/deepseek-v4-flash"); + expect(rootReadme).toContain("main Pi model"); }); });