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..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..1f87800 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()); } }); diff --git a/packages/advisor/src/extension.ts b/packages/advisor/src/extension.ts index 7b515c6..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 ───────────────────────────────────────── @@ -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 { @@ -2076,8 +2159,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 +2171,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") {