From 75a66e0e23340f9d672cd26f6eeef25ea00f9b08 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 00:51:05 +0200 Subject: [PATCH 01/10] feat: escalate Board risks to read-only Head --- packages/advisor/src/board-watcher.test.ts | 4 +- packages/advisor/src/board-watcher.ts | 8 ++- packages/advisor/src/completions.test.ts | 3 +- packages/advisor/src/completions.ts | 3 +- packages/advisor/src/extension.test.ts | 33 ++++++++++ packages/advisor/src/extension.ts | 72 +++++++++++++++++++++- 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/packages/advisor/src/board-watcher.test.ts b/packages/advisor/src/board-watcher.test.ts index 1ef7c0e..5022c45 100644 --- a/packages/advisor/src/board-watcher.test.ts +++ b/packages/advisor/src/board-watcher.test.ts @@ -12,8 +12,8 @@ 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(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", () => { diff --git a/packages/advisor/src/board-watcher.ts b/packages/advisor/src/board-watcher.ts index cbb7234..0219210 100644 --- a/packages/advisor/src/board-watcher.ts +++ b/packages/advisor/src/board-watcher.ts @@ -7,6 +7,8 @@ export interface BoardWatchConfig { mode: BoardWatchMode; cooldownTurns: number; maxInterventions: number; + headEscalation: "off" | "enabled"; + headMaxCalls: number; } export interface BoardWatchState { @@ -17,6 +19,7 @@ export interface BoardWatchState { lastTurn?: number; lastInterventionTurn?: number; lastRiskFingerprint?: string; + lastEscalatedRiskFingerprint?: string; lastDecision?: BoardDecision; } @@ -42,7 +45,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,6 +60,8 @@ 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), }; } @@ -76,6 +81,7 @@ export function normalizeBoardWatchState(raw: unknown): BoardWatchState { 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, }; } diff --git a/packages/advisor/src/completions.test.ts b/packages/advisor/src/completions.test.ts index 9c2cead..ecfd351 100644 --- a/packages/advisor/src/completions.test.ts +++ b/packages/advisor/src/completions.test.ts @@ -29,7 +29,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..ff1144f 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -263,6 +263,39 @@ describe("Advisor PR1 lifecycle", () => { } }); + 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); + } finally { + unlinkSync(advisorBoardWatchConfigPath()); + } + }); + it("deduplicates repeated lifecycle events while preserving same-turn failures", () => { const handlers = new Map void>(); const pi = { diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index 7b515c6..c80ad58 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -1844,6 +1844,62 @@ function collectLifecycleBoardEvents(state: SessionState, toolResults: unknown[] state.boardEvents = [...state.boardEvents, ...events].slice(-MAX_BOARD_EVENTS); } +async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, state: SessionState, riskFingerprint: string): Promise { + const watch = loadBoardWatchConfig(); + if (watch.headEscalation !== "enabled" || watch.headMaxCalls <= 0 || state.boardWatch.lastEscalatedRiskFingerprint === riskFingerprint) return; + 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.", + reason: "architecture_risk", + }, async (systemPrompt, messages, options) => completeWithHigherAdvisorModel(ctx, cfg, systemPrompt, messages, { + ...options, + role: "head", + maxAttempts: 1, + })); + state.headOfBoard = state.headOfBoard ?? { calls: 0 }; + if (result.skipped) { + state.headOfBoard.lastSkipped = result.skipped; + saveState(state); + return; + } + if (!result.response) { + state.headOfBoard.lastSkipped = "no_response"; + saveState(state); + return; + } + state.headOfBoard.calls += result.accounting.headOfBoardCalls; + state.headOfBoard.lastAt = new Date().toISOString(); + state.headOfBoard.lastModel = result.response.model; + state.headOfBoard.lastSkipped = undefined; + state.boardWatch.lastEscalatedRiskFingerprint = riskFingerprint; + saveState(state); + 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: decision.action === "would_whisper" ? decision.severity : "important", reason: "Board escalation", nonBinding: true, readOnly: true }, + }, { triggerTurn: false, deliverAs: "nextTurn" }); + } + } catch (error) { + state.headOfBoard = state.headOfBoard ?? { calls: 0 }; + state.headOfBoard.lastSkipped = "error"; + saveState(state); + 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; @@ -1856,6 +1912,7 @@ function recordBoardWatchIfEnabled(pi: ExtensionAPI, ctx: any, state: SessionSta display: true, details: result.advice.details, }, { triggerTurn: false, deliverAs: "nextTurn" }); + if (config.headEscalation === "enabled") void escalateBoardHead(pi, ctx, loadConfig(), state, result.advice.fingerprint); } } @@ -2078,6 +2135,7 @@ export function registerAdvisor(pi: ExtensionAPI): void { `Board watcher: ${current.mode}`, `Runs: ${state.boardWatch.runs}; interventions: ${state.boardWatch.interventions}; 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 +2145,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") { From 88f88096e1a9bb47e68f7da1c47fd0f296c0e92b Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:00:01 +0200 Subject: [PATCH 02/10] fix: cap Head attempts and redact auth secrets --- packages/advisor/src/board-head.test.ts | 6 ++++-- packages/advisor/src/board-head.ts | 6 ++++-- packages/advisor/src/board-watcher.test.ts | 1 + packages/advisor/src/board-watcher.ts | 4 +++- packages/advisor/src/extension.test.ts | 3 +++ packages/advisor/src/extension.ts | 8 +++++--- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/advisor/src/board-head.test.ts b/packages/advisor/src/board-head.test.ts index fc8ec9a..ac2395b 100644 --- a/packages/advisor/src/board-head.test.ts +++ b/packages/advisor/src/board-head.test.ts @@ -114,8 +114,8 @@ 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", riskIds: ["risk:token=abcd1234"] }, + question: "Assess release readiness with MY_SECRET=shhhhhhh and {\"apiKey\": \"json-secret-value\"}", }); const payload = JSON.stringify({ content: request.messages[0]?.content, escalation: request.escalation }); @@ -123,6 +123,8 @@ describe("head-of-board adapter", () => { expect(payload).not.toContain("abcdef1234567890"); expect(payload).not.toContain("AKIAABCDEFGHIJKLMNOP"); expect(payload).not.toContain("shhhhhhh"); + expect(payload).not.toContain("dXNlcjpwYXNz"); + expect(payload).not.toContain("json-secret-value"); expect(payload).toContain("[secret]"); }); diff --git a/packages/advisor/src/board-head.ts b/packages/advisor/src/board-head.ts index b19e723..95bc6b1 100644 --- a/packages/advisor/src/board-head.ts +++ b/packages/advisor/src/board-head.ts @@ -83,7 +83,8 @@ 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 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-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*\s*=\s*[^\s"',;}]{4,}/gi; const BARE_BEARER_RE = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi; @@ -125,8 +126,9 @@ 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(KEYED_SECRET_RE, (_match, key: string) => `${key}=[secret]`) .replace(NAMED_SECRET_ASSIGNMENT_RE, (match) => `${match.split(/=/, 1)[0].trim()}=[secret]`) .replace(/\s+/g, " ") .trim() diff --git a/packages/advisor/src/board-watcher.test.ts b/packages/advisor/src/board-watcher.test.ts index 5022c45..83a2e74 100644 --- a/packages/advisor/src/board-watcher.test.ts +++ b/packages/advisor/src/board-watcher.test.ts @@ -13,6 +13,7 @@ function riskyLedger(turn = 1) { describe("Board watcher", () => { it("defaults to deterministic shadow mode", () => { 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 }); }); diff --git a/packages/advisor/src/board-watcher.ts b/packages/advisor/src/board-watcher.ts index 0219210..20894cf 100644 --- a/packages/advisor/src/board-watcher.ts +++ b/packages/advisor/src/board-watcher.ts @@ -14,6 +14,7 @@ export interface BoardWatchConfig { export interface BoardWatchState { runs: number; interventions: number; + headAttempts: number; suppressed: number; lastAt?: string; lastTurn?: number; @@ -66,7 +67,7 @@ export function normalizeBoardWatchConfig(raw: unknown): BoardWatchConfig { } 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 { @@ -76,6 +77,7 @@ 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, diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index ff1144f..724e321 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -291,6 +291,9 @@ describe("Advisor PR1 lifecycle", () => { 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 { unlinkSync(advisorBoardWatchConfigPath()); } diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index c80ad58..f6a5255 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -1846,7 +1846,10 @@ function collectLifecycleBoardEvents(state: SessionState, toolResults: unknown[] async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, state: SessionState, riskFingerprint: string): Promise { const watch = loadBoardWatchConfig(); - if (watch.headEscalation !== "enabled" || watch.headMaxCalls <= 0 || state.boardWatch.lastEscalatedRiskFingerprint === riskFingerprint) return; + if (watch.headEscalation !== "enabled" || watch.headMaxCalls <= 0 || state.boardWatch.lastEscalatedRiskFingerprint === riskFingerprint || state.boardWatch.headAttempts >= watch.headMaxCalls) return; + state.boardWatch.headAttempts += 1; + state.boardWatch.lastEscalatedRiskFingerprint = riskFingerprint; + saveState(state); const headConfig = { ...defaultHeadOfBoardConfig(), mode: "enabled" as const, @@ -1882,7 +1885,6 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, state.headOfBoard.lastAt = new Date().toISOString(); state.headOfBoard.lastModel = result.response.model; state.headOfBoard.lastSkipped = undefined; - state.boardWatch.lastEscalatedRiskFingerprint = riskFingerprint; saveState(state); if (typeof pi.sendMessage === "function") { pi.sendMessage({ @@ -2133,7 +2135,7 @@ 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.", From 933cd20644e8dd77cd96caababf789e0bb9e7758 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:09:15 +0200 Subject: [PATCH 03/10] fix: preserve Head state and redact AWS credentials --- packages/advisor/src/board-head.test.ts | 14 +++++++++- packages/advisor/src/board-head.ts | 2 +- packages/advisor/src/extension.ts | 35 ++++++++++++++++--------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/advisor/src/board-head.test.ts b/packages/advisor/src/board-head.test.ts index ac2395b..1f166c9 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,7 +124,7 @@ 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 Authorization: Basic dXNlcjpwYXNz token=abcd1234 AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP", riskIds: ["risk:token=abcd1234"] }, + 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\"}", }); const payload = JSON.stringify({ content: request.messages[0]?.content, escalation: request.escalation }); @@ -122,6 +132,8 @@ describe("head-of-board adapter", () => { 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"); diff --git a/packages/advisor/src/board-head.ts b/packages/advisor/src/board-head.ts index 95bc6b1..2b6cce3 100644 --- a/packages/advisor/src/board-head.ts +++ b/packages/advisor/src/board-head.ts @@ -82,7 +82,7 @@ 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 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-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*\s*=\s*[^\s"',;}]{4,}/gi; diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index f6a5255..b4080f6 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -1864,7 +1864,6 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, ledger, decision, question: "Review this material Board risk and recommend the next safe step. Do not request or perform mutations.", - reason: "architecture_risk", }, async (systemPrompt, messages, options) => completeWithHigherAdvisorModel(ctx, cfg, systemPrompt, messages, { ...options, role: "head", @@ -1872,20 +1871,29 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, })); state.headOfBoard = state.headOfBoard ?? { calls: 0 }; if (result.skipped) { - state.headOfBoard.lastSkipped = result.skipped; - saveState(state); + 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) { - state.headOfBoard.lastSkipped = "no_response"; - saveState(state); + 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; } - state.headOfBoard.calls += result.accounting.headOfBoardCalls; - state.headOfBoard.lastAt = new Date().toISOString(); - state.headOfBoard.lastModel = result.response.model; - state.headOfBoard.lastSkipped = undefined; - saveState(state); + const latest = loadState(ctx); + 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", @@ -1895,9 +1903,10 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, }, { triggerTurn: false, deliverAs: "nextTurn" }); } } catch (error) { - state.headOfBoard = state.headOfBoard ?? { calls: 0 }; - state.headOfBoard.lastSkipped = "error"; - saveState(state); + 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) }); } } From 1589460703dce34c6c988b6c99c892607e2f77f5 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:17:43 +0200 Subject: [PATCH 04/10] fix: redact JSON AWS credentials --- packages/advisor/src/board-head.test.ts | 3 ++- packages/advisor/src/board-head.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/advisor/src/board-head.test.ts b/packages/advisor/src/board-head.test.ts index 1f166c9..1cd688b 100644 --- a/packages/advisor/src/board-head.test.ts +++ b/packages/advisor/src/board-head.test.ts @@ -125,7 +125,7 @@ describe("head-of-board adapter", () => { const request = buildHeadOfBoardRequest({ ledger, 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\"}", + question: "Assess release readiness with MY_SECRET=shhhhhhh and {\"apiKey\": \"json-secret-value\", \"AWS_SECRET_ACCESS_KEY\": \"json-aws-secret-value\"}", }); const payload = JSON.stringify({ content: request.messages[0]?.content, escalation: request.escalation }); @@ -137,6 +137,7 @@ describe("head-of-board adapter", () => { 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).toContain("[secret]"); }); diff --git a/packages/advisor/src/board-head.ts b/packages/advisor/src/board-head.ts index 2b6cce3..a51f2ce 100644 --- a/packages/advisor/src/board-head.ts +++ b/packages/advisor/src/board-head.ts @@ -85,7 +85,7 @@ export type HeadOfBoardComplete = (systemPrompt: string, messages: Array<{ role: 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-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*\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 BARE_BEARER_RE = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi; export function defaultHeadOfBoardConfig(): HeadOfBoardConfig { @@ -129,7 +129,7 @@ function cleanText(value: unknown, max = 500): string { .replace(BASIC_AUTH_RE, "$1[secret]") .replace(SECRET_RE, "[secret]") .replace(KEYED_SECRET_RE, (_match, key: string) => `${key}=[secret]`) - .replace(NAMED_SECRET_ASSIGNMENT_RE, (match) => `${match.split(/=/, 1)[0].trim()}=[secret]`) + .replace(NAMED_SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=[secret]`) .replace(/\s+/g, " ") .trim() .slice(0, max); From 65f0675b37a0aff24cb119854ced0505307246a3 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:20:11 +0200 Subject: [PATCH 05/10] fix: complete bounded Board Head escalation --- packages/advisor/src/extension.test.ts | 33 ++++++++++++++++++++++++++ packages/advisor/src/extension.ts | 4 +++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index 724e321..447469a 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -299,6 +299,39 @@ describe("Advisor PR1 lifecycle", () => { } }); + 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", 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); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(callsBefore + 1); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ kind: "board-head", readOnly: true }) }), expect.objectContaining({ triggerTurn: false, deliverAs: "nextTurn" })); + } finally { + unlinkSync(advisorBoardWatchConfigPath()); + } + }); + it("deduplicates repeated lifecycle events while preserving same-turn failures", () => { const handlers = new Map void>(); const pi = { diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index b4080f6..dce50dd 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -1923,7 +1923,9 @@ function recordBoardWatchIfEnabled(pi: ExtensionAPI, ctx: any, state: SessionSta display: true, details: result.advice.details, }, { triggerTurn: false, deliverAs: "nextTurn" }); - if (config.headEscalation === "enabled") void escalateBoardHead(pi, ctx, loadConfig(), state, result.advice.fingerprint); + } + if (config.headEscalation === "enabled" && result.decision.action === "would_whisper" && result.riskFingerprint) { + void escalateBoardHead(pi, ctx, loadConfig(), state, result.riskFingerprint); } } From 98e9a98e895eaf5d6732285ef50dbf7b98399d4c Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:21:52 +0200 Subject: [PATCH 06/10] test: isolate shared watcher config cleanup --- packages/advisor/src/extension.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index 447469a..e553fea 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,7 @@ 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()); } }); @@ -295,7 +295,7 @@ describe("Advisor PR1 lifecycle", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(1); } finally { - unlinkSync(advisorBoardWatchConfigPath()); + if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); } }); @@ -328,7 +328,7 @@ describe("Advisor PR1 lifecycle", () => { expect(vi.mocked(completeSimple)).toHaveBeenCalledTimes(callsBefore + 1); expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ kind: "board-head", readOnly: true }) }), expect.objectContaining({ triggerTurn: false, deliverAs: "nextTurn" })); } finally { - unlinkSync(advisorBoardWatchConfigPath()); + if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); } }); From ced2c42b8ec31677ffa682d87e7075fcc5b2e8ec Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:29:11 +0200 Subject: [PATCH 07/10] fix: reserve Head escalations atomically --- packages/advisor/src/board-head.test.ts | 4 +++- packages/advisor/src/board-head.ts | 2 +- packages/advisor/src/extension.test.ts | 1 + packages/advisor/src/extension.ts | 13 +++++++++---- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/advisor/src/board-head.test.ts b/packages/advisor/src/board-head.test.ts index 1cd688b..a8574e8 100644 --- a/packages/advisor/src/board-head.test.ts +++ b/packages/advisor/src/board-head.test.ts @@ -125,7 +125,7 @@ describe("head-of-board adapter", () => { const request = buildHeadOfBoardRequest({ ledger, 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\"}", + 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 }); @@ -138,6 +138,8 @@ describe("head-of-board adapter", () => { 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 a51f2ce..bc4a8d3 100644 --- a/packages/advisor/src/board-head.ts +++ b/packages/advisor/src/board-head.ts @@ -85,7 +85,7 @@ export type HeadOfBoardComplete = (systemPrompt: string, messages: Array<{ role: 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-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*["']?)\s*[:=]\s*["']?[^\s"',;}]{4,}/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 { diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index e553fea..d63901c 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -324,6 +324,7 @@ describe("Advisor PR1 lifecycle", () => { }, }; 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).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ kind: "board-head", readOnly: true }) }), expect.objectContaining({ triggerTurn: false, deliverAs: "nextTurn" })); diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index dce50dd..9fe5767 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -1844,12 +1844,17 @@ function collectLifecycleBoardEvents(state: SessionState, toolResults: unknown[] state.boardEvents = [...state.boardEvents, ...events].slice(-MAX_BOARD_EVENTS); } -async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, state: SessionState, riskFingerprint: string): Promise { - const watch = loadBoardWatchConfig(); - if (watch.headEscalation !== "enabled" || watch.headMaxCalls <= 0 || state.boardWatch.lastEscalatedRiskFingerprint === riskFingerprint || state.boardWatch.headAttempts >= watch.headMaxCalls) return; +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, @@ -1924,7 +1929,7 @@ function recordBoardWatchIfEnabled(pi: ExtensionAPI, ctx: any, state: SessionSta details: result.advice.details, }, { triggerTurn: false, deliverAs: "nextTurn" }); } - if (config.headEscalation === "enabled" && result.decision.action === "would_whisper" && result.riskFingerprint) { + if (result.decision.action === "would_whisper" && result.riskFingerprint && reserveBoardHeadEscalation(config, state, result.riskFingerprint)) { void escalateBoardHead(pi, ctx, loadConfig(), state, result.riskFingerprint); } } From bc2c75a3a7ef77bd0564fd11483e5fcff164b0d0 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:36:39 +0200 Subject: [PATCH 08/10] fix: reject stale Head advice and preserve shadow escalation --- packages/advisor/src/board-watcher.ts | 6 +++++- packages/advisor/src/extension.test.ts | 4 ++-- packages/advisor/src/extension.ts | 12 ++++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/advisor/src/board-watcher.ts b/packages/advisor/src/board-watcher.ts index 20894cf..3ff341f 100644 --- a/packages/advisor/src/board-watcher.ts +++ b/packages/advisor/src/board-watcher.ts @@ -107,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/extension.test.ts b/packages/advisor/src/extension.test.ts index d63901c..1f87800 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -311,7 +311,7 @@ describe("Advisor PR1 lifecycle", () => { } 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", headEscalation: "enabled", headMaxCalls: 1 })); + writeFileSync(advisorBoardWatchConfigPath(), JSON.stringify({ mode: "shadow", maxInterventions: 0, headEscalation: "enabled", headMaxCalls: 1 })); try { registerAdvisor(pi); const ctx = { @@ -327,7 +327,7 @@ describe("Advisor PR1 lifecycle", () => { 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).toHaveBeenCalledWith(expect.objectContaining({ customType: "advisor:board", details: expect.objectContaining({ kind: "board-head", readOnly: true }) }), expect.objectContaining({ triggerTurn: false, deliverAs: "nextTurn" })); + expect(sendMessage).not.toHaveBeenCalled(); } finally { if (existsSync(advisorBoardWatchConfigPath())) unlinkSync(advisorBoardWatchConfigPath()); } diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index 9fe5767..2ddbb79 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 ───────────────────────────────────────── @@ -1890,6 +1890,14 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, 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, @@ -1904,7 +1912,7 @@ async function escalateBoardHead(pi: ExtensionAPI, ctx: any, cfg: AdvisorConfig, 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: decision.action === "would_whisper" ? decision.severity : "important", reason: "Board escalation", nonBinding: true, readOnly: 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) { From d3d58b32f7c22f146be39a823cdf6e93023a871a Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 01:43:16 +0200 Subject: [PATCH 09/10] fix: decouple Head escalation from watcher suppression --- packages/advisor/src/board-watcher.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/advisor/src/board-watcher.test.ts b/packages/advisor/src/board-watcher.test.ts index 83a2e74..d27bbdc 100644 --- a/packages/advisor/src/board-watcher.test.ts +++ b/packages/advisor/src/board-watcher.test.ts @@ -41,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(); From 083a732a9171ce7ee78303bcd274cf12ca1516e6 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 18 Aug 2026 00:57:51 +0200 Subject: [PATCH 10/10] docs: document cheap main model and Board posture --- README.md | 7 ++++++- packages/advisor/README.md | 7 ++++++- packages/advisor/src/completions.test.ts | 2 ++ packages/advisor/src/extension.test.ts | 20 ++++++++++++++++++++ packages/advisor/src/extension.ts | 6 +++++- packages/bundle/README.md | 6 +++++- packages/bundle/src/extension.test.ts | 3 +++ 7 files changed, 47 insertions(+), 4 deletions(-) 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/completions.test.ts b/packages/advisor/src/completions.test.ts index ecfd351..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"); diff --git a/packages/advisor/src/extension.test.ts b/packages/advisor/src/extension.test.ts index 1f87800..963241f 100644 --- a/packages/advisor/src/extension.test.ts +++ b/packages/advisor/src/extension.test.ts @@ -351,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 2ddbb79..fcb6475 100644 --- a/packages/advisor/src/extension.ts +++ b/packages/advisor/src/extension.ts @@ -2119,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; } 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"); }); });