SLOPSHOPPER

agent-council

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

newpanecommandtoaststatusprompt
★ 1v0.3.3MITupdated 2026-10-09apolenkov/agent-council
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-council
│ ┃ Council ✕ › fix the failing auth test and add an audit log call │ ┃ [ close ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /council │ ⎿ agent-council: The council is reviewing the working diff; /counc │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ agent-council: reviewing…

Draws

Pane · Council
[ close ]
README

<img alt="agent-council: a council of independent code reviewers for Claude Code" src=".github/assets/banner-light.svg" width="100%">

ci codeql release license: MIT Claude Code ≥ 2.1.287 OpenSSF Scorecard

/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 in Claude Code: the Council pane runs codex, pi, devin and ocr in parallel on a working diff, then shows a summary of agreements, a disagreement over a pagination off-by-one, and a unique finding, with send to model, rerun and close buttons

┌ 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 ]                            │
└──────────────────────────────────────────────────────────────────┘

Why

  • One reviewer, human or model, misses things; another one misses different things.
  • Running several review CLIs by hand and reconciling their output is slow, so it gets skipped.
  • Pasting raw reviews into the session floods the model with duplicates and unverified claims.

Features

  • 👥 Parallel council: every installed member reviews the same diff at once; at least two are needed.
  • 🤝 One merged summary: agreements, disagreements, unique findings and notes, written by Claude or scored by TypeSafe's Jev.
  • ✋ You decide what the model sees: the summary stays in the pane until /council send.
  • 🔔 Opt-in auto-review that only notifies, after an idle answered turn.
  • ⏳ Limit-aware: a member with a future epoch in its limit file is skipped.
  • 🔒 Secret-like untracked files are never read or sent to Pi and Devin; Codex and OpenCodeReview read the tree themselves.

Install

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:

MemberHow it is run
codexcodex exec review --uncommitted --ephemeral (a question: codex exec review --ephemeral -)
pipi -p --no-session --no-tools <prompt>
devindevin --permission-mode auto --respect-workspace-trust false -p <prompt>
ocrocr 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 now agent-council (repository apolenkov/agent-council, formerly claude-council; marketplace agent-mods, formerly agent-watch and claude-mods). Reinstall it under the new name and remove the old council plugin. Your options keep their names but live under the new plugin id, so set them again. The command is still /council.

Usage

CommandWhat it does
/councilReview the working diff (git diff HEAD plus untracked text files)
/council <question>The same, with your question for every reviewer
/council sendSubmit the last summary to the model (same as the pane's button)
/council statusOpen the pane
/council cancelStop 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.

Auto-review

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.

Configuration

Set in /config.

OptionDefaultWhat it does
members(empty: all installed)Comma list choosing and ordering members, e.g. codex,ocr.
limitsDir~/.local/state/executor-limitsA file <dir>/<member> holding a future epoch (seconds) skips that member.
timeoutMin8A member running longer is stopped and marked failed.
summarizerclaudeclaude, or jev (TypeSafe scores, Claude writes).
summarizerModelsonnetThe 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.
systemOneUrlhttps://api.typesafe.aiThe 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.
jevThreshold0.3Findings Jev rates less likely than this to be real go to notes.
autoReviewoffnotify turns the auto-review on.
cooldownMin10The least time between two auto-reviews.

Limit files are only read, never written; a missing file means no limit.

The summarizers

  • claude: one $.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.
  • jev: TypeSafe's Jev decides, per finding, whether it is the same issue as an earlier one (a Choice), how likely it is a real defect (a Noul), and, per issue several members raised, whether they contradict each other. Requests hold at most 9 questions and 14,000 characters (the owner's calibration rule, not an API limit); at most 20 findings are scored, the rest stand alone. Claude then writes one line per issue. If System One fails, is refused, or a remote one has no key, Claude merges alone and a note says why.

Privacy

What leaves your machine:

  • To each member's own service: the reviewers run as your user, with their own accounts. Pi and Devin receive the diff in their prompt: `git

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.

  • To Anthropic, through the session's own client: the members' findings (paths, lines, titles, details) and your question, to write the summary.
  • To the System One API, only with 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.

  • To the session's model: nothing, until you run /council send or press the pane's button.

No telemetry. See SECURITY.md.

Development

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).

License

MIT. engine-types/claude-code.d.ts is © Anthropic PBC and not covered by the MIT license; see engine-types/NOTICE.md.

Source 24 files
hooks/register.tsx 205 lines
1import 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};
205
hooks/effects/command.ts 98 lines
1import 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};
98
hooks/effects/council.ts 306 lines
1import 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};
306
hooks/effects/host.ts 88 lines
1import 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}>;
88
hooks/model/background.ts 69 lines
1import { 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};
69
hooks/model/config.ts 89 lines
1import 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});
89
hooks/view/pane.tsx 129 lines
1import 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};
129
hooks/model/summary.ts 181 lines
1import 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");
181
types/index.d.ts 77 lines
1/** 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}
77
hooks/model/format.ts 77 lines
1import 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);
77
hooks/effects/detect.ts 76 lines
1import 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};
76
hooks/effects/diff.ts 81 lines
1import {
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