@roodriigoooo/pi-scrutiny 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import type { PanelMember, PanelMode, ScrutinyConfig, ScrutinyParams, ScrutinySurface } from "./types.js";
2
+ import { SURFACE_DEFAULTS } from "./config.js";
3
+ import { buildContextScoutSection } from "./scout.js";
4
+ import { truncate } from "./util.js";
5
+
6
+ type ExecLike = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise<{ stdout?: string; stderr?: string; code?: number; killed?: boolean }>;
7
+
8
+ export async function buildTaskPacket(input: {
9
+ params: ScrutinyParams;
10
+ surface: ScrutinySurface;
11
+ cwd: string;
12
+ config: ScrutinyConfig;
13
+ exec: ExecLike;
14
+ signal?: AbortSignal;
15
+ }): Promise<string> {
16
+ const sections: string[] = [];
17
+ sections.push(`# Scrutiny task packet`);
18
+ sections.push(`surface: ${input.surface}`);
19
+ sections.push(`cwd: ${input.cwd}`);
20
+ sections.push("");
21
+ sections.push(`## Task`);
22
+ sections.push(input.params.prompt.trim());
23
+
24
+ if (input.params.context?.trim()) {
25
+ sections.push("", `## User-supplied context`, truncate(input.params.context.trim(), 12_000));
26
+ }
27
+
28
+ const scout = await buildContextScoutSection({ params: input.params, surface: input.surface, cwd: input.cwd, exec: input.exec, signal: input.signal });
29
+ if (scout) sections.push("", scout);
30
+
31
+ const includeGitDiff = input.params.includeGitDiff ?? SURFACE_DEFAULTS[input.surface].includeGitDiff;
32
+ if (includeGitDiff) {
33
+ const git = await readGitContext(input.exec, input.cwd, input.config.gitDiffCharLimit, input.signal);
34
+ if (git) sections.push("", `## Git working tree`, git);
35
+ }
36
+
37
+ sections.push("", "## Instructions", ...sharedInstructions());
38
+ return sections.join("\n").trim() + "\n";
39
+ }
40
+
41
+ function sharedInstructions(): string[] {
42
+ return [
43
+ "- Answer independently. Do not assume other panelists will cover gaps.",
44
+ "- Prefer concrete, testable claims over vibes.",
45
+ "- Surface uncertainty and missing evidence.",
46
+ "- If the packet looks too narrow, name the missing surrounding files/systems/tests that must be inspected before trusting the result. Do not guess around missing context.",
47
+ "- You are running without tools unless the packet says otherwise. Do not say you will read files, call tools, or inspect the repo later; use only the packet and name missing evidence explicitly.",
48
+ "- Do not edit files. Do not propose a final patch to merge. This is deliberation, not the edit.",
49
+ "- Keep answer dense. No preamble.",
50
+ ];
51
+ }
52
+
53
+ type SurfaceSpec = {
54
+ heading: string;
55
+ panelHeadings: string[];
56
+ trailer: string[];
57
+ };
58
+
59
+ const SURFACE_SPECS: Record<Exclude<ScrutinySurface, "verify">, SurfaceSpec> = {
60
+ consult: {
61
+ heading: "You are a Scrutiny panelist on a bounded research/synthesis question.",
62
+ panelHeadings: ["## Position", "## Evidence", "## Risks", "## Blind spots / missing evidence", "## Recommendation"],
63
+ trailer: ["Output is evidence for the main Pi agent to synthesize. It is not a patch."],
64
+ },
65
+ hypotheses: {
66
+ heading: "You are a Scrutiny panelist on a debugging problem. Do not propose a fix yet.",
67
+ panelHeadings: [
68
+ "## Likely root causes (ranked)",
69
+ "## Confirming evidence per cause",
70
+ "## Minimal distinguishing test",
71
+ "## What would rule this cause out",
72
+ "## Missing context / needed inspection",
73
+ ],
74
+ trailer: [
75
+ "Do not propose a fix. The main agent will run the best diagnostic, then act against the repo and tests.",
76
+ "If you disagree with the obvious cause, say so explicitly — disagreement is a useful signal here.",
77
+ ],
78
+ },
79
+ criteria: {
80
+ heading: "You are a Scrutiny panelist deriving acceptance criteria before any code is written.",
81
+ panelHeadings: ["## Acceptance criteria", "## Edge cases", "## Backward-compatibility risks", "## Migration concerns", "## Test cases", "## Missing context / needed inspection"],
82
+ trailer: ["The main agent will implement against the fused spec. Be concrete and testable."],
83
+ },
84
+ "repo-map": {
85
+ heading: "You are a Scrutiny panelist mapping the repo for an upcoming edit. Output context, not an answer.",
86
+ panelHeadings: ["## Relevant symbols", "## Call paths", "## Tests touched", "## Config / files", "## Invariants / prior patterns", "## Missing context / needed inspection"],
87
+ trailer: [
88
+ "Output is a compact repo map. The main agent will edit with this context.",
89
+ "Prefer exact symbol names, file paths, and line references over prose.",
90
+ ],
91
+ },
92
+ risks: {
93
+ heading: "You are a Scrutiny risk reviewer. You review one risk class only.",
94
+ panelHeadings: ["## Risk class", "## Findings", "## Severity", "## Suggested check or test", "## Missing context / needed inspection"],
95
+ trailer: [
96
+ "Focus on your assigned risk class. Do not review other classes.",
97
+ "For Java/Spring/Kafka/WebFlux: watch race conditions, reactive-chain mistakes, retry/circuit-breaker semantics, idempotency, message ordering.",
98
+ "Propose a concrete check or test that would catch each finding, not a fix to merge.",
99
+ ],
100
+ },
101
+ };
102
+
103
+ export function panelPrompt(input: { packet: string; role: string; surface: ScrutinySurface; panelMode?: PanelMode }): string {
104
+ if (input.surface === "verify") throw new Error("verify surface has no panel prompt");
105
+ const spec = SURFACE_SPECS[input.surface];
106
+ const panelMode = input.panelMode ?? SURFACE_DEFAULTS[input.surface].panelMode ?? "roles";
107
+ const frame = panelMode === "replicate"
108
+ ? `${spec.heading} Panel mode: replicate. Every panelist receives this same prompt; model priors provide diversity.`
109
+ : `${spec.heading} Panel mode: roles. Role: ${input.role}.`;
110
+ return [
111
+ frame,
112
+ "Produce one independent analysis from the packet only. Do not claim you will call tools or inspect files.",
113
+ "Return Markdown with these headings exactly:",
114
+ ...spec.panelHeadings,
115
+ "",
116
+ ...spec.trailer,
117
+ "",
118
+ input.packet,
119
+ ].join("\n");
120
+ }
121
+
122
+ export function judgePrompt(input: { packet: string; panelMode?: PanelMode; responses: Array<{ model: string; role: string; content: string }> }): string {
123
+ const panelMode = input.panelMode ?? "replicate";
124
+ const disagreementInstruction = panelMode === "replicate"
125
+ ? "Set disagreement_signal=true when panelists disagree sharply on root cause, architecture, or a load-bearing claim. The main agent treats that as a stop signal to gather more evidence or ask the human, not as noise to smooth over."
126
+ : "Panel mode is roles: each panelist used a different lens. Set disagreement_signal=false. Treat non-overlap as coverage/gaps, not contradiction; report gaps in blind_spots or risks.";
127
+ const responses = input.responses
128
+ .map((response, index) => [`### Panel ${index + 1}: ${response.model} (${response.role})`, response.content].join("\n"))
129
+ .join("\n\n");
130
+ return [
131
+ "You are a Scrutiny trade-off explainer. Compare panel outputs. Do not majority-vote. Do not pick a winner. Do not propose a final answer or patch.",
132
+ "Return ONLY valid JSON. No Markdown fence. Schema:",
133
+ `{`,
134
+ ` "consensus": ["..."],`,
135
+ ` "contradictions": [{"topic":"...","stances":[{"model":"...","stance":"..."}]}],`,
136
+ ` "unique_insights": [{"model":"...","insight":"..."}],`,
137
+ ` "risks": ["..."],`,
138
+ ` "blind_spots": ["..."],`,
139
+ ` "disagreement_signal": true|false,`,
140
+ ` "confidence": "low|medium|high"`,
141
+ `}`,
142
+ "",
143
+ disagreementInstruction,
144
+ "You explain trade-offs only. The main Pi agent and objective repo checks are the arbiters.",
145
+ "",
146
+ "Original task packet:",
147
+ input.packet,
148
+ "",
149
+ "Panel responses:",
150
+ responses,
151
+ ].join("\n");
152
+ }
153
+
154
+ type LensSet = string[];
155
+
156
+ const SURFACE_LENSES: Record<Exclude<ScrutinySurface, "verify">, LensSet> = {
157
+ consult: ["first-pass analyst", "skeptical reviewer", "synthesizer", "edge-case hunter"],
158
+ hypotheses: ["most-likely-cause investigator", "alternative-cause skeptic", "distinguishing-test designer", "environment/config examiner"],
159
+ criteria: ["acceptance-criteria author", "edge-case author", "backward-compatibility reviewer", "migration/test-case author"],
160
+ "repo-map": ["call-path mapper", "api/symbol mapper", "test/invariant mapper", "config/build mapper"],
161
+ risks: ["concurrency reviewer", "reactive-chain reviewer", "api-compatibility reviewer", "security reviewer", "performance reviewer", "data-migration reviewer", "null/error-handling reviewer", "flaky-test reviewer"],
162
+ };
163
+
164
+ export function panelRoles(members: PanelMember[], surface: ScrutinySurface): Array<{ model: string; role: string; thinking?: PanelMember["thinking"] }> {
165
+ if (surface === "verify") return [];
166
+ const panelMode = SURFACE_DEFAULTS[surface].panelMode ?? "roles";
167
+ if (panelMode === "replicate") return members.map((member) => ({ model: member.model, role: "replicate analyst", thinking: member.thinking }));
168
+ const lenses = SURFACE_LENSES[surface];
169
+ return members.map((member, index) => ({ model: member.model, role: member.lens ?? lenses[index] ?? `panelist-${index + 1}`, thinking: member.thinking }));
170
+ }
171
+
172
+ async function readGitContext(exec: ExecLike, cwd: string, diffCharLimit: number, signal?: AbortSignal): Promise<string | undefined> {
173
+ try {
174
+ const inside = await exec("git", ["rev-parse", "--is-inside-work-tree"], { timeout: 3_000, signal });
175
+ if (inside.code !== 0 || inside.stdout?.trim() !== "true") return undefined;
176
+ const status = await exec("git", ["status", "--short"], { timeout: 5_000, signal });
177
+ const stat = await exec("git", ["diff", "--stat"], { timeout: 5_000, signal });
178
+ const diff = diffCharLimit > 0 ? await exec("git", ["diff", "--no-ext-diff"], { timeout: 8_000, signal }) : undefined;
179
+ const chunks = [
180
+ status.stdout?.trim() ? `### status\n\n\`\`\`\n${status.stdout.trim()}\n\`\`\`` : undefined,
181
+ stat.stdout?.trim() ? `### diff stat\n\n\`\`\`\n${stat.stdout.trim()}\n\`\`\`` : undefined,
182
+ diff?.stdout?.trim() ? `### diff\n\n\`\`\`diff\n${truncate(diff.stdout.trim(), diffCharLimit)}\n\`\`\`` : undefined,
183
+ ].filter(Boolean);
184
+ return chunks.length ? chunks.join("\n\n") : undefined;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
@@ -0,0 +1,413 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
3
+ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { SCRUTINY_SURFACES, SURFACE_DEFAULTS, readScrutinyConfig } from "./config.js";
5
+ import { panelRoles } from "./packet.js";
6
+ import type { Council, PanelMember, ScrutinyParams, ScrutinySurface } from "./types.js";
7
+ import { formatTokens } from "./util.js";
8
+
9
+ const JUDGE_MODES: Array<NonNullable<ScrutinyParams["judgeMode"]>> = ["auto", "off", "on"];
10
+
11
+ type SurfaceHint = { produces: string; flow: string };
12
+
13
+ const SURFACE_HINTS: Record<ScrutinySurface, SurfaceHint> = {
14
+ consult: { produces: "produces a synthesized analysis (research/synthesis)", flow: "↳ runs inline; streams status while the panel works; esc cancels" },
15
+ hypotheses: { produces: "produces ranked root causes + distinguishing tests, not a fix", flow: "↳ runs inline; streams status while the panel works; esc cancels" },
16
+ criteria: { produces: "produces an acceptance spec to implement against, not a patch", flow: "↳ runs inline; streams status while the panel works; esc cancels" },
17
+ "repo-map": { produces: "produces a compact repo map for an upcoming edit, not an answer", flow: "↳ runs inline; streams status while the panel works; esc cancels" },
18
+ risks: { produces: "produces per-class risk findings + suggested checks, not a merged patch", flow: "↳ runs inline; panel then verify; esc cancels" },
19
+ verify: { produces: "produces objective pass/fail (tests, typecheck, lint). the arbiter", flow: "↳ runs inline; blocks until checks finish; esc cancels" },
20
+ };
21
+
22
+ type PaletteState = {
23
+ prompt: string;
24
+ surface: ScrutinySurface;
25
+ surfaceLocked: boolean;
26
+ judgeMode: NonNullable<ScrutinyParams["judgeMode"]>;
27
+ includeGitDiff: boolean;
28
+ verify: boolean;
29
+ panelCount: number;
30
+ showHelp: boolean;
31
+ councilIndex: number; // -1 = no council (surface mode); >=0 = active council
32
+ };
33
+
34
+ export async function showScrutinyPalette(ctx: ExtensionCommandContext, initialPrompt = ""): Promise<ScrutinyParams | null> {
35
+ const config = readScrutinyConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
36
+ const councils = config.councils;
37
+ const maxPanel = Math.max(0, Math.min(config.panel.length, config.maxPanelModels));
38
+ const initialSurface = inferPaletteSurface(initialPrompt);
39
+ const state: PaletteState = {
40
+ prompt: initialPrompt,
41
+ surface: initialSurface,
42
+ surfaceLocked: Boolean(initialPrompt),
43
+ judgeMode: SURFACE_DEFAULTS[initialSurface].judgeMode,
44
+ includeGitDiff: SURFACE_DEFAULTS[initialSurface].includeGitDiff,
45
+ verify: SURFACE_DEFAULTS[initialSurface].verify,
46
+ panelCount: initialSurface === "verify" ? 0 : Math.min(SURFACE_DEFAULTS[initialSurface].panelCount, Math.max(1, maxPanel)),
47
+ showHelp: false,
48
+ councilIndex: -1,
49
+ };
50
+
51
+ return ctx.ui.custom<ScrutinyParams | null>(
52
+ (tui, theme, _kb, done) => new ScrutinyPalette(tui, theme, config.panel.slice(0, config.maxPanelModels), config.verifyChecks.map((check) => check.name).join(", ") || "none", councils, state, done),
53
+ {
54
+ overlay: true,
55
+ overlayOptions: {
56
+ anchor: "center",
57
+ width: "74%",
58
+ minWidth: 68,
59
+ maxHeight: "82%",
60
+ margin: 1,
61
+ },
62
+ },
63
+ );
64
+ }
65
+
66
+ class ScrutinyPalette implements Component, Focusable {
67
+ focused = false;
68
+
69
+ constructor(
70
+ private readonly tui: TUI,
71
+ private readonly theme: Theme,
72
+ private readonly panelMembers: PanelMember[],
73
+ private readonly verifyChecksLabel: string,
74
+ private readonly councils: Council[],
75
+ private readonly state: PaletteState,
76
+ private readonly done: (value: ScrutinyParams | null) => void,
77
+ ) {}
78
+
79
+ handleInput(data: string): void {
80
+ if (matchesKey(data, Key.enter)) {
81
+ const prompt = this.state.prompt.trim();
82
+ if (!prompt) return;
83
+ const council = this.activeCouncil();
84
+ if (council) {
85
+ if (council.surface === "verify" && !prompt) return;
86
+ this.done({
87
+ prompt,
88
+ surface: council.surface,
89
+ panelMembers: council.panelists,
90
+ judge: council.judge,
91
+ judgeMode: council.judgeMode,
92
+ includeGitDiff: council.includeGitDiff,
93
+ verify: council.verify,
94
+ });
95
+ return;
96
+ }
97
+ if (this.state.surface !== "verify" && this.state.panelCount === 0) return;
98
+ this.done({
99
+ prompt,
100
+ surface: this.state.surface,
101
+ judgeMode: this.state.judgeMode,
102
+ panelMembers: this.state.surface === "verify" ? undefined : this.panelMembers.slice(0, this.state.panelCount),
103
+ includeGitDiff: this.state.includeGitDiff,
104
+ verify: this.state.verify,
105
+ });
106
+ return;
107
+ }
108
+ if (matchesKey(data, Key.escape)) {
109
+ this.done(null);
110
+ return;
111
+ }
112
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) {
113
+ this.cycleSurface(1);
114
+ return this.rerender();
115
+ }
116
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.up)) {
117
+ this.cycleSurface(-1);
118
+ return this.rerender();
119
+ }
120
+ if (matchesKey(data, Key.ctrl("j")) && this.state.surface !== "verify") {
121
+ this.cycleJudge();
122
+ return this.rerender();
123
+ }
124
+ if (matchesKey(data, Key.ctrl("g"))) {
125
+ this.state.includeGitDiff = !this.state.includeGitDiff;
126
+ return this.rerender();
127
+ }
128
+ if (matchesKey(data, Key.ctrl("p")) && this.councils.length > 0) {
129
+ this.cycleCouncil();
130
+ return this.rerender();
131
+ }
132
+ if (matchesKey(data, Key.ctrl("n")) && this.state.surface !== "verify") {
133
+ this.cyclePanelCount();
134
+ return this.rerender();
135
+ }
136
+ if (matchesKey(data, Key.ctrl("v")) && this.state.surface !== "verify") {
137
+ this.state.verify = !this.state.verify;
138
+ return this.rerender();
139
+ }
140
+ if (matchesKey(data, Key.ctrl("u"))) {
141
+ this.state.prompt = "";
142
+ this.state.surfaceLocked = false;
143
+ this.state.surface = "consult";
144
+ this.applySurfaceDefaults();
145
+ return this.rerender();
146
+ }
147
+ if (matchesKey(data, Key.ctrl("w"))) {
148
+ this.state.prompt = this.state.prompt.replace(/\s*\S+\s*$/, "");
149
+ this.syncInferredSurface();
150
+ return this.rerender();
151
+ }
152
+ if (matchesKey(data, Key.backspace) || data === "\x7f") {
153
+ this.state.prompt = this.state.prompt.slice(0, -1);
154
+ this.syncInferredSurface();
155
+ return this.rerender();
156
+ }
157
+ if (data === "?") {
158
+ this.state.showHelp = !this.state.showHelp;
159
+ return this.rerender();
160
+ }
161
+ if (isPrintable(data)) {
162
+ this.state.prompt += data.replace(/[\r\n\t]/g, " ");
163
+ this.syncInferredSurface();
164
+ return this.rerender();
165
+ }
166
+ }
167
+
168
+ render(width: number): string[] {
169
+ const w = Math.max(50, width);
170
+ const lines: string[] = [];
171
+ const accent = (s: string) => this.theme.fg("accent", s);
172
+ const dim = (s: string) => this.theme.fg("dim", s);
173
+ const warn = (s: string) => this.theme.fg("warning", s);
174
+ const ok = (s: string) => this.theme.fg("success", s);
175
+ const title = `${accent("pi-scrutiny")} ${dim("telescope")}`;
176
+ const council = this.activeCouncil();
177
+ const effectiveSurface = council ? council.surface : this.state.surface;
178
+ const hint = SURFACE_HINTS[effectiveSurface];
179
+
180
+ lines.push(topBorder(w, title, this.theme));
181
+ lines.push(frameLine(this.inputLine(w - 4), w, this.theme));
182
+ lines.push(frameLine(this.chipLine(), w, this.theme));
183
+ lines.push(frameLine(`${accent("▸")} ${dim(hint.produces)}`, w, this.theme));
184
+ lines.push(frameLine(`${accent("↳")} ${dim(hint.flow)}`, w, this.theme));
185
+ lines.push(midBorder(w, this.theme));
186
+
187
+ if (council) {
188
+ lines.push(frameLine(`${ok("@panel")} ${accent(council.name)} ${dim("saved panel")}`, w, this.theme));
189
+ if (council.surface === "verify") {
190
+ lines.push(frameLine(`${ok("◆")} ${dim("objective arbiter · no panel · no judge")}`, w, this.theme));
191
+ } else if (council.panelists.length === 0) {
192
+ lines.push(frameLine(`${this.theme.fg("error", "×")} saved panel has no members ${dim("fix panels config")}`, w, this.theme));
193
+ } else {
194
+ lines.push(frameLine(`${accent("mode")} ${dim(surfaceModeLine(council.surface))}`, w, this.theme));
195
+ for (const [index, p] of council.panelists.entries()) {
196
+ const icon = index === 0 ? ok("●") : accent("●");
197
+ const lens = p.lens ?? panelRoles([p], council.surface)[0]?.role ?? "panelist";
198
+ const thinking = p.thinking ? ` ${this.theme.fg("muted", `think:${p.thinking}`)}` : "";
199
+ lines.push(frameLine(` ${icon} ${padRight(shortModel(p.model), 24)} ${dim(lens)}${thinking}`, w, this.theme));
200
+ }
201
+ }
202
+ } else if (this.state.surface === "verify") {
203
+ lines.push(frameLine(`${ok("◆")} ${dim("objective arbiter · no panel · no judge")}`, w, this.theme));
204
+ lines.push(frameLine(`${dim("checks:")} ${accent(this.verifyCheckNames())}`, w, this.theme));
205
+ } else {
206
+ lines.push(frameLine(`${accent("mode")} ${dim(surfaceModeLine(this.state.surface))}`, w, this.theme));
207
+ const roles = panelRoles(this.panelMembers.slice(0, this.state.panelCount), this.state.surface);
208
+ if (roles.length === 0) {
209
+ lines.push(frameLine(`${this.theme.fg("error", "×")} panel missing ${dim("set PI_SCRUTINY_PANEL=provider/model,provider/model")}`, w, this.theme));
210
+ } else {
211
+ for (const [index, item] of roles.entries()) {
212
+ const icon = index === 0 ? ok("●") : accent("●");
213
+ const thinking = item.thinking ? ` ${this.theme.fg("muted", `think:${item.thinking}`)}` : "";
214
+ lines.push(frameLine(` ${icon} ${padRight(shortModel(item.model), 24)} ${dim(item.role)}${thinking}`, w, this.theme));
215
+ }
216
+ }
217
+ }
218
+
219
+ lines.push(frameLine("", w, this.theme));
220
+ lines.push(frameLine(this.budgetLine(), w, this.theme));
221
+
222
+ if (this.state.showHelp) {
223
+ lines.push(midBorder(w, this.theme));
224
+ for (const line of [
225
+ "enter run · esc cancel",
226
+ "tab/↓ surface · shift-tab/↑ previous surface",
227
+ "ctrl+j evidence map · ctrl+g git diff · ctrl+n panel size · ctrl+v verify",
228
+ "ctrl+p saved panel · ctrl+n panel size · ctrl+u clear · ctrl+w delete word · ? hide help",
229
+ ]) lines.push(frameLine(dim(line), w, this.theme));
230
+ } else {
231
+ lines.push(midBorder(w, this.theme));
232
+ lines.push(frameLine(dim("enter run · esc cancel · tab surface · ^p saved panel · ^n panel size · ^j map · ^g git · ^v verify · ? help"), w, this.theme));
233
+ }
234
+ lines.push(bottomBorder(w, this.theme));
235
+ return lines;
236
+ }
237
+
238
+ invalidate(): void {}
239
+
240
+ private inputLine(width: number): string {
241
+ const label = this.theme.fg("muted", "task › ");
242
+ const empty = this.theme.fg("dim", "describe problem for panel...");
243
+ const prompt = this.state.prompt ? this.state.prompt : empty;
244
+ const cursor = this.focused ? `${CURSOR_MARKER}${this.theme.bg("selectedBg", " ")}` : "";
245
+ return truncateToWidth(`${label}${prompt}${cursor}`, width);
246
+ }
247
+
248
+ private chipLine(): string {
249
+ const council = this.activeCouncil();
250
+ if (council) {
251
+ const chips = [chip(this.theme, `@${council.name}`, "accent"), chip(this.theme, council.surface, "muted")];
252
+ const mode = SURFACE_DEFAULTS[council.surface].panelMode;
253
+ if (mode) chips.push(chip(this.theme, mode, mode === "replicate" ? "accent" : "muted"));
254
+ if (council.surface !== "verify") chips.push(chip(this.theme, `members ${council.panelists.length}`, council.panelists.length ? "success" : "error"));
255
+ if (council.judgeMode) chips.push(chip(this.theme, `map:${council.judgeMode}`, council.judgeMode === "on" ? "warning" : "muted"));
256
+ if (council.verify !== undefined) chips.push(chip(this.theme, `verify:${council.verify ? "on" : "off"}`, council.verify ? "warning" : "muted"));
257
+ chips.push(chip(this.theme, this.estimateChip(), "accent"));
258
+ return chips.join(" ");
259
+ }
260
+ const chips = [chip(this.theme, this.state.surface, this.state.surfaceLocked ? "accent" : "muted")];
261
+ const mode = SURFACE_DEFAULTS[this.state.surface].panelMode;
262
+ if (mode) chips.push(chip(this.theme, mode, mode === "replicate" ? "accent" : "muted"));
263
+ if (this.state.surface !== "verify") {
264
+ chips.push(chip(this.theme, `panel ${this.state.panelCount}/${this.panelMembers.length}`, this.state.panelCount ? "success" : "error"));
265
+ chips.push(chip(this.theme, `map:${this.state.judgeMode}`, this.state.judgeMode === "on" ? "warning" : "muted"));
266
+ }
267
+ chips.push(chip(this.theme, `git:${this.state.includeGitDiff ? "on" : "off"}`, this.state.includeGitDiff ? "warning" : "muted"));
268
+ if (this.state.surface !== "verify") chips.push(chip(this.theme, `verify:${this.state.verify ? "on" : "off"}`, this.state.verify ? "warning" : "muted"));
269
+ chips.push(chip(this.theme, this.estimateChip(), "accent"));
270
+ if (this.councils.length > 0) chips.push(chip(this.theme, "^p saved", "muted"));
271
+ return chips.join(" ");
272
+ }
273
+
274
+ private budgetLine(): string {
275
+ const council = this.activeCouncil();
276
+ const packetTokens = this.estimatedPacketTokens();
277
+ const surface = council ? council.surface : this.state.surface;
278
+ const panelCount = surface === "verify" ? 0 : (council ? council.panelists.length : this.state.panelCount);
279
+ const includeGit = council ? (council.includeGitDiff ?? SURFACE_DEFAULTS[surface].includeGitDiff) : this.state.includeGitDiff;
280
+ const replicated = packetTokens * Math.max(1, panelCount);
281
+ const prefix = this.theme.fg("accent", "budget");
282
+ const git = includeGit ? " · git diff estimate included" : "";
283
+ if (surface === "verify") return `${prefix} objective checks only · no panel tokens${git}`;
284
+ return `${prefix} packet ~${formatTokens(packetTokens)} tok × ${panelCount} = ~${formatTokens(replicated)} replicated input${git}`;
285
+ }
286
+
287
+ private estimateChip(): string {
288
+ const council = this.activeCouncil();
289
+ const packetTokens = this.estimatedPacketTokens();
290
+ const surface = council ? council.surface : this.state.surface;
291
+ const panelCount = surface === "verify" ? 0 : (council ? council.panelists.length : this.state.panelCount);
292
+ return surface === "verify" ? "0 panel" : `~${formatTokens(packetTokens)}×${panelCount} tok`;
293
+ }
294
+
295
+ private estimatedPacketTokens(): number {
296
+ const baseChars = this.state.prompt.length + 1_800 + (this.state.includeGitDiff ? 6_000 : 0);
297
+ return Math.max(1, Math.ceil(baseChars / 4));
298
+ }
299
+
300
+ private verifyCheckNames(): string {
301
+ return this.verifyChecksLabel;
302
+ }
303
+
304
+ private cycleSurface(delta: number): void {
305
+ this.state.councilIndex = -1; // leaving council mode when manually cycling surface
306
+ const index = SCRUTINY_SURFACES.indexOf(this.state.surface);
307
+ this.state.surface = SCRUTINY_SURFACES[(index + delta + SCRUTINY_SURFACES.length) % SCRUTINY_SURFACES.length]!;
308
+ this.state.surfaceLocked = true;
309
+ this.applySurfaceDefaults();
310
+ }
311
+
312
+ private activeCouncil(): Council | undefined {
313
+ return this.state.councilIndex >= 0 && this.state.councilIndex < this.councils.length ? this.councils[this.state.councilIndex] : undefined;
314
+ }
315
+
316
+ private cycleCouncil(): void {
317
+ // cycle: -1 (surface mode) -> 0 -> 1 -> ... -> last -> -1
318
+ if (this.state.councilIndex >= this.councils.length - 1) this.state.councilIndex = -1;
319
+ else this.state.councilIndex += 1;
320
+ }
321
+
322
+ private applySurfaceDefaults(): void {
323
+ const defaults = SURFACE_DEFAULTS[this.state.surface];
324
+ this.state.judgeMode = defaults.judgeMode;
325
+ this.state.includeGitDiff = defaults.includeGitDiff;
326
+ this.state.verify = defaults.verify;
327
+ if (this.state.surface === "verify") {
328
+ this.state.panelCount = 0;
329
+ } else {
330
+ const maxPanel = Math.max(1, this.panelMembers.length);
331
+ this.state.panelCount = Math.min(defaults.panelCount, maxPanel);
332
+ }
333
+ }
334
+
335
+ private cycleJudge(): void {
336
+ const index = JUDGE_MODES.indexOf(this.state.judgeMode);
337
+ this.state.judgeMode = JUDGE_MODES[(index + 1) % JUDGE_MODES.length]!;
338
+ }
339
+
340
+ private cyclePanelCount(): void {
341
+ if (this.panelMembers.length === 0) return;
342
+ this.state.panelCount = (this.state.panelCount % this.panelMembers.length) + 1;
343
+ }
344
+
345
+ private syncInferredSurface(): void {
346
+ if (!this.state.surfaceLocked) {
347
+ const inferred = inferPaletteSurface(this.state.prompt);
348
+ if (inferred !== this.state.surface) {
349
+ this.state.surface = inferred;
350
+ this.applySurfaceDefaults();
351
+ }
352
+ }
353
+ }
354
+
355
+ private rerender(): void {
356
+ this.tui.requestRender();
357
+ }
358
+ }
359
+
360
+ function surfaceModeLine(surface: ScrutinySurface): string {
361
+ const mode = SURFACE_DEFAULTS[surface].panelMode;
362
+ if (mode === "replicate") return "replicate · same prompt · disagreement is signal";
363
+ if (mode === "roles") return "roles · separate lenses · coverage/gaps are signal";
364
+ return "objective arbiter · no panel";
365
+ }
366
+
367
+ function inferPaletteSurface(prompt: string): ScrutinySurface {
368
+ const text = prompt.toLowerCase();
369
+ if (/\b(verify|typecheck|lint|run tests|test suite|does it pass|check the build|ci)\b/.test(text)) return "verify";
370
+ if (/\b(risk|review the patch|review this change|concurrency|race|reactive|idempoten|circuit.?breaker|security review)\b/.test(text)) return "risks";
371
+ if (/\b(root cause|why does|debug|intermittent|flaky|bug in|what is causing)\b/.test(text)) return "hypotheses";
372
+ if (/\b(acceptance criter|edge case|backward.?compat|migrat|spec for|definition of done)\b/.test(text)) return "criteria";
373
+ if (/\b(repo map|where is|call path|callers of|symbols|trace|how does .* work|navigate the code)\b/.test(text)) return "repo-map";
374
+ return "consult";
375
+ }
376
+
377
+ function chip(theme: Theme, text: string, color: "accent" | "muted" | "success" | "warning" | "error"): string {
378
+ return theme.fg(color, `[${text}]`);
379
+ }
380
+
381
+ function topBorder(width: number, title: string, theme: Theme): string {
382
+ const plain = `╭─ ${title} `;
383
+ return theme.fg("borderAccent", truncateToWidth(`${plain}${"─".repeat(width)}`, width - 1, "")) + theme.fg("borderAccent", "╮");
384
+ }
385
+
386
+ function midBorder(width: number, theme: Theme): string {
387
+ return theme.fg("borderMuted", `├${"─".repeat(Math.max(0, width - 2))}┤`);
388
+ }
389
+
390
+ function bottomBorder(width: number, theme: Theme): string {
391
+ return theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
392
+ }
393
+
394
+ function frameLine(content: string, width: number, theme: Theme): string {
395
+ const innerWidth = Math.max(0, width - 4);
396
+ const clipped = truncateToWidth(content, innerWidth, "…");
397
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
398
+ return `${theme.fg("borderMuted", "│ ")}${clipped}${padding}${theme.fg("borderMuted", " │")}`;
399
+ }
400
+
401
+ function padRight(text: string, width: number): string {
402
+ const clipped = truncateToWidth(text, width, "…");
403
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
404
+ }
405
+
406
+ function shortModel(model: string): string {
407
+ const parts = model.split("/");
408
+ return parts.at(-1) ?? model;
409
+ }
410
+
411
+ function isPrintable(data: string): boolean {
412
+ return data.length > 0 && !/^\x1b/.test(data) && [...data].every((char) => char >= " " || char === "\n" || char === "\t");
413
+ }