Skip to content
21 changes: 19 additions & 2 deletions packages/advisor/src/board-head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] };
Expand All @@ -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]");
});

Expand Down
12 changes: 7 additions & 5 deletions packages/advisor/src/board-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ export interface HeadOfBoardResult {

export type HeadOfBoardComplete = (systemPrompt: string, messages: Array<{ role: "user"; content: string }>, options: { maxTokens: number; reasoning: ThinkingLevel }) => Promise<HeadOfBoardCompletion | null>;

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 {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/advisor/src/board-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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();
Expand Down
18 changes: 15 additions & 3 deletions packages/advisor/src/board-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
};
}
Expand All @@ -99,6 +107,10 @@ function adviceText(decision: Extract<BoardDecision, { action: "would_whisper" }
].join("\n").slice(0, 1800);
}

export function boardWatchRiskFingerprint(ledger: BoardLedger, decision: BoardDecision): string | undefined {
return decision.action === "would_whisper" ? fingerprint(ledger, decision) : undefined;
}

export function runBoardWatch(config: BoardWatchConfig, previous: BoardWatchState, ledger: BoardLedger, turn: number, now = new Date().toISOString()): BoardWatchResult {
const prior = normalizeBoardWatchState(previous);
const state: BoardWatchState = {
Expand All @@ -121,7 +133,7 @@ export function runBoardWatch(config: BoardWatchConfig, previous: BoardWatchStat
state.suppressed += 1;
return { state, decision, riskFingerprint: id, skipped: "cooldown" };
}
if (prior.interventions >= config.maxInterventions) {
if (config.mode === "intervene" && prior.interventions >= config.maxInterventions) {
state.suppressed += 1;
return { state, decision, riskFingerprint: id, skipped: "limit" };
}
Expand Down
3 changes: 2 additions & 1 deletion packages/advisor/src/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
3 changes: 2 additions & 1 deletion packages/advisor/src/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ const advisorNested: Record<string, Array<[string, string?]>> = {
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?]> = [
Expand Down
74 changes: 72 additions & 2 deletions packages/advisor/src/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, (event: unknown, ctx: any) => 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<string, (event: unknown, ctx: any) => 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());
}
});

Expand Down
Loading
Loading