A council of independent code reviewers (Codex, Pi, Devin, OpenCodeReview) runs over your working diff in parallel; one summary of agreements, disagreements…

<img alt="agent-council: a council of independent code reviewers for Claude Code" src=".github/assets/banner-light.svg" width="100%">
/council hands your working diff to every reviewer CLI you have installed (Codex, Pi, Devin, OpenCodeReview), runs them in parallel, and merges what they found into one summary: where they agree, where they disagree, and what only one of them saw. Nothing reaches the session's model until you send it.

┌ Council ─────────────────────────────────────────────────────────┐
│ Question: is the cache safe? │
│ ● codex done 3:12 2 findings │
│ ● pi done 1:47 2 findings │
│ ◐ ocr running 2:05 │
│ [ocr] reviewing src/cache.ts … │
│ ○ devin skipped limited until 18:40 │
│ │
│ Agreements │
│ • (codex, pi) src/cache.ts:12 entries are never invalidated, so │
│ stale values are served after an update. │
│ Unique findings │
│ • (codex) src/log.ts:3 typo in a log message. │
│ • (pi) src/api.ts:40 save() is not awaited. │
│ │
│ [ send to model ] [ rerun ] [ close ] │
└──────────────────────────────────────────────────────────────────┘
/council send.This repository is its own marketplace:
/plugin marketplace add apolenkov/agent-council
/plugin install agent-council@agent-council
It is also listed, with its sibling mods, in the agent-mods marketplace:
/plugin marketplace add apolenkov/agent-mods
/plugin install agent-council@agent-mods
Requires Claude Code 2.1.287 or later (mods are on by default), and at least two of these CLIs installed and signed in:
| Member | How it is run |
|---|---|
| codex | codex exec review --uncommitted --ephemeral (a question: codex exec review --ephemeral -) |
| pi | pi -p --no-session --no-tools <prompt> |
| devin | devin --permission-mode auto --respect-workspace-trust false -p <prompt> |
| ocr | ocr review --format json --audience agent (a question rides as --background) |
A member counts as installed when <bin> --version prints its own signature (codex-cli …, open-code-review v…, devin …, a bare version for pi), so another tool that happens to be called ocr is not run. Pi and Devin get the diff in their prompt and are asked for JSON lines; prose is kept as one finding. Devin is always started with --permission-mode auto (read-only tools), whatever DEVIN_PERMISSION_MODE says. Pi runs with no tools.
[!NOTE] The OpenCodeReview adapter was built from its CLI reference (v1.12.11) and a recorded example of its JSON, not from a live review; the Codex parser follows the findings block that codex-rs prints. Neither has been checked against a paid run yet.
[!NOTE] Renamed from
council. The plugin is nowagent-council(repositoryapolenkov/agent-council, formerlyclaude-council; marketplaceagent-mods, formerlyagent-watchandclaude-mods). Reinstall it under the new name and remove the oldcouncilplugin. Your options keep their names but live under the new plugin id, so set them again. The command is still/council.
| Command | What it does |
|---|---|
/council | Review the working diff (git diff HEAD plus untracked text files) |
/council <question> | The same, with your question for every reviewer |
/council send | Submit the last summary to the model (same as the pane's button) |
/council status | Open the pane |
/council cancel | Stop a running review; its reviewers are stopped and no summary is written |
With fewer than two runnable members the council says so and runs nothing. Only one run at a time.
What /council send submits to the model:
Independent reviewers (the council) reviewed the working diff.
Verify each point against the code before acting on it.
## Agreements
- (codex, pi) src/cache.ts:12 entries are never invalidated …
## Unique findings
- (codex) src/log.ts:3 typo in a log message.
- (pi) src/api.ts:40 save() is not awaited.
With autoReview set to notify, an answered turn of the main conversation schedules a review once the session has been idle for 15 seconds (any new prompt cancels it), when the diff changed since the last review and the cooldown has passed. While other work the turn left running is not done (a subagent, or a background Bash task the transcript shows started and not yet ended by its completion notice), nothing is scheduled, and the check is made again right before the review starts; the turn that follows that work's end schedules it. It never holds a turn, never opens the pane, and only notifies: a toast and the status line agent-council: N findings (1 finding for one; Claude Code adds the agent-council: label). The status line shows while a review runs and its result until you open /council status or send the summary; then it clears, leaving room for other plugins' lines. Off by default, because the members' CLIs may cost you money.
Set in /config.
| Option | Default | What it does |
|---|---|---|
members | (empty: all installed) | Comma list choosing and ordering members, e.g. codex,ocr. |
limitsDir | ~/.local/state/executor-limits | A file <dir>/<member> holding a future epoch (seconds) skips that member. |
timeoutMin | 8 | A member running longer is stopped and marked failed. |
summarizer | claude | claude, or jev (TypeSafe scores, Claude writes). |
summarizerModel | sonnet | The Claude model that writes the summary (an alias or a full id). |
typesafeApiKey | (empty: TYPESAFE_API_KEY) | Kept in secure storage. Not needed for a loopback systemOneUrl. |
systemOneUrl | https://api.typesafe.ai | The System One API's base URL; http only for 127.0.0.1, localhost or ::1. |
systemOneModel | (empty: TYPESAFE_MODEL) | The System One model; jev-latest when both are empty. |
jevThreshold | 0.3 | Findings Jev rates less likely than this to be real go to notes. |
autoReview | off | notify turns the auto-review on. |
cooldownMin | 10 | The least time between two auto-reviews. |
Limit files are only read, never written; a missing file means no limit.
$.model.complete on summarizerModel (Sonnet by default) merges every finding into agreements, disagreements, unique findings and notes. If its reply is not the asked JSON, every finding is listed as it came, with a note saying so.What leaves your machine:
diff HEAD plus untracked text files of at most 64 KB, cut at 200 KB. Untracked files named like secrets (.env*, *.pem, *.key, anything with secret or credential in its name) are never read or sent. Codex (--uncommitted`) and OpenCodeReview read the working tree themselves; that filter cannot apply to them, their own ignore rules do.
summarizer: jev: each finding's member, location, title and up to 300 characters of detail. By default that is TypeSafe (api.typesafe.ai, needs a key). With `systemOneUrl:http://127.0.0.1:8010 and systemOneModel: kev-latest` (a local Kev server speaking the same API) the scoring stays on your machine and no key or Authorization header is sent; the prose is still written by Claude, as above. Plain http to any other host is refused.
/council send or press the pane's button.No telemetry. See SECURITY.md.
See CONTRIBUTING.md: npm ci, then npm run check; try it live with claude --plugin-dir .. Releases are cut by release-please; the history before 0.2.0 comes from agent-mods. Questions: SUPPORT.md.
Live checks run locally: npm run eval (headless, on your Claude login; not in CI).
MIT. engine-types/claude-code.d.ts is © Anthropic PBC and not covered by the MIT license; see engine-types/NOTICE.md.
hooks/register.tsx 205 lines1import type {
2 EngineInterface,
3 Register,
4 RenderElement,
5 RenderInput,
6} from "claude-code";
7import { atom, read, update } from "claude-code";
8
9import { councilCommand, sendSummary, startRun } from "./effects/command.ts";
10import { autoReview, interruptStale } from "./effects/council.ts";
11import type { Host } from "./effects/host.ts";
12import { runningBackgroundTasks } from "./model/background.ts";
13import { configOf, type CouncilConfig } from "./model/config.ts";
14import { paneView } from "./view/pane.tsx";
15
16/** The last (or current) run; idle with no members before the first. */
17const RUN = atom({ plugin: "agent-council", key: "run" } as const, {
18 phase: "idle",
19 members: [],
20});
21
22/** The diff last reviewed and when. */
23const REVIEWED = atom({ plugin: "agent-council", key: "reviewed" } as const, {
24 hash: "",
25 at: 0,
26});
27
28/** Bumped by every prompt, so a waiting auto-review sees it is stale. */
29const AUTO_TOKEN = atom(
30 { plugin: "agent-council", key: "autoToken" } as const,
31 0,
32);
33
34/** The pane's id. */
35const PANE = "council";
36
37/** How long the session must stay idle before an auto-review starts. */
38const IDLE_MS = 15_000;
39
40/**
41 * Binds the engine for the effects: every `$` call is spelled here.
42 * @param $ the hook's engine
43 * @returns the host
44 */
45function hostOf($: Readonly<EngineInterface>): Host {
46 return {
47 now: () => $.clock.now(),
48 after: (ms, callback) => $.clock.after(ms, callback),
49 every: (ms, callback) => $.clock.every(ms, callback),
50 isWorkRunning: () => isWorkRunning($),
51 run: (argv, init) => $.process.run(argv, init),
52 spawn: (request) => $.process.spawn(request),
53 readFile: (path) => $.fs.read(path),
54 stat: (path) => $.fs.stat(path),
55 writeFile: (path, text) => $.fs.write(path, text),
56 removeFile: async (path) => {
57 try {
58 await $.process.run(["rm", "-f", path]);
59 } catch {
60 // Best effort: a prompt file left in the temp folder is no harm.
61 }
62 },
63 tmpdir: () => $.env.get("TMPDIR"),
64 home: () => $.env.get("HOME"),
65 typesafeKey: () => $.env.get("TYPESAFE_API_KEY"),
66 typesafeModel: () => $.env.get("TYPESAFE_MODEL"),
67 cwd: () => $.session.cwd(),
68 complete: (request) => $.model.complete(request),
69 fetch: (url, init) => $.http.fetch(url, init),
70 submit: (text) => $.prompt.submit({ text }),
71 openPane: () => $.ui.open({ id: PANE, title: "Council" }),
72 closePane: () => $.ui.close({ id: PANE }),
73 status: (text) => {
74 $.ui.status(text);
75 },
76 toast: (text) => {
77 $.ui.toast(text);
78 },
79 ...stateOf($),
80 };
81}
82
83/**
84 * Whether other work still runs: a subagent, or a background Bash task the
85 * transcript shows started and not yet ended.
86 * @param $ the hook's engine
87 * @returns true while either runs
88 */
89async function isWorkRunning($: Readonly<EngineInterface>): Promise<boolean> {
90 const agents = await $.agent.list();
91 const rows = await $.session.messages();
92 return (
93 agents.some((agent) => agent.status === "running") ||
94 runningBackgroundTasks(rows).length > 0
95 );
96}
97
98/**
99 * The host's reads and writes of the council's `$.state`.
100 * @param $ the hook's engine
101 * @returns those members of the host
102 */
103function stateOf(
104 $: Readonly<EngineInterface>,
105): Pick<
106 Host,
107 "readRun" | "updateRun" | "readReviewed" | "writeReviewed" | "readToken"
108> {
109 return {
110 readRun: () => read($, RUN),
111 updateRun: (change) => update($, RUN, change),
112 readReviewed: () => read($, REVIEWED),
113 writeReviewed: (reviewed) => update($, REVIEWED, () => reviewed),
114 readToken: () => read($, AUTO_TOKEN),
115 };
116}
117
118/**
119 * Draws the pane from the run, its buttons bound to the effects.
120 * @param $ the hook's engine
121 * @param e the Pane's render input
122 * @param config the plugin's config
123 * @returns the tree
124 */
125async function drawPane(
126 $: Readonly<EngineInterface>,
127 e: Readonly<RenderInput<"Pane">>,
128 config: CouncilConfig,
129): Promise<Readonly<RenderElement>> {
130 const { Box, Text, Button } = $.ui.resolve(e);
131 const host = hostOf($);
132 const run = await read($, RUN);
133 const acts = {
134 onSend: () => {
135 void sendSummary(host);
136 },
137 onRerun: () => {
138 void startRun(host, config, run.question);
139 },
140 onClose: () => {
141 void host.closePane();
142 },
143 };
144 return paneView(
145 { ui: { Box, Text, Button }, acts },
146 run,
147 await $.clock.now(),
148 );
149}
150
151/**
152 * Wires the council: /council, its pane, and the auto-review that only
153 * notifies.
154 * @param on registers a hook
155 * @param options the plugin's `userConfig` values
156 */
157export const register: Register = (on, options) => {
158 const config = configOf(options);
159
160 on("session.start", async ($, e, next) => {
161 await $.command.register({
162 name: "council",
163 description:
164 "Independent reviewers on the working diff, one summary (send, status, cancel)",
165 argumentHint: "[question | send | status | cancel]",
166 });
167 // A reload keeps $.state but drops the old timers and children.
168 await interruptStale(hostOf($));
169 return next(e);
170 });
171
172 on("command.run", { command: "council" }, async ($, e) => ({
173 text: await councilCommand(hostOf($), e.args, config),
174 }));
175
176 // Any prompt makes a waiting auto-review stale: it runs only after idle.
177 on("prompt.submit", async ($, e, next) => {
178 await update($, AUTO_TOKEN, (token) => token + 1);
179 return next(e);
180 });
181
182 on("turn.complete", async ($, e, next) => {
183 // Not while a subagent or a background Bash task still works: its
184 // result is not in the diff yet. Its end brings another turn, and that
185 // one schedules.
186 if (
187 config.autoReview === "notify" &&
188 e.agentId === undefined &&
189 e.reason === "answer" &&
190 !(await hostOf($).isWorkRunning())
191 ) {
192 const token = await read($, AUTO_TOKEN);
193 const host = hostOf($);
194 $.clock.after(IDLE_MS, () => {
195 void autoReview(host, config, token);
196 });
197 }
198 return next(e);
199 });
200
201 on("ui.render", { component: "Pane", requestId: PANE }, ($, e) =>
202 drawPane($, e, config),
203 );
204};
205hooks/effects/command.ts 98 lines1import type { CouncilConfig } from "../model/config.ts";
2import { sendText } from "../model/summary.ts";
3import { claimOf, convene, didCancel } from "./council.ts";
4import type { Host } from "./host.ts";
5
6/**
7 * Submits the last summary to the model: the one way findings reach it.
8 * @param host the engine
9 * @returns what to tell the owner
10 */
11export const sendSummary = async (host: Host): Promise<string> => {
12 const { summary } = await host.readRun();
13 if (summary === undefined) {
14 return "No council summary to send yet.";
15 }
16 const text = sendText(summary);
17 // Seen and sent: the line gives way to other plugins' status lines.
18 host.status(undefined);
19 // Submitted once this hook has returned: a prompt waits on the turn.
20 host.after(0, () => {
21 void host.submit(text);
22 });
23 return "Sent the council's summary to the model.";
24};
25
26/**
27 * Starts a run unless one is under way: claims the council, opens the
28 * pane, and schedules the work on the clock so no hook waits on it.
29 * @param host the engine
30 * @param config the plugin's config
31 * @param question the owner's question, when there is one
32 * @returns what to tell the owner
33 */
34export const startRun = async (
35 host: Host,
36 config: CouncilConfig,
37 question: string | undefined,
38): Promise<string> => {
39 const request = {
40 isAuto: false,
41 ...(question !== undefined && { question }),
42 };
43 const runId = await claimOf(host, request);
44 if (runId === undefined) {
45 return "The council is already reviewing; /council status shows it.";
46 }
47 await host.openPane();
48 host.after(0, () => {
49 void convene(host, config, { ...request, runId });
50 });
51 return "The council is reviewing the working diff; /council status shows it.";
52};
53
54/**
55 * Opens the pane; a finished run's status line is cleared, it was seen.
56 * @param host the engine
57 * @returns what to tell the owner
58 */
59const showPane = async (host: Host): Promise<string> => {
60 const { phase } = await host.readRun();
61 if (phase === "done") {
62 host.status(undefined);
63 }
64 await host.openPane();
65 return "Council pane opened.";
66};
67
68const cancelText = async (host: Host): Promise<string> =>
69 (await didCancel(host))
70 ? "Cancelled the council's review."
71 : "No council review is running.";
72
73/**
74 * `/council`, `/council <question>`, `/council send`, `/council status`,
75 * `/council cancel`.
76 * @param host the engine
77 * @param args what followed the command
78 * @param config the plugin's config
79 * @returns the command's output line
80 */
81export const councilCommand = async (
82 host: Host,
83 args: string,
84 config: CouncilConfig,
85): Promise<string> => {
86 const word = args.trim();
87 if (word === "send") {
88 return sendSummary(host);
89 }
90 if (word === "status") {
91 return showPane(host);
92 }
93 if (word === "cancel") {
94 return cancelText(host);
95 }
96 return startRun(host, config, word === "" ? undefined : word);
97};
98hooks/effects/council.ts 306 lines1import type { CouncilMember, CouncilRun } from "../../types/index.d.ts";
2import type { CouncilConfig } from "../model/config.ts";
3import { findingsLabel } from "../model/format.ts";
4import { itemCount } from "../model/summary.ts";
5import { detectMembers } from "./detect.ts";
6import { hashOf, workingDiff } from "./diff.ts";
7import type { Host } from "./host.ts";
8import { runMember } from "./run-member.ts";
9import { summarize } from "./summarize.ts";
10
11/** What started a run: the owner's question, and whether it was automatic. */
12export type CouncilRequest = Readonly<{
13 question?: string;
14 isAuto: boolean;
15 /** The diff, when the caller already read it. */
16 diff?: string;
17}>;
18
19/** A request whose run was claimed: every write is made to that run only. */
20export type ClaimedRequest = CouncilRequest & Readonly<{ runId: string }>;
21
22const MIN_MEMBERS = 2;
23
24const isRunBusy = (run: CouncilRun): boolean =>
25 run.phase === "running" || run.phase === "summarizing";
26
27// Still this run, in this phase: a cancel (or a newer run) ends ours.
28const isOurs = (
29 run: CouncilRun,
30 id: string,
31 phase: CouncilRun["phase"],
32): boolean => run.id === id && run.phase === phase;
33
34/**
35 * Cancels the running review: the run ends as cancelled, its members'
36 * children are stopped by their runs (they watch the run), and no summary
37 * is written.
38 * @param host the engine
39 * @returns whether a review was running
40 */
41export const didCancel = async (host: Host): Promise<boolean> => {
42 const before = await host.readRun();
43 if (!isRunBusy(before)) {
44 return false;
45 }
46 await host.updateRun((run): CouncilRun =>
47 run.id === before.id && isRunBusy(run)
48 ? {
49 ...run,
50 phase: "done",
51 note: "Cancelled.",
52 members: run.members.map((member) =>
53 member.status === "running" || member.status === "waiting"
54 ? { ...member, status: "failed", reason: "cancelled" }
55 : member,
56 ),
57 }
58 : run,
59 );
60 host.status(undefined);
61 return true;
62};
63
64/**
65 * Takes the council for a run, unless one is under way (single flight for
66 * the whole council): the check and the write are one conditional update,
67 * so of two entry points racing, one wins.
68 * @param host the engine
69 * @param request the question and whether it is automatic
70 * @returns the claim's run id when this caller holds the council now
71 */
72export const claimOf = async (
73 host: Host,
74 request: CouncilRequest,
75): Promise<string | undefined> => {
76 const id = crypto.randomUUID();
77 const startedAt = await host.now();
78 const run = await host.updateRun((current): CouncilRun =>
79 isRunBusy(current)
80 ? current
81 : {
82 id,
83 phase: "running",
84 startedAt,
85 isAuto: request.isAuto,
86 members: [],
87 ...(request.question !== undefined && {
88 question: request.question,
89 }),
90 },
91 );
92 if (run.id !== id) {
93 return undefined;
94 }
95 host.status("reviewing…");
96 return id;
97};
98
99/**
100 * Ends a run a reload cut short: the old environment's timers and children
101 * are gone, so nothing will finish it.
102 * @param host the engine
103 * @returns once written
104 */
105export const interruptStale = async (host: Host): Promise<void> => {
106 await host.updateRun((run): CouncilRun =>
107 isRunBusy(run)
108 ? {
109 ...run,
110 phase: "done",
111 note: "interrupted (the plugin reloaded); /council runs it again.",
112 members: run.members.map((member) =>
113 member.status === "running" || member.status === "waiting"
114 ? { ...member, status: "failed", reason: "interrupted" }
115 : member,
116 ),
117 }
118 : run,
119 );
120};
121
122// Ends run `id` with a note, unless it was cancelled or replaced meanwhile.
123const finishWithNote = async (
124 host: Host,
125 id: string,
126 note: string,
127): Promise<void> => {
128 const run = await host.updateRun((current): CouncilRun =>
129 current.id === id && isRunBusy(current)
130 ? { ...current, phase: "done", note }
131 : current,
132 );
133 if (run.id !== id || run.note !== note) {
134 return;
135 }
136 host.status(undefined);
137 host.toast(note);
138};
139
140const tooFew = (members: readonly CouncilMember[]): string => {
141 const ready = members.filter((member) => member.status === "waiting");
142 const names = members.map((member) =>
143 member.reason === undefined
144 ? member.name
145 : `${member.name} (${member.reason})`,
146 );
147 const list = names.length === 0 ? "none found" : names.join(", ");
148 return `${String(ready.length)} reviewer(s) can run (${list}); the council needs ${String(MIN_MEMBERS)}. Nothing was run.`;
149};
150
151const hasReviewedWith = async (
152 host: Host,
153 config: CouncilConfig,
154 input: Readonly<{ id: string; diff: string; question?: string }>,
155): Promise<boolean> => {
156 const { id } = input;
157 const members = await detectMembers(host, config);
158 const run = await host.updateRun((current): CouncilRun =>
159 isOurs(current, id, "running") ? { ...current, members } : current,
160 );
161 // Cancelled (or replaced) while the members were found: launch nobody.
162 if (!isOurs(run, id, "running")) {
163 return false;
164 }
165 const runnable = members.filter((member) => member.status === "waiting");
166 if (runnable.length < MIN_MEMBERS) {
167 await finishWithNote(host, id, tooFew(members));
168 return false;
169 }
170 await Promise.all(
171 runnable.map(({ name }) =>
172 runMember(host, { name, input, timeoutMs: config.timeoutMs, runId: id }),
173 ),
174 );
175 return hasSummarized(host, config, input);
176};
177
178// The summary of the run `id`, unless it was cancelled meanwhile.
179const hasSummarized = async (
180 host: Host,
181 config: CouncilConfig,
182 input: Readonly<{ id: string; question?: string }>,
183): Promise<boolean> => {
184 const { id } = input;
185 const done = await host.updateRun((run): CouncilRun =>
186 isOurs(run, id, "running") ? { ...run, phase: "summarizing" } : run,
187 );
188 if (!isOurs(done, id, "summarizing")) {
189 return false;
190 }
191 const summary = await summarize(
192 host,
193 done.members.flatMap((member) => member.findings),
194 {
195 config,
196 ...(input.question !== undefined && { question: input.question }),
197 },
198 );
199 const ended = await host.updateRun((run): CouncilRun =>
200 isOurs(run, id, "summarizing") ? { ...run, phase: "done", summary } : run,
201 );
202 if (ended.summary !== summary) {
203 return false;
204 }
205 const count = itemCount(summary);
206 host.status(findingsLabel(count));
207 host.toast(`${findingsLabel(count)} — /council status`);
208 return true;
209};
210
211/**
212 * One run, after `claimOf`: the diff, the members, their reviews in
213 * parallel, the summary. Called from a `$.clock.after` callback, never
214 * awaited by a hook; the long `$` calls inside do not count against any
215 * hook's budget.
216 * @param host the engine
217 * @param config the plugin's config
218 * @param request the question and whether it is automatic
219 * @returns once the run is done
220 */
221export const convene = async (
222 host: Host,
223 config: CouncilConfig,
224 request: ClaimedRequest,
225): Promise<void> => {
226 try {
227 await reviewDiff(host, config, request);
228 } catch (error) {
229 // Never leave the council busy: a failed run is a finished one. If even
230 // that write fails, see below.
231 const why = error instanceof Error ? error.message : String(error);
232 try {
233 await finishWithNote(host, request.runId, `the run failed: ${why}`);
234 } catch {
235 // The module is gone (a reload): the next session start ends the run.
236 }
237 }
238};
239
240const reviewDiff = async (
241 host: Host,
242 config: CouncilConfig,
243 request: ClaimedRequest,
244): Promise<void> => {
245 const id = request.runId;
246 const diff = request.diff ?? (await workingDiff(host));
247 if (diff.trim() === "") {
248 await finishWithNote(
249 host,
250 id,
251 "nothing to review: the working tree is clean.",
252 );
253 return;
254 }
255 const hasReviewed = await hasReviewedWith(host, config, {
256 id,
257 diff,
258 ...(request.question !== undefined && { question: request.question }),
259 });
260 // Only a review that ran counts: a skipped one may run once members free
261 // up. Hashed after the review: the digest is real time, not the clock's.
262 if (hasReviewed) {
263 await host.writeReviewed({
264 hash: await hashOf(diff),
265 at: await host.now(),
266 });
267 }
268};
269
270/**
271 * The auto-review, once the session has been idle: runs only when no
272 * prompt came since it was scheduled, no run is under way, the cooldown
273 * has passed and the diff changed since the last review.
274 * @param host the engine
275 * @param config the plugin's config
276 * @param token the prompt count when it was scheduled
277 * @returns once done or skipped
278 */
279export const autoReview = async (
280 host: Host,
281 config: CouncilConfig,
282 token: number,
283): Promise<void> => {
284 const reviewed = await host.readReviewed();
285 const now = await host.now();
286 const isStale =
287 (await host.readToken()) !== token ||
288 isRunBusy(await host.readRun()) ||
289 (reviewed.at > 0 && now - reviewed.at < config.cooldownMs);
290 if (isStale) {
291 return;
292 }
293 const diff = await workingDiff(host);
294 const isWanted =
295 diff.trim() !== "" &&
296 (await hashOf(diff)) !== reviewed.hash &&
297 // A prompt while the diff was read still cancels the run, and a
298 // subagent or background task started during the idle wait holds it back.
299 (await host.readToken()) === token &&
300 !(await host.isWorkRunning());
301 const runId = isWanted ? await claimOf(host, { isAuto: true }) : undefined;
302 if (runId !== undefined) {
303 await convene(host, config, { isAuto: true, diff, runId });
304 }
305};
306hooks/effects/host.ts 88 lines1import type {
2 HookStream,
3 HttpInit,
4 HttpResponse,
5 ModelCompleteRequest,
6 ModelCompleteResult,
7 ProcessRunInit,
8 ProcessRunResult,
9 ProcessSpawnChunk,
10 ProcessSpawnRequest,
11 ProcessSpawnResult,
12 Timer,
13} from "claude-code";
14
15import type { CouncilReviewed, CouncilRun } from "../../types/index.d.ts";
16
17/**
18 * The engine as a hook in register.tsx bound it from its `$`, each member
19 * spelled `$.noun.event(...)` there (the engine follows `$` into no
20 * import); what the effects and the timers they start call.
21 */
22export type Host = Readonly<{
23 /** `$.clock.now`. */
24 now: () => Promise<number>;
25 /** `$.clock.after`. */
26 after: (ms: number, callback: () => void) => Timer;
27 /** `$.clock.every`. */
28 every: (ms: number, callback: () => void) => Timer;
29 /**
30 * Whether other work still runs: a subagent (`$.agent.list()`) or a
31 * background Bash task (from `$.session.messages()`).
32 */
33 isWorkRunning: () => Promise<boolean>;
34 /** `$.process.run`. */
35 run: (
36 argv: readonly string[],
37 init?: Readonly<ProcessRunInit>,
38 ) => Promise<ProcessRunResult>;
39 /** `$.process.spawn`. */
40 spawn: (
41 request: Readonly<ProcessSpawnRequest>,
42 ) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>;
43 /** `$.fs.read`. */
44 readFile: (path: string) => Promise<string>;
45 /** `$.fs.write`. */
46 writeFile: (path: string, text: string) => Promise<void>;
47 /** Best-effort `rm -f` through `$.process.run`; never rejects. */
48 removeFile: (path: string) => Promise<void>;
49 /** `$.env.get("TMPDIR")`. */
50 tmpdir: () => Promise<string | undefined>;
51 /** `$.fs.stat`: the kind and size. */
52 stat: (path: string) => Promise<Readonly<{ kind: string; size: number }>>;
53 /** `$.env.get("HOME")`. */
54 home: () => Promise<string | undefined>;
55 /** `$.env.get("TYPESAFE_API_KEY")`. */
56 typesafeKey: () => Promise<string | undefined>;
57 /** `$.env.get("TYPESAFE_MODEL")`. */
58 typesafeModel: () => Promise<string | undefined>;
59 /** `$.session.cwd`. */
60 cwd: () => Promise<string>;
61 /** `$.model.complete`. */
62 complete: (
63 request: Readonly<ModelCompleteRequest>,
64 ) => Promise<ModelCompleteResult>;
65 /** `$.http.fetch`. */
66 fetch: (url: string, init: Readonly<HttpInit>) => Promise<HttpResponse>;
67 /** `$.prompt.submit` with the text. */
68 submit: (text: string) => Promise<unknown>;
69 /** `$.ui.open` of the council's pane. */
70 openPane: () => Promise<unknown>;
71 /** `$.ui.close` of the council's pane. */
72 closePane: () => Promise<unknown>;
73 /** `$.ui.status`. */
74 status: (text: string | undefined) => void;
75 /** `$.ui.toast`. */
76 toast: (text: string) => void;
77 /** Reads the run (`read($, RUN)`). */
78 readRun: () => Promise<CouncilRun>;
79 /** Changes the run (`update($, RUN, change)`). */
80 updateRun: (change: (run: CouncilRun) => CouncilRun) => Promise<CouncilRun>;
81 /** Reads the last review (`read($, REVIEWED)`). */
82 readReviewed: () => Promise<CouncilReviewed>;
83 /** Writes the last review. */
84 writeReviewed: (reviewed: CouncilReviewed) => Promise<unknown>;
85 /** Reads the prompt count (`read($, AUTO_TOKEN)`). */
86 readToken: () => Promise<number>;
87}>;
88hooks/model/background.ts 69 lines1import { fieldOf } from "../json/parse-json.ts";
2
3/** The part of a `$.session.messages()` row the check reads. */
4export type TranscriptRow = Readonly<{
5 role: string;
6 text: string;
7 toolUses: readonly Readonly<{
8 tool: string;
9 input?: unknown;
10 result?: unknown;
11 isError?: true;
12 }>[];
13}>;
14
15/** A notification block opens with this tag and may be cut before its close. */
16const OPEN = "<task-notification>";
17const CLOSE = "</task-notification>";
18const TASK_ID = /<task-id>([^<]+)<\/task-id>/u;
19const TERMINAL = /<status>(?:completed|failed|killed|stopped)<\/status>/u;
20
21const usesOf = (rows: readonly TranscriptRow[]): TranscriptRow["toolUses"] =>
22 rows.filter((row) => row.role === "assistant").flatMap((row) => row.toolUses);
23
24const startedIn = (rows: readonly TranscriptRow[]): readonly string[] =>
25 usesOf(rows)
26 .filter((use) => use.tool === "Bash")
27 .map((use) => fieldOf(use.result, "backgroundTaskId"))
28 .filter((id): id is string => typeof id === "string");
29
30// A notification ends a task only with a terminal status: one saying a
31// command may be waiting for input carries the id, and the task runs on.
32const notifiedIn = (rows: readonly TranscriptRow[]): readonly string[] =>
33 rows
34 .filter((row) => row.role === "user")
35 .flatMap((row) => row.text.split(OPEN).slice(1))
36 .map((part) => part.split(CLOSE, 1)[0] ?? "")
37 .filter((block) => TERMINAL.test(block))
38 .map((block) => TASK_ID.exec(block)?.[1])
39 .filter((id) => id !== undefined);
40
41// A successful TaskStop ends its task; the engine adds no notification then.
42const stoppedIn = (rows: readonly TranscriptRow[]): readonly string[] =>
43 usesOf(rows)
44 .filter(
45 (use) =>
46 use.tool === "TaskStop" &&
47 use.isError !== true &&
48 use.result !== undefined,
49 )
50 .map(
51 (use) => fieldOf(use.input, "task_id") ?? fieldOf(use.input, "shell_id"),
52 )
53 .filter((id): id is string => typeof id === "string");
54
55/**
56 * The background Bash tasks still running: started by a Bash call that went
57 * to the background (`result.backgroundTaskId`) and not yet ended by a
58 * task-notification with a terminal status or by a successful TaskStop. No
59 * API lists them; the transcript does.
60 * @param rows the session's messages, oldest first
61 * @returns the running tasks' ids
62 */
63export const runningBackgroundTasks = (
64 rows: readonly TranscriptRow[],
65): readonly string[] => {
66 const ended = new Set([...notifiedIn(rows), ...stoppedIn(rows)]);
67 return startedIn(rows).filter((id) => !ended.has(id));
68};
69hooks/model/config.ts 89 lines1import type { PluginOptions } from "claude-code";
2
3import type { CouncilMemberName } from "../../types/index.d.ts";
4
5/** Every reviewer the council can run, in its default order. */
6export const MEMBER_NAMES: readonly CouncilMemberName[] = [
7 "codex",
8 "pi",
9 "devin",
10 "ocr",
11];
12
13const MINUTE_MS = 60_000;
14
15/** `process.run` stops a child at ten minutes; leave room for the rest. */
16const MAX_TIMEOUT_MS = 570_000;
17
18const DEFAULTS = {
19 limitsDir: "~/.local/state/executor-limits",
20 timeoutMin: 8,
21 jevThreshold: 0.3,
22 cooldownMin: 10,
23 summarizerModel: "sonnet",
24 systemOneUrl: "https://api.typesafe.ai",
25} as const;
26
27/** The plugin's options, read and defaulted. */
28export type CouncilConfig = Readonly<{
29 members: readonly CouncilMemberName[];
30 limitsDir: string;
31 timeoutMs: number;
32 summarizer: "claude" | "jev";
33 /** The model that writes the summary (an alias or a full id). */
34 summarizerModel: string;
35 jevThreshold: number;
36 typesafeApiKey: string;
37 /** The System One API's base URL: TypeSafe's, or a local server's. */
38 systemOneUrl: string;
39 /** Its model; empty: TYPESAFE_MODEL, else jev-latest. */
40 systemOneModel: string;
41 autoReview: "notify" | "off";
42 cooldownMs: number;
43}>;
44
45const isMemberName = (name: string): name is CouncilMemberName =>
46 (MEMBER_NAMES as readonly string[]).includes(name);
47
48const stringOf = (value: unknown, fallback: string): string =>
49 typeof value === "string" ? value : fallback;
50
51const numberOf = (value: unknown, fallback: number): number =>
52 typeof value === "number" && Number.isFinite(value) ? value : fallback;
53
54/**
55 * Reads the comma list of members, keeping its order and known names.
56 * @param list the option's text
57 * @returns the names, possibly none
58 */
59const membersOf = (list: string): readonly CouncilMemberName[] =>
60 list
61 .split(",")
62 .map((name) => name.trim())
63 .filter(isMemberName);
64
65/**
66 * The options as the council uses them.
67 * @param options the `userConfig` values the engine passes to `register`
68 * @returns the config, every value defaulted
69 */
70export const configOf = (options: PluginOptions): CouncilConfig => ({
71 members: membersOf(stringOf(options["members"], "")),
72 limitsDir: stringOf(options["limitsDir"], DEFAULTS.limitsDir),
73 timeoutMs: Math.min(
74 MAX_TIMEOUT_MS,
75 numberOf(options["timeoutMin"], DEFAULTS.timeoutMin) * MINUTE_MS,
76 ),
77 summarizer: options["summarizer"] === "jev" ? "jev" : "claude",
78 summarizerModel:
79 stringOf(options["summarizerModel"], "").trim() || DEFAULTS.summarizerModel,
80 jevThreshold: numberOf(options["jevThreshold"], DEFAULTS.jevThreshold),
81 typesafeApiKey: stringOf(options["typesafeApiKey"], ""),
82 systemOneUrl:
83 stringOf(options["systemOneUrl"], "").trim() || DEFAULTS.systemOneUrl,
84 systemOneModel: stringOf(options["systemOneModel"], "").trim(),
85 autoReview: options["autoReview"] === "notify" ? "notify" : "off",
86 cooldownMs:
87 numberOf(options["cooldownMin"], DEFAULTS.cooldownMin) * MINUTE_MS,
88});
89hooks/view/pane.tsx 129 lines1import type { Elements, RenderElement } from "claude-code";
2
3import type {
4 CouncilMember,
5 CouncilRun,
6 CouncilSummary,
7} from "../../types/index.d.ts";
8import { memberLine, tailLines } from "../model/format.ts";
9
10const TAIL_LINES = 3;
11
12const SECTIONS = [
13 ["agreements", "Agreements"],
14 ["disagreements", "Disagreements"],
15 ["unique", "Unique findings"],
16] as const;
17
18/** What the pane's buttons do. */
19type PaneActs = Readonly<{
20 onSend: () => void;
21 onRerun: () => void;
22 onClose: () => void;
23}>;
24
25/** What the pane draws with: the surface's elements and the buttons' acts. */
26export type PaneKit = Readonly<{
27 ui: Readonly<Pick<Elements["terminal"], "Box" | "Text" | "Button">>;
28 acts: PaneActs;
29}>;
30
31const memberView = (
32 kit: PaneKit,
33 member: CouncilMember,
34 nowMs: number,
35): Readonly<RenderElement> => {
36 const { Box, Text } = kit.ui;
37 return (
38 <Box flexDirection="column" key={`member:${member.name}`}>
39 <Text>{memberLine(member, nowMs)}</Text>
40 {member.status === "running" &&
41 tailLines(member.tail, TAIL_LINES).map((line) => (
42 <Text dimColor wrap="truncate">{` ${line}`}</Text>
43 ))}
44 </Box>
45 );
46};
47
48const summaryView = (
49 kit: PaneKit,
50 summary: CouncilSummary,
51): readonly Readonly<RenderElement>[] => {
52 const { Box, Text } = kit.ui;
53 return [
54 ...SECTIONS.filter(([key]) => summary[key].length > 0).map(
55 ([key, title]) => (
56 <Box flexDirection="column" key={`section:${key}`}>
57 <Text bold>{title}</Text>
58 {summary[key].map((item) => (
59 <Text>{`• (${item.members.join(", ")}) ${item.text}`}</Text>
60 ))}
61 </Box>
62 ),
63 ),
64 ...summary.notes.map((note) => <Text dimColor>{`note: ${note}`}</Text>),
65 ];
66};
67
68const buttonsView = (
69 kit: PaneKit,
70 run: CouncilRun,
71): Readonly<RenderElement> => {
72 const { Box, Button } = kit.ui;
73 const isBusy = run.phase === "running" || run.phase === "summarizing";
74 return (
75 <Box flexDirection="row" gap={1}>
76 {run.summary !== undefined && (
77 <Button
78 key="send"
79 label="send to model"
80 variant="primary"
81 onPress={kit.acts.onSend}
82 />
83 )}
84 {!isBusy && (
85 <Button key="rerun" label="rerun" onPress={kit.acts.onRerun} />
86 )}
87 <Button
88 key="close"
89 label="close"
90 role="dismiss"
91 onPress={kit.acts.onClose}
92 />
93 </Box>
94 );
95};
96
97/**
98 * The council's pane: each member with its status and time (and the tail
99 * of a running one), then the summary, then the buttons.
100 * @param kit the elements and the buttons' acts
101 * @param run the run to draw
102 * @param nowMs the time now
103 * @returns the tree
104 */
105export const paneView = (
106 kit: PaneKit,
107 run: CouncilRun,
108 nowMs: number,
109): Readonly<RenderElement> => {
110 const { Box, Text } = kit.ui;
111 return (
112 <Box flexDirection="column">
113 {run.phase === "idle" && (
114 <Text dimColor>No council has run yet. /council starts one.</Text>
115 )}
116 {run.question !== undefined && (
117 <Text italic>{`Question: ${run.question}`}</Text>
118 )}
119 {run.members.map((member) => memberView(kit, member, nowMs))}
120 {run.phase === "summarizing" && (
121 <Text dimColor>Writing the summary…</Text>
122 )}
123 {run.note !== undefined && <Text>{run.note}</Text>}
124 {run.summary !== undefined && summaryView(kit, run.summary)}
125 {buttonsView(kit, run)}
126 </Box>
127 );
128};
129hooks/model/summary.ts 181 lines1import type {
2 CouncilFinding,
3 CouncilSummary,
4 CouncilSummaryItem,
5} from "../../types/index.d.ts";
6import { fieldOf, parseJson } from "../json/parse-json.ts";
7
8const MAX_DETAIL = 600;
9
10/**
11 * A value with text around it, or nothing when there is no value.
12 * @param before the text before it
13 * @param value the value, maybe absent or empty
14 * @param after the text after it
15 * @returns the joined text, or ""
16 */
17const around = (
18 before: string,
19 value: string | number | undefined,
20 after: string,
21): string =>
22 value === undefined || value === ""
23 ? ""
24 : `${before}${String(value)}${after}`;
25
26const SHAPE =
27 '{"agreements":[{"members":["codex","pi"],"text":"..."}],"disagreements":[],"unique":[],"notes":["..."]}';
28
29/**
30 * Where a finding points, as `path:line`, or nothing.
31 * @param finding the finding
32 * @returns the location with a trailing space, or ""
33 */
34export const whereOf = (finding: CouncilFinding): string =>
35 finding.path === undefined
36 ? ""
37 : `${finding.path}${around(":", finding.line, "")} `;
38
39/**
40 * The prompt that asks Claude to merge the findings into one summary.
41 * @param findings every member's findings
42 * @param question the owner's question, when there was one
43 * @returns the prompt
44 */
45export const claudePrompt = (
46 findings: readonly CouncilFinding[],
47 question: string | undefined,
48): string =>
49 [
50 "Independent code reviewers reviewed the same diff. Merge their findings.",
51 "agreements: one issue raised by two or more members.",
52 "disagreements: members contradict each other (say who holds what).",
53 "unique: an issue only one member raised.",
54 "notes: anything else worth one line (likely noise, missing context).",
55 "Each item lists the members and one or two plain sentences, file:line first.",
56 `Answer with JSON only, exactly this shape: ${SHAPE}`,
57 question === undefined ? "" : `The owner asked: ${question}`,
58 JSON.stringify(
59 findings.map((finding) => ({
60 ...finding,
61 detail: finding.detail.slice(0, MAX_DETAIL),
62 })),
63 ),
64 ].join("\n");
65
66const isStringList = (value: unknown): value is readonly string[] =>
67 Array.isArray(value) && value.every((item) => typeof item === "string");
68
69const isItem = (value: unknown): value is CouncilSummaryItem =>
70 isStringList(fieldOf(value, "members")) &&
71 typeof fieldOf(value, "text") === "string";
72
73const isItemList = (value: unknown): value is readonly CouncilSummaryItem[] =>
74 Array.isArray(value) && value.every(isItem);
75
76const emptyWhenAbsent = (value: unknown): readonly [] | undefined =>
77 value === undefined ? [] : undefined;
78
79// An absent section is empty; a malformed one fails the whole summary.
80const itemsOf = (value: unknown): readonly CouncilSummaryItem[] | undefined =>
81 isItemList(value) ? value : emptyWhenAbsent(value);
82
83const SECTIONS = ["agreements", "disagreements", "unique", "notes"] as const;
84
85const summaryOf = (value: unknown): CouncilSummary | undefined => {
86 const agreements = itemsOf(fieldOf(value, "agreements"));
87 const disagreements = itemsOf(fieldOf(value, "disagreements"));
88 const unique = itemsOf(fieldOf(value, "unique"));
89 const notes = fieldOf(value, "notes") ?? [];
90 const hasSection = SECTIONS.some((key) => fieldOf(value, key) !== undefined);
91 return hasSection &&
92 agreements !== undefined &&
93 disagreements !== undefined &&
94 unique !== undefined &&
95 isStringList(notes)
96 ? { agreements, disagreements, unique, notes }
97 : undefined;
98};
99
100/**
101 * Reads Claude's reply: the JSON object in it, fenced or among prose.
102 * @param reply the reply's text
103 * @returns the summary, or undefined when the reply holds none
104 */
105export const parseSummary = (reply: string): CouncilSummary | undefined => {
106 const json = reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1);
107 return summaryOf(parseJson(json));
108};
109
110/**
111 * One finding as a line of the summary.
112 * @param finding the finding
113 * @returns `path:line [severity] title — detail`
114 */
115export const lineOf = (finding: CouncilFinding): string =>
116 [
117 whereOf(finding),
118 around("[", finding.severity, "] "),
119 finding.title,
120 around(" — ", finding.detail, ""),
121 ].join("");
122
123/**
124 * The summary when no summarizer could merge: every finding as it came.
125 * @param findings every member's findings
126 * @param why why the summarizer's answer was not used
127 * @returns each finding as a unique item, the reason as a note
128 */
129export const rawSummary = (
130 findings: readonly CouncilFinding[],
131 why: string,
132): CouncilSummary => ({
133 agreements: [],
134 disagreements: [],
135 unique: findings.map((finding) => ({
136 members: [finding.member],
137 text: lineOf(finding),
138 })),
139 notes: [why],
140});
141
142/**
143 * How many points the summary makes.
144 * @param summary the summary
145 * @returns agreements, disagreements and unique findings together
146 */
147export const itemCount = (summary: CouncilSummary): number =>
148 summary.agreements.length +
149 summary.disagreements.length +
150 summary.unique.length;
151
152const section = (
153 title: string,
154 items: readonly CouncilSummaryItem[],
155): readonly string[] =>
156 items.length === 0
157 ? []
158 : [
159 `## ${title}`,
160 ...items.map((item) => `- (${item.members.join(", ")}) ${item.text}`),
161 "",
162 ];
163
164/**
165 * The summary as the prompt `/council send` submits.
166 * @param summary the summary
167 * @returns markdown for the model
168 */
169export const sendText = (summary: CouncilSummary): string =>
170 [
171 "Independent reviewers (the council) reviewed the working diff.",
172 "Verify each point against the code before acting on it.",
173 "",
174 ...section("Agreements", summary.agreements),
175 ...section("Disagreements", summary.disagreements),
176 ...section("Unique findings", summary.unique),
177 ...(summary.notes.length === 0
178 ? []
179 : ["## Notes", ...summary.notes.map((note) => `- ${note}`)]),
180 ].join("\n");
181types/index.d.ts 77 lines1/** A reviewer the council knows how to run. */
2export type CouncilMemberName = "codex" | "pi" | "devin" | "ocr";
3
4/** One finding of one reviewer. */
5export type CouncilFinding = Readonly<{
6 member: CouncilMemberName;
7 path?: string;
8 line?: number;
9 severity?: string;
10 title: string;
11 detail: string;
12}>;
13
14/** Where one reviewer is in a run. */
15export type CouncilMemberStatus =
16 "waiting" | "running" | "done" | "skipped" | "failed";
17
18/** One reviewer's row in a run. */
19export type CouncilMember = Readonly<{
20 name: CouncilMemberName;
21 status: CouncilMemberStatus;
22 startedAt?: number;
23 endedAt?: number;
24 /** The last of its output, for the pane. */
25 tail: string;
26 /** Why it was skipped or failed. */
27 reason?: string;
28 findings: readonly CouncilFinding[];
29 /** Its whole output, the fallback when the summary cannot be parsed. */
30 raw: string;
31}>;
32
33/** One merged point of the summary and who raised it. */
34export type CouncilSummaryItem = Readonly<{
35 members: readonly string[];
36 text: string;
37}>;
38
39/** What the summarizer makes of every finding. */
40export type CouncilSummary = Readonly<{
41 agreements: readonly CouncilSummaryItem[];
42 disagreements: readonly CouncilSummaryItem[];
43 unique: readonly CouncilSummaryItem[];
44 notes: readonly string[];
45}>;
46
47/** Where the council is. */
48export type CouncilPhase = "idle" | "running" | "summarizing" | "done";
49
50/** The last (or current) run of the council. */
51export type CouncilRun = Readonly<{
52 /** Who claimed the run: a claim that finds another id lost the race. */
53 id?: string;
54 phase: CouncilPhase;
55 startedAt?: number;
56 question?: string;
57 isAuto?: boolean;
58 members: readonly CouncilMember[];
59 summary?: CouncilSummary;
60 /** A plain line instead of a run: too few members, nothing to review. */
61 note?: string;
62}>;
63
64/** The diff the council last reviewed, for the auto-review. */
65export type CouncilReviewed = Readonly<{ hash: string; at: number }>;
66
67declare module "claude-code" {
68 interface PluginState {
69 "agent-council": {
70 run: CouncilRun;
71 reviewed: CouncilReviewed;
72 /** Bumped by every prompt: an auto-review waiting on an older one is dropped. */
73 autoToken: number;
74 };
75 }
76}
77hooks/model/format.ts 77 lines1import type {
2 CouncilMember,
3 CouncilMemberStatus,
4} from "../../types/index.d.ts";
5
6const SECOND_MS = 1000;
7const MINUTE_S = 60;
8const PAD = 2;
9const NAME_WIDTH = 6;
10const STATUS_WIDTH = 8;
11const TIME_WIDTH = 5;
12
13const GLYPHS: Readonly<Record<CouncilMemberStatus, string>> = {
14 waiting: "·",
15 running: "◐",
16 done: "●",
17 skipped: "○",
18 failed: "✗",
19};
20
21/**
22 * A duration as `m:ss`.
23 * @param ms the duration, in milliseconds
24 * @returns the label
25 */
26export const elapsedLabel = (ms: number): string => {
27 const seconds = Math.floor(ms / SECOND_MS);
28 return `${String(Math.floor(seconds / MINUTE_S))}:${String(seconds % MINUTE_S).padStart(PAD, "0")}`;
29};
30
31const timeOf = (member: CouncilMember, nowMs: number): string =>
32 member.startedAt === undefined
33 ? ""
34 : elapsedLabel((member.endedAt ?? nowMs) - member.startedAt);
35
36/**
37 * A count of findings, singular for one.
38 * @param count how many
39 * @returns `1 finding` or `N findings`
40 */
41export const findingsLabel = (count: number): string =>
42 `${String(count)} ${count === 1 ? "finding" : "findings"}`;
43
44const outcomeOf = (member: CouncilMember): string =>
45 member.status === "done"
46 ? findingsLabel(member.findings.length)
47 : (member.reason ?? "");
48
49/**
50 * One member's row in the pane.
51 * @param member the member
52 * @param nowMs the time now, for a running member's elapsed time
53 * @returns `glyph name status m:ss outcome`, trailing space trimmed
54 */
55export const memberLine = (member: CouncilMember, nowMs: number): string =>
56 [
57 GLYPHS[member.status],
58 member.name.padEnd(NAME_WIDTH),
59 member.status.padEnd(STATUS_WIDTH),
60 timeOf(member, nowMs).padStart(TIME_WIDTH),
61 outcomeOf(member),
62 ]
63 .join(" ")
64 .trimEnd();
65
66/**
67 * The last non-empty lines of a member's output.
68 * @param tail the output's tail
69 * @param count how many lines
70 * @returns the lines, oldest first
71 */
72export const tailLines = (tail: string, count: number): readonly string[] =>
73 tail
74 .split("\n")
75 .filter((line) => line.trim() !== "")
76 .slice(-count);
77hooks/effects/detect.ts 76 lines1import type { CouncilMember, CouncilMemberName } from "../../types/index.d.ts";
2import type { CouncilConfig } from "../model/config.ts";
3import { MEMBER_NAMES } from "../model/config.ts";
4import { isMemberVersion, pickMembers } from "../model/detect.ts";
5import { expandHome, limitedUntil, untilLabel } from "../model/limits.ts";
6import type { Host } from "./host.ts";
7
8const VERSION_TIMEOUT_MS = 10_000;
9
10const isInstalled = async (
11 host: Host,
12 name: CouncilMemberName,
13): Promise<boolean> => {
14 try {
15 const run = await host.run([name, "--version"], {
16 timeoutMs: VERSION_TIMEOUT_MS,
17 });
18 return run.exitCode === 0 && isMemberVersion(name, run.stdout);
19 } catch {
20 // Not on PATH, or it did not answer in time.
21 return false;
22 }
23};
24
25const limitOf = async (
26 host: Host,
27 path: string,
28 nowMs: number,
29): Promise<number | undefined> => {
30 try {
31 return limitedUntil(await host.readFile(path), nowMs);
32 } catch {
33 // No limit file: the member is free.
34 return undefined;
35 }
36};
37
38const rowOf = (
39 name: CouncilMemberName,
40 until: number | undefined,
41): CouncilMember => ({
42 name,
43 status: until === undefined ? "waiting" : "skipped",
44 tail: "",
45 findings: [],
46 raw: "",
47 ...(until !== undefined && { reason: untilLabel(until) }),
48});
49
50/**
51 * Finds the members installed here (each by its `--version` signature),
52 * picks the configured ones, and marks those whose limit file holds a
53 * future reset as skipped.
54 * @param host the engine
55 * @param config the plugin's config
56 * @returns a row per picked member, waiting or skipped
57 */
58export const detectMembers = async (
59 host: Host,
60 config: CouncilConfig,
61): Promise<readonly CouncilMember[]> => {
62 const found = await Promise.all(
63 MEMBER_NAMES.map(async (name) =>
64 (await isInstalled(host, name)) ? [name] : [],
65 ),
66 );
67 const picked = pickMembers(found.flat(), config.members);
68 const directory = expandHome(config.limitsDir, (await host.home()) ?? "~");
69 const nowMs = await host.now();
70 return Promise.all(
71 picked.map(async (name) =>
72 rowOf(name, await limitOf(host, `${directory}/${name}`, nowMs)),
73 ),
74 );
75};
76hooks/effects/diff.ts 81 lines1import {
2 capDiff,
3 isSentUntracked,
4 untrackedSection,
5} from "../model/diff-input.ts";
6import type { Host } from "./host.ts";
7
8/** The most of the diff the reviewers are handed. */
9const MAX_DIFF = 200_000;
10/** Untracked files larger than this are left out. */
11const MAX_UNTRACKED = 65_536;
12const HEX = 16;
13const BYTE_DIGITS = 2;
14/** The NUL character: git's -z separator and the binary-file marker; never shown in the UI. */
15const NUL = String.fromCodePoint(0);
16
17const untrackedOf = async (
18 host: Host,
19 path: string,
20 cwd: string,
21): Promise<string> => {
22 try {
23 const stat = await host.stat(`${cwd}/${path}`);
24 const text =
25 stat.kind === "file" && stat.size <= MAX_UNTRACKED
26 ? await host.readFile(`${cwd}/${path}`)
27 : "";
28 return text === "" || text.includes(NUL)
29 ? ""
30 : untrackedSection(path, text);
31 } catch {
32 // Gone or unreadable since git listed it.
33 return "";
34 }
35};
36
37/**
38 * The working diff the council reviews: `git diff HEAD`, then each
39 * untracked file that is text, at most 64 KB and not named like a secret,
40 * the whole cut at 200 KB.
41 * @param host the engine
42 * @returns the diff; "" outside a repository or with nothing changed
43 */
44export const workingDiff = async (host: Host): Promise<string> => {
45 const tracked = await host.run(["git", "diff", "HEAD"]);
46 const listed = await host.run([
47 "git",
48 "ls-files",
49 "-z",
50 "--others",
51 "--exclude-standard",
52 ]);
53 const cwd = await host.cwd();
54 const untracked = await Promise.all(
55 // NUL-separated: names are as on disk, never quoted (core.quotePath).
56 listed.stdout
57 .split(NUL)
58 .filter((path) => path !== "" && isSentUntracked(path))
59 .map((path) => untrackedOf(host, path, cwd)),
60 );
61 return capDiff(
62 [tracked.exitCode === 0 ? tracked.stdout : "", ...untracked].join(""),
63 MAX_DIFF,
64 );
65};
66
67/**
68 * The diff's SHA-256, to tell whether it changed since the last review.
69 * @param diff the diff
70 * @returns the hash in hex
71 */
72export const hashOf = async (diff: string): Promise<string> => {
73 const digest = await crypto.subtle.digest(
74 "SHA-256",
75 new TextEncoder().encode(diff),
76 );
77 return [...new Uint8Array(digest)]
78 .map((byte) => byte.toString(HEX).padStart(BYTE_DIGITS, "0"))
79 .join("");
80};
81