Skip to content
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Specialists are read/search-only and suggest-only by default. They can return fi
/pi-rogue-advisor <question>
/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
```
Expand Down Expand Up @@ -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 `<provider>/<model>` 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

Expand Down
7 changes: 6 additions & 1 deletion packages/advisor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <provider>/<model>\|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 <role-id> <task>` | Make one explicit bounded specialist call |
Expand Down Expand Up @@ -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 `<provider>/<model>` 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
Expand All @@ -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.
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
5 changes: 4 additions & 1 deletion packages/advisor/src/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -29,7 +31,8 @@ describe("advisor completions", () => {

it("offers explicit Board controls and roles", () => {
expect(advisorArgumentCompletions("board ")?.map((i) => i.value)).toEqual(["watch", "specialist", "head"]);
expect(advisorArgumentCompletions("board watch ")?.map((i) => i.value)).toEqual(["status", "off", "shadow", "intervene"]);
expect(advisorArgumentCompletions("board watch ")?.map((i) => i.value)).toEqual(["status", "off", "shadow", "intervene", "head"]);
expect(advisorArgumentCompletions("board watch head ")?.map((i) => i.value)).toEqual(["status", "on", "off"]);
expect(advisorArgumentCompletions("review ")).toBeNull();
expect(advisorArgumentCompletions("profile ")).toBeNull();
});
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
Loading
Loading