SLOPSHOPPER

council-of-elrond

A council of specialist reviewers that gates risky tool calls before they run. Rules decide the tier; model members give a second opinion; anything uncertain…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · council-of-elrond
│ ┃ The debate ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Write: /work/app/src/cache.ts { "content": │ council-of-elrond │ │ ┃ "// TODO: implement eviction later\nconst ● council-of-elrond: C│ Council: new here? Try /council shadow on │ │ ┃ store = new Map<string, unknown>()\n\nexport ⏺ Read(src/auth.ts) │ for the first days. Reviewers then log │ │ ┃ function remember(key: string, value: ⎿ Read 6 lines │ verdicts without refusing; rules, │ │ ┃ unknown) {\n console.log('here')\n ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ store.set(key, value)\n}\n" } ⎿ Added 2 lines, removed 1 line │ ┃ Legolas: ✗ no verdict ⏺ Edit(/work/app/src/auth.ts) │ ┃ Reason: malformed verdict: the reply is ⎿ Denied by council-of-elrond: This call was refused before │ ┃ not a single JSON object │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Write: /work/app/src/audit.ts { "content": │ ┃ "// TODO: implement persistence\nexport ✻ Worked for 42s · done 4:20 PM │ ┃ async function audit(event: string, subject: │ ┃ string) {\n console.log('audit', event, › /council │ ┃ subject)\n // ... rest of the │ ┃ implementation\n}\n" } │ ┃ Legolas: ✗ no verdict │ ┃ Reason: malformed verdict: the reply is │ ┃ not a single JSON object │ ┃ │ ┃ Edit: /work/app/src/auth.ts { "old_string": │ ┃ "if (!claims) throw new Error('invalid │ ┃ token')", "new_string": "if (!claims || │ ┃ claims.exp < Date.now() / 1000) throw new │ ┃ Error('invalid token')\n await ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · The debate
Write: /work/app/src/cache.ts { "content": "// TODO: implement eviction later\nconst store = new Map<string, unknown>()\n\nexport function remember(key: string, value: unknown) {\n console.log('here')\n store.set(key, value)\n}\n" } Legolas: ✗ no verdict Reason: malformed verdict: the reply is not a single JSON object Write: /work/app/src/audit.ts { "content": "// TODO: implement persistence\nexport async function audit(event: string, subject: string) {\n console.log('audit', event, subject)\n // ... rest of the implementation\n}\n" } Legolas: ✗ no verdict Reason: malformed verdict: the reply is not a single JSON object Edit: /work/app/src/auth.ts { "old_string": "if (!claims) throw new Error('invalid token')", "new_string": "if (!claims || claims.exp < Date.now() / 1000) throw new Error('invalid token')\n await audit('refresh', claims.sub)" } Legolas: ✗ no verdict Reason: malformed verdict: the reply is not a single JSON object Wipes since your last prompt: 5 over 5 operations, 0 locked out Threat meter No blocks yet.
Pane · Council
Mode: enforcing Members: Gandalf [gandalf]: on, model sonnet (built-in); approved 0, revised 0, blocked 0, no verdict 0 Legolas [legolas]: on, model sonnet (built-in); approved 0, revised 0, blocked 0, no verdict 3 Aragorn [aragorn]: on, model opus (built-in); approved 0, revised 0, blocked 0, no verdict 0 the Council of Elrond [council], for big operations: on, model opus (built-in), members in parallel; approved 0, revised 0, blocked 0 Gimli, for big operations: on, 0 commands configured; passed 0, failed 0 Gollum: on Galadriel: on Since your last prompt: 5 wipes over 5 operations, 0 locked out Review tokens this session: 39 of 1500000 Median review time: 0.0 s over 3 reviews
README

council-of-elrond

A Claude Code mod that gates risky tool calls before they run. Rules sort every call into a tier. Calls that need a second opinion go to a model reviewer, and anything uncertain comes to you.

The rules tier is the safety boundary. The model reviewers are a second opinion: they can catch what a pattern misses, but they never widen what the rules allow. This is not a sandbox or a security product (see What it does not protect against).

Status: all six stages done, with the final deliverables (SPEC §21–22), after the 0.5.0 hardening. Available now: the rules; three model reviewers (destructive operations, diffs, and git and databases) with routing between them; the full council with your project's own checks for big operations; the secrets scan, read-only previews, review rounds and lockout, the approve cache, escalation to you, allow rules offered after "allow once", /council and /council report, shadow mode and bypass, fail-closed handling and the audit log, the theme (with a plain mode), and the debate pane, the council check band, the wipe counter, the threat meter and the epic drop. The 0.5.0 release closed the rules-tier and pipeline findings of the Stage 5 review (docs/REVIEW-2026-10.md). Headless live checks ran on Claude Code 2.1.294; the interactive screens (the dialogs, the pane, the band, the mode label) still await a check in a terminal. See ROADMAP.md for what's left, SPEC.md for the original spec and DESIGN.md for how it works.

Your first days: shadow mode

Turn on shadow mode while you tune the rules: /council shadow on for one session, or "Shadow mode" in /config. Reviewers still run and log their verdicts to the audit log, but they never refuse. The block tier, the ask tier, protected paths and the secrets scan still enforce. The label council: shadow shows by the prompt while it is on. Until the audit log has a line, a one-time toast at session start suggests it.

Loading it

Requires Claude Code 2.1.287 or later (mods are on by default).

  • One session: claude --plugin-dir ./mods/council-of-elrond
  • For good: claude plugin marketplace add Deluha/council-of-elrond, then claude plugin install council-of-elrond@council-of-elrond. Other ways, updating and removing: docs/INSTALL.md.
  • Where no flag can be given (desktop app, SDK host): set CLAUDE_CODE_PLUGIN_DIRS to the folder's absolute path in the environment or in ~/.claude/settings.json under env.

Check it loaded: /plugin shows the mods line, and claude plugin validate ./mods/council-of-elrond lists its hooks.

Tiers

| Tier | What happens | | :- | :- | | allow | Passes untouched, with no model call. Anything no rule matches is allowed. | | review | A model reviewer gives a verdict: approve runs it, revise or block refuses it. | | ask | Comes straight to you. | | block | Refused, with the rule's reason. No model call, no question. |

Compound commands (&&, ;, |, $(...), backticks, subshells, sh -c '...', eval, and heredocs fed to a shell) are split, and the strictest part decides.

Shipped defaults:

  • Block:
  • a recursive delete of /, your home directory, the project root, or anything outside the project;
  • a force push or delete of a protected branch;
  • DROP or TRUNCATE against a production-looking target;
  • raw disk writes.
  • Ask:
  • sudo, doas, su;
  • anything touching a protected path: .env*, CI config, migrations folders, .git/, .claude/council-of-elrond/, .claude/settings*.json.
  • Review:
  • file writes and edits;
  • deletes, moves, in-place edits, writes through a redirect;
  • git operations that change a remote or history, or discard work;
  • database clients and migrations;
  • infrastructure changes, network writes, publishing;
  • running scripts or inline code;
  • Bash with the sandbox disabled;
  • MCP tools whose names say they change something.

Approve never pre-approves. An approved call still goes through Claude Code's normal permission check and prompt. The mod never takes part in the permission decision.

Subagents are gated too. A subagent's tool calls go through the same pipeline as the main conversation's, and their audit lines carry the subagent's agentId (checked live on Claude Code 2.1.294, with the subagent in the background and in the foreground).

Reviewers

A review-tier call goes to exactly one model reviewer, the one its rule names (member and profile in a rule), unless it is a big operation (see The full council). Each reviewer has a short checklist of its own and sees only what that checklist needs.

| Reviewer | Takes | Sees | Checks | | :- | :- | :- | :- | | Destructive operations (Gandalf, gandalf) | deletes, moves, overwrites, scripts, infrastructure, anything no rule names | the call, the preview, scripts Claude wrote this session | reversibility, width, reach, backups, intent | | Diffs (Legolas, legolas) | Write, Edit, NotebookEdit | a diff built from the call against the file as it stands (cut to the diff line limit), the file's git status | matches the request, scope creep, weakened tests, disabled checks, dependency/CI/config changes, obvious breakage. No style review. | | Git (Aragorn, aragorn, profile git) | push, merge, rebase, reset, tag, amend and other history changes | the command, the branch, remote and commits it would send or bring in, recent history with remote branches marked, your protected branches | protected target, force, rewriting pushed history, unrelated commits | | Database (Aragorn, aragorn, profile database) | SQL clients, migrations, .sql files | the command, SQL from -c/-e/heredocs, .sql files it names inside the project, or a SQL file's diff; which production patterns match | rollback path, destructive DDL, UPDATE/DELETE without WHERE, production-looking target, transactions, long locks |

Gandalf is the fallback. A call goes to Gandalf when its rule names nobody, when the named reviewer is switched off, when a compound command's parts name different reviewers (one specialist would judge only its own part), or when a rule names the diff reviewer for something with no file. If Gandalf is off too, the call comes to you. /council test "<command>" shows who would review and why.

The full council

Big operations go to every enabled reviewer at once instead of one. By default they are a git push, a merge into a protected branch (git merge while a protected branch is checked out, or gh pr merge) and a database migration.

  • Who sits: every enabled reviewer with something of the call to review.
  • The destructive-operations reviewer always sits.
  • The git and database reviewer sits once for each profile the call touches, so git push && psql … gets both.
  • The diff reviewer sits for a file change, and for a push or merge. There it reviews the changes the push would send or the merge would bring in, read by one fixed, read-only git diff. When the remote does not have the branch yet, the diff is against the remote's default branch (<remote>/HEAD), the changes since the branch point.
  • One model: every member sits on the council's model (opus by default), not its own.
  • One deadline: every member runs under one shared deadline from that model (Opus 45 s, or "Review deadline" in /config). By default they run in parallel. "Full council one at a time" asks them in turn instead, each getting what is left of the deadline, and stops at the first block.
  • Strictest wins: block, then revise, then approve. Every objection is labelled with the reviewer who raised it. A reviewer that errors, times out or answers malformed counts as a block from that reviewer.
  • Blocked only for want of verdicts (nobody actually objected, every check passed): the call comes to you, as a single reviewer's failure does.
  • Any real objection or failed check: Claude gets the refusal.
  • Independent: reviewers never see each other's verdicts.

Project checks (Gimli, gimli) run alongside: your tests, lint and typecheck, from gimli.commands in rules.json:

{
  "schemaVersion": 1,
  "gimli": {
    "commands": [
      { "name": "tests", "argv": ["npm", "test"], "timeoutMs": 300000 },
      { "name": "lint", "argv": ["npx", "eslint", "."] }
    ]
  },
  "bigOperations": ["publish", "/^kubectl\\s+apply(\\s|$)/"]
}
  • Only these commands run. They run by argument vector with no shell, in the project root, and nothing comes from Claude or the call. rules.json is a protected path, so Claude can't add a command without asking you.
  • Pass or fail by exit code. A failure, a timeout or a command that can't start is a block. Claude gets the check's last 20 lines (redacted) so it can fix the cause; the audit log keeps none of them.
  • Each command has its own timeout: 120 s unless timeoutMs says otherwise, at most 600 s. It runs outside the reviewers' deadline, so a council can take as long as the longer of the two.
  • Stopping early: Esc ends them. Once a reviewer has blocked, a check still running is stopped, since it can only add a block.
  • Make them non-interactive. No watch mode or prompts; standard input is closed. For example, ["env", "CI=1", "npm", "test"].

Changing what counts as big: bigOperations in rules.json adds rule ids, /regex/flags matched against each shell part, or merge-to-protected; it only adds to the shipped list. Only calls the rules already send to review can be big operations: a blocked call stays refused, and an ask-tier call still comes to you. To send big operations to one reviewer like any other call, turn off "Full council for big operations" in /config. /council test "<command>" shows when a call would go to the full council, who would sit and which checks would run.

Editing rules

Put project overrides in .claude/council-of-elrond/rules.json:

{
  "schemaVersion": 1,
  "rules": [
    {
      "id": "allow-clean-build",
      "tier": "allow",
      "tools": ["Bash"],
      "command": "^rm -rf (build|dist)$",
      "reason": "Build output is disposable here."
    },
    {
      "id": "no-terraform-destroy",
      "tier": "block",
      "tools": ["Bash"],
      "command": "^terraform destroy",
      "reason": "Infrastructure is torn down by CI only."
    },
    {
      "id": "review-mcp-db",
      "tier": "review",
      "tools": ["mcp__db__*"],
      "reason": "Database MCP calls."
    }
  ],
  "disableRules": ["shell-move"],
  "protectedPaths": ["secrets/**"],
  "protectedBranches": ["staging"],
  "productionPatterns": ["\\bprd-"],
  "models": { "gandalf": "opus" }
}

Rule fields:

  • tools: exact names, globs (mcp__*) or /regex/flags.
  • command: a regex matched against each shell part, with quotes removed and wrappers such as sudo, env and nice stripped.
  • path: a regex matched against the file path relative to the project root.
  • input: a regex matched against the call's JSON.
  • member and profile: who reviews (gandalf, legolas, aragorn with git or database).

How overrides combine with the shipped rules:

  • Overrides win. A matching project rule decides before the shipped rules.
  • Two things can't be lowered. No project rule goes below a shipped block rule or a protected path, and block rules can't be disabled.
  • Lists only grow. Lists add to the shipped ones.

A broken file is ignored whole. The shipped rules stay in force, and the errors are listed by field in the transcript.

The overrides file is itself a protected path, so Claude can't edit it without asking you.

Models

| Reviewer | Built-in default | | :- | :- | | Destructive operations (Gandalf) | sonnet | | Diffs (Legolas) | sonnet | | Git and database (Aragorn, both profiles) | opus | | Full council (every member, for big operations) | opus |

Aliases resolve to the newest model of that family your Claude Code build knows. A reviewer's model comes from the first of these that sets one:

  1. A session switch (/council model gandalf opus). Each switch is checked with one tiny request first.
  2. Its row in /config ("Gandalf model", "Legolas model", "Aragorn model", "Full council model": default, sonnet, opus, fable or haiku).
  3. models in the project rules file, which also takes full model IDs.
  4. The built-in default.

The token cap, effort and deadline follow the model automatically. "Review deadline" in /config overrides the deadline.

Options (/config)

| Option | Default | | :- | :- | | Gandalf enabled | on (off: calls only it would review come to you) | | Gandalf model | default | | Legolas enabled | on (off: its reviews go to Gandalf) | | Legolas model | default | | Aragorn enabled | on (off: its reviews go to Gandalf) | | Aragorn model | default | | Full council for big operations | on (off: big operations go to one reviewer) | | Full council model | default | | Full council one at a time | off (members in parallel) | | Project checks enabled | on (runs gimli.commands for big operations) | | Review deadline (seconds) | 0 (from the model) | | Session token budget | 1,500,000 (spent: reviews come to you) | | Audit log path | .claude/council-of-elrond/audit/audit.jsonl | | Audit log size before rotation (KB) | 1024, three files kept | | Shadow mode | off | | Plain mode | off (themed). On: no theme text anywhere | | Secrets scan enabled | on | | Read-only preview enabled | on | | Preview line limit | 80 | | Diff line limit | 200 | | Tool errors count as failed attempts | on |

Modes

| Mode | What it does | How to set it | | :- | :- | :- | | Enforcing (the default) | Every tier applies. A reviewer's revise or block refuses the call; a reviewer that fails, or none being on, brings it to you. | Nothing to set. | | Shadow | Reviewers and the full council (its project checks included) log their verdicts and never refuse; a failed review passes, logged. The block tier, the ask tier, protected paths, the secrets scan and lockouts still enforce. | /council shadow on for the session, or "Shadow mode" in /config. Label: council: shadow. | | Bypass (themed: Leeroy mode) | Every gated call passes unreviewed, block-tier calls and the secrets scan included, and each is logged. | /council off, for this session only and never saved; /council on ends it. Label: council: bypass (council: Leeroy mode themed). | | Plain | Every string in its plain variant, with no theme text anywhere; behaviour is identical. It combines with any mode above. | "Plain mode" in /config. |

Theme and plain mode

By default the council speaks in its theme. The "Plain mode" option in /config switches every theme word off; behaviour is identical in both modes.

What is themed, for you only:

  • The names: /council, the dialog and the report say Gandalf, Legolas, Aragorn (git or databases), Gimli, Gollum, Galadriel, Elrond and the Council of Elrond where plain mode says the role ("the destructive-operations reviewer", "the full council").
  • The escalation dialog is a loot roll: headed "Loot roll", with the options "Need: allow once", "Pass: keep blocked" and, for one possible secret, "Greed: add to allowlist".
  • Bypass is Leeroy mode (council: Leeroy mode by the prompt, and in /council).
  • /council counts wipes where plain mode says "refused or failed attempts".
  • Each member's one line of flavour in the debate pane, the pane's title ("The debate"), the council check band's header ("Ready check"), the wipe counter, the threat meter and the epic drop (see Debate pane and council check).

What is never themed: what Claude reads. Refusals, the full council's reasons and the rule reason written to your rules file are plain in both modes, and name the role, not the character. Member ids you type (/council model gandalf opus) are config keys and stay as written. The dialogs that confirm exact data you agree to write (the allowlist entry, an allow rule) keep their plain text.

Each theme term and its plain name are in the Glossary.

"Council" stays in plain mode: it is the product's name.

Commands

Everything /council prints is for you only: it draws in a pane where a surface draws one, else as dim transcript lines. Claude never reads it. In a plain claude -p "/council" run nothing prints; use --output-format stream-json, where the lines arrive as ui_log messages.

| Command | What it does | | :- | :- | | /council | Status: mode, each member with its state, model and verdict counts, the full council and the project checks, failed attempts since your last prompt, tokens spent, median review time. | | /council off, /council on | Bypass for this session: gated calls pass unreviewed and are logged. Never persisted. The label council: bypass (council: Leeroy mode when themed) shows by the prompt. | | /council shadow on, /council shadow off | Shadow mode for this session, over the /config setting. | | /council log [n] | The last n gated calls (default 10) from the audit log. | | /council rules | Every effective rule with its source (shipped or project), and the lists. | | /council test "<command>" | Which tier, rule, reviewer (and profile, and why it fell back to Gandalf if it did) and operation key a shell command would get. For a big operation it shows the full council's seats, model and checks. Runs nothing, so a merge is assumed to land on a protected branch. | | /council model [<member> <model> [--save]] | Lists each slot's model and where it came from, or switches one for this session (default clears the switch). --save also writes the /config row when the model is one of its picker values. | | /council reload | Reads rules.json again. | | /council report | A summary of the audit log, rotated files included (see below). | | /council debate | Opens the debate pane at any width (where nothing draws, prints its rows as transcript lines). |

/council report

Read the report after a few days in shadow mode to tune the rules. It has four parts:

  • Most refused: the rules behind the most refusals, each with who refused (the rules, a reviewer, you, the secrets scan, a lockout), and the operations refused most.
  • Stopped, then allowed by you: calls the council stopped and you then allowed once (or allowlisted), by rule, with examples. These are the false-positive candidates. It also lists the allow rules you added that way.
  • Shadow verdicts that would have refused: per reviewer, how many blocks and revises it logged in shadow mode, and under which rules.
  • Cost per reviewer: tokens per reviewer, with its share of full councils (taken from each member's own tokens in the council), the full council's sittings, and the median review time. Review times are logged from Stage 5 on; older lines count for tokens only.

A line that doesn't parse is skipped and counted; a log file that can't be read is named and left out.

Debate pane and council check

Three things show you a review as it happens, none of them read by Claude:

  • The debate pane (The debate themed, Council review plain) lists your newest reviews, three at most, newest first. Each shows the call, then one row per reviewer: its name, a symbol with a word (✓ approve, ✗ revise, ✗ block, ✗ no verdict, … reviewing, – sat out), its reason and its safer alternative, and, in themed mode, one line of flavour. A full council also shows each project check (✓ passed, ✗ failed, ✗ timed out, ✗ could not start, – stopped, … running) and a final verdict row. Below the reviews are two session-wide rows: the wipe counter (refused or failed attempts since your last prompt, over how many operations, and how many are locked out; "Wipes" themed) and the threat meter (each reviewer that has blocked, most blocks first, as a bar and the number: Gandalf ███ 3 blocks; plain mode says the destructive-operations reviewer ███ 3 blocks). Nothing depends on colour: every symbol has its word.
  • The council check band sits above the prompt only while a full council sits: a header ("Ready check" themed, "Full council review" plain), then a row per reviewer and per check as it answers. It leaves when the council is done, and gives way to a survey. A single reviewer's review never draws in the band.
  • The epic drop is for themed mode only. When a push or merge passed the full council, ran, and was not a repeat of an approved call, "✦ Legendary commit acquired" shows in the band for eight seconds, with a toast of the same words. Plain mode has none of it: no toast, no row.

The pane has its own tab (council-debate), beside the /council output pane. It opens on its own once per session, the first time a model review or a full council starts; Claude Code keeps an unasked pane hidden below 144 terminal columns, so on a narrow terminal it stays hidden. /council debate opens it at any width, and is the way back after you close it. It follows the agent in view: with a subagent's transcript on screen, it shows that subagent's reviews.

What draws where: the pane on every surface Claude Code draws panes for (terminal, desktop, VS Code, mobile; whether VS Code paints it is for a live check to say), and the band on the terminal and desktop only. Where a surface draws nothing (a claude -p run, the SDK, cloud), nothing is drawn and nothing is lost: /council debate prints the rows as transcript lines, and the audit log and /council keep the full record. The mod does not reach a cloud session from repository settings; see Cloud sessions.

Secrets scan

Every call is scanned for secrets in what it would write or run: the shell command (heredocs included), Write content, an Edit's new text, a notebook cell, an MCP call's input. A high-confidence finding refuses the call whatever its tier, allowed calls included. A low-confidence finding comes to you only on a gated call: an allowed call never gets a question.

  • High confidence (private keys, AWS, GitHub, Anthropic, OpenAI, Slack, Google and Stripe keys, passwords in connection strings): refused without asking you, on an allowed call too (it is logged, with tier allow). Claude is told to remove the secret.
  • Low confidence (password=…-style assignments, long random-looking tokens): comes to you with a redacted snippet. Allow once, keep blocked, type an instruction, or add to allowlist. The allowlist asks a second time, showing the exact entry: a sha256: fingerprint of the secret, never the secret. Only then is it written to rules.json under gollum.allowlist.

Add your own patterns in rules.json:

{
  "schemaVersion": 1,
  "gollum": {
    "patterns": [{ "id": "acme-key", "level": "high", "regex": "ACME-[0-9]{8}", "label": "Acme key" }],
    "allowlist": ["sha256:0123456789abcdef"]
  }
}

Allowlist entries are exact strings or fingerprints, never regexes. Your patterns are also used to redact the dialog, reviewer prompts and the audit log.

Read-only preview

Before a review, the mod runs a few fixed, read-only inspections and shows the result to the reviewer and to you. The commands come from a table in the mod; the call's targets are passed only as data, and the proposed command never runs.

  • Deletes (rm, find -delete): what each target is, and a folder's entries (no process).
  • git push: the current branch, the remote's URL (redacted) and the commits it would send. When the remote does not have the branch yet, the commits are shown against the remote's default branch (<remote>/HEAD).
  • git merge: the current branch and the commits it would
Source 34 files
hooks/register.ts 1636 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, ProcessRunResult, ProcessSpawnResult, Register, RenderElement, ToolCallResult } from 'claude-code'
3
4import type { CouncilPanel } from '../types'
5import { appendPlan, AUDIT_GITIGNORE, auditLine, fingerprintOf, ROTATED_FILES } from './audit.js'
6import type { AuditRecord, Decision } from './audit.js'
7import { OVERRIDES_PATH } from './config/defaults.js'
8import { loadConfig, MODEL_ID } from './config/schema.js'
9import type { LoadedConfig } from './config/schema.js'
10import { MODEL_SLOTS } from './config/types.js'
11import type { GimliCommand, MemberName, ModelSlot } from './config/types.js'
12import { withAllowlistEntry, withRule } from './config/write.js'
13import type { FileEdit } from './config/write.js'
14import { isSlot, logOutput, modelsOutput, parseCouncil, rulesOutput, statusOutput, testOutput } from './elrond/commands.js'
15import { combine, hasRealBlock } from './elrond/combine.js'
16import type { Voice } from './elrond/combine.js'
17import { bigOperationOf, councilSeats, needsCurrentBranch, rangeOf } from './elrond/council.js'
18import type { Big, CouncilSeat } from './elrond/council.js'
19import type { CouncilCommand, Output } from './elrond/commands.js'
20import { interpretAnswer, interpretRejection, optionsOf, questionText } from './elrond/escalation.js'
21import type { Answer, MemberOpinion, Unanswered } from './elrond/escalation.js'
22import { deadlineFor, profileOf, resolveModel } from './elrond/models.js'
23import type { ModelChoice } from './elrond/models.js'
24import {
25  cacheApprove,
26  isOutOfRounds,
27  isWipeOutcome,
28  lockoutOf,
29  noteRound,
30  noteWipe,
31  operationOf,
32  outcomeOf,
33  resetOperation,
34  resetRounds,
35  roundsLeft,
36} from './elrond/operations.js'
37import type { WipePolicy } from './elrond/operations.js'
38import { refusalText } from './elrond/refusal.js'
39import { reportOutput } from './elrond/report.js'
40import { bandRows, debateRows, voiceNote } from './elrond/view.js'
41import type { Row } from './elrond/view.js'
42import { ruleJson, suggestRule } from './elrond/suggest.js'
43import type { Refusal } from './elrond/refusal.js'
44import { route } from './elrond/routing.js'
45import type { Enabled, Seat } from './elrond/routing.js'
46import { ARAGORN_LIMITS, productionHits, sqlOf } from './members/aragorn.js'
47import type { SqlPiece } from './members/aragorn.js'
48import { requestOf, whoOf } from './members/brief.js'
49import type { Brief } from './members/brief.js'
50import { formatPreview, INSPECTION_TIMEOUT_MS, MAX_LISTED, planPreview, rangeDiffInspection } from './members/galadriel.js'
51import type { Inspection, InspectionResult } from './members/galadriel.js'
52import { GANDALF_LIMITS } from './members/gandalf.js'
53import { isGimliBlock, keepTail, statusOf, tailOf } from './members/gimli.js'
54import type { GimliRun } from './members/gimli.js'
55import { patternsWith, scanCall } from './members/gollum.js'
56import { callDiff, LEGOLAS_LIMITS } from './members/legolas.js'
57import type { CallDiff, Current } from './members/legolas.js'
58import type { GollumFinding } from './members/gollum.js'
59import { newNonce, parseVerdict, truncate } from './members/shared.js'
60import type { Verdict } from './members/shared.js'
61import { redact } from './redact.js'
62import type { SecretPattern } from './redact.js'
63import { classify, FILE_PATH_FIELDS, SHELL_TOOLS } from './rules/classify.js'
64import type { Call, Classification, ClassifyContext } from './rules/classify.js'
65import { isInside, relativeTo, resolve } from './rules/paths.js'
66import {
67  addReviewTime,
68  addTokens,
69  clearCache,
70  clearEpic,
71  closeDebate,
72  count,
73  declineRule,
74  INITIAL_SESSION,
75  isShadow,
76  markDebateOpened,
77  noteCheck,
78  noteVoice,
79  noteWritten,
80  openDebate,
81  resetForPrompt,
82  sessionOf,
83  tidyCall,
84  tidyReason,
85  withBypass,
86  withEpic,
87  withSessionModel,
88  withShadow,
89} from './state.js'
90import type { CouncilDebate, CouncilSession } from '../types'
91import { currentMode, setMode, text } from './strings.js'
92import type { StringKey } from './strings.js'
93
94/**
95 * Elrond, the chair: the one `tool.call` hook every call passes through.
96 * Rules classify it; allow passes untouched; block refuses; a locked-out
97 * operation refuses; the secrets scan refuses or asks; ask goes to the user;
98 * review gets a read-only preview and goes to a model member. Every failure
99 * refuses or asks: the hook never lets a gated call through on an error.
100 *
101 * Also `/council`, and the mode label by the prompt. The only file that
102 * touches `$`; everything it decides with is a pure function in a sibling.
103 */
104
105const session = atom({ plugin: 'council-of-elrond', key: 'session' } as const, INITIAL_SESSION)
106
107const panel = atom({ plugin: 'council-of-elrond', key: 'panel' } as const, { title: '', lines: [] } as CouncilPanel)
108
109/** Margin kept on the hook's own 10 s budget before any pass-through. */
110const BUDGET_GUARD_MS = 1_000
111
112/** Prompt origins that are the user's own and reset the per-prompt state. */
113const HUMAN_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
114
115const MAX_SCRIPTS = 3
116
117const COMMAND = 'council'
118const PANE_ID = 'council'
119
120/** The debate pane: its own id, so it sits beside the `/council` output as a tab. */
121const DEBATE_PANE_ID = 'council-debate'
122
123/** Rows the debate pane asks for where it is placed inline. */
124const DEBATE_PANE_ROWS = 12
125
126/** How long the epic drop row and toast last. */
127const EPIC_MS = 8_000
128
129/** The `/config` row a `--save` writes, per slot that has one. */
130const CONFIG_ROWS: Readonly<Partial<Record<ModelSlot, string>>> = {
131  gandalf: 'council-of-elrond.gandalfModel',
132  legolas: 'council-of-elrond.legolasModel',
133  aragorn: 'council-of-elrond.aragornModel',
134  council: 'council-of-elrond.councilModel',
135}
136
137/** What the `/config` model picker offers; anything else is session-only. */
138const PICKER_OPTIONS: readonly string[] = ['default', 'sonnet', 'opus', 'fable', 'haiku']
139
140/** Git for previews: no locks taken, no prompts, plain output. */
141const GIT_ENV: Record<string, string> = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', LC_ALL: 'C' }
142
143const PROBE_TIMEOUT_MS = 20_000
144
145type Settings = {
146  gandalfEnabled: boolean
147  gandalfModel: string
148  legolasEnabled: boolean
149  legolasModel: string
150  aragornEnabled: boolean
151  aragornModel: string
152  councilEnabled: boolean
153  councilModel: string
154  councilSequential: boolean
155  gimliEnabled: boolean
156  diffLines: number
157  reviewDeadlineSeconds: number
158  tokenBudget: number
159  auditLogPath: string
160  auditMaxKb: number
161  shadowMode: boolean
162  toolErrorsAreWipes: boolean
163  previewLines: number
164  gollumEnabled: boolean
165  galadrielEnabled: boolean
166  plainMode: boolean
167}
168
169const modelSetting = (value: unknown): string => (typeof value === 'string' ? value : 'default')
170
171const settingsOf = (options: PluginOptions): Settings => ({
172  gandalfEnabled: options.gandalfEnabled !== false,
173  gandalfModel: modelSetting(options.gandalfModel),
174  legolasEnabled: options.legolasEnabled !== false,
175  legolasModel: modelSetting(options.legolasModel),
176  aragornEnabled: options.aragornEnabled !== false,
177  aragornModel: modelSetting(options.aragornModel),
178  councilEnabled: options.councilEnabled !== false,
179  councilModel: modelSetting(options.councilModel),
180  councilSequential: options.councilSequential === true,
181  gimliEnabled: options.gimliEnabled !== false,
182  diffLines: typeof options.diffLines === 'number' ? options.diffLines : 200,
183  reviewDeadlineSeconds: typeof options.reviewDeadlineSeconds === 'number' ? options.reviewDeadlineSeconds : 0,
184  tokenBudget: typeof options.tokenBudget === 'number' ? options.tokenBudget : 1_500_000,
185  auditLogPath:
186    typeof options.auditLogPath === 'string' && options.auditLogPath !== ''
187      ? options.auditLogPath
188      : '.claude/council-of-elrond/audit/audit.jsonl',
189  auditMaxKb: typeof options.auditMaxKb === 'number' ? options.auditMaxKb : 1024,
190  shadowMode: options.shadowMode === true,
191  toolErrorsAreWipes: options.toolErrorsAreWipes !== false,
192  previewLines: typeof options.previewLines === 'number' ? options.previewLines : 80,
193  gollumEnabled: options.gollumEnabled !== false,
194  galadrielEnabled: options.galadrielEnabled !== false,
195  plainMode: options.plainMode === true,
196})
197
198type Context = {
199  loaded: LoadedConfig
200  root: string
201  /** The root with symbolic links resolved, to map resolved paths back under `root`. */
202  realRoot: string
203  home?: string
204  /** Shipped and configured secret patterns: the scan's, and every redaction's. */
205  patterns: readonly SecretPattern[]
206}
207
208type Review =
209  | { ok: true; verdict: Verdict; model: string; tokens: number }
210  | { ok: false; problem: string; model: string; tokens: number }
211
212// Module state: starts over on every load, as the module does.
213let settings: Settings = settingsOf({})
214let context: Promise<Context> | undefined
215let auditQueue: Promise<void> = Promise.resolve()
216let isGitignoreChecked = false
217const warned = new Set<string>()
218
219const wipePolicy = (): WipePolicy => ({ toolErrors: settings.toolErrorsAreWipes })
220
221const enabledMembers = (): Enabled => ({
222  gandalf: settings.gandalfEnabled,
223  legolas: settings.legolasEnabled,
224  aragorn: settings.aragornEnabled,
225})
226
227/** The /config row's model for a slot. */
228const settingsModel = (slot: ModelSlot): string => {
229  switch (slot) {
230    case 'gandalf':
231      return settings.gandalfModel
232    case 'legolas':
233      return settings.legolasModel
234    case 'aragorn':
235      return settings.aragornModel
236    case 'council':
237      return settings.councilModel
238  }
239}
240
241function warnOnce($: EngineInterface, key: string, line: string, toast?: string): void {
242  if (warned.has(key)) return
243  warned.add(key)
244  $.ui.log(line)
245  if (toast !== undefined) $.ui.toast(toast, { timeoutMs: 8_000 })
246}
247
248async function loadContext($: EngineInterface): Promise<Context> {
249  const root = await $.session.root()
250  const realRoot = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
251  const home = await $.env.get('HOME')
252  const path = `${root}/${OVERRIDES_PATH}`
253  let fileText: string | undefined
254  let readProblem: string | undefined
255  if (await $.fs.exists(path)) {
256    fileText = await $.fs.read(path).catch((error: unknown) => {
257      readProblem = `(file): could not be read: ${error instanceof Error ? error.message : String(error)}`
258      return undefined
259    })
260  }
261  const loaded = readProblem !== undefined ? { ...loadConfig(undefined), errors: [readProblem] } : loadConfig(fileText)
262  if (loaded.errors.length > 0) {
263    warnOnce(
264      $,
265      'config',
266      text('notice.configBroken', { path: OVERRIDES_PATH, errors: loaded.errors.join('; ') }),
267      text('notice.configBrokenShort'),
268    )
269  }
270  const patterns = patternsWith(loaded.compiled.config.gollum.patterns)
271  return { loaded, root, realRoot, patterns, ...(home !== undefined && { home }) }
272}
273
274async function contextOf($: EngineInterface): Promise<Context> {
275  if (context === undefined) {
276    context = loadContext($).catch((error: unknown) => {
277      context = undefined
278      throw error
279    })
280  }
281  return context
282}
283
284/** Reads the rules again (decision 9: after the mod's own writes, and on `/council reload`). */
285async function reloadContext($: EngineInterface): Promise<Context> {
286  context = undefined
287  warned.delete('config')
288  const fresh = await contextOf($)
289  await update($, session, value => clearCache(sessionOf(value)))
290  return fresh
291}
292
293/** Where a file tool's path really lands, symbolic links resolved, mapped under the root. */
294async function realPathOf($: EngineInterface, call: Call, cwd: string, ctx: Context): Promise<string | undefined> {
295  const field = FILE_PATH_FIELDS[call.tool]
296  const given = field === undefined ? undefined : call.input[field]
297  if (typeof given !== 'string') return undefined
298  const absolute = resolve(given, cwd, ctx.home)
299  const cut = absolute.lastIndexOf('/')
300  const own = await $.fs.stat(absolute, { resolve: true }).catch(() => undefined)
301  let real = own?.realPath
302  if (real === undefined) {
303    const folder = await $.fs.stat(absolute.slice(0, cut) || '/', { resolve: true }).catch(() => undefined)
304    if (folder?.realPath !== undefined) real = `${folder.realPath.replace(/\/$/, '')}/${absolute.slice(cut + 1)}`
305  }
306  if (real === undefined) return undefined
307  return ctx.realRoot !== ctx.root && (real === ctx.realRoot || isInside(real, ctx.realRoot))
308    ? `${ctx.root}${real.slice(ctx.realRoot.length)}`
309    : real
310}
311
312/** Content of scripts the call runs that Claude wrote this session, redacted. */
313async function scriptsOf(
314  $: EngineInterface,
315  classification: Classification,
316  cwd: string,
317  written: readonly string[],
318  ctx: Context,
319): Promise<{ path: string; content: string }[]> {
320  const scripts: { path: string; content: string }[] = []
321  for (const word of classification.scriptPaths) {
322    if (scripts.length >= MAX_SCRIPTS) break
323    const path = resolve(word, cwd, ctx.home)
324    if (!written.includes(path)) continue
325    const content = await $.fs.read(path).catch(() => undefined)
326    if (typeof content === 'string') {
327      scripts.push({ path, content: redact(truncate(content, GANDALF_LIMITS.scriptLines, GANDALF_LIMITS.scriptChars), ctx.patterns) })
328    }
329  }
330  return scripts
331}
332
333/**
334 * Runs one git inspection from the table: its argv, then, only if that exits
335 * non-zero or throws and the table gives one, its fixed `orElse` argv, once.
336 * The fallback's result (and label) stands only if it succeeds; otherwise the
337 * first attempt's outcome is kept, as if there were no fallback.
338 */
339async function runGitInspection(
340  $: EngineInterface,
341  step: { label: string; argv: readonly string[]; orElse?: { label: string; argv: readonly string[] } },
342  ctx: Context,
343): Promise<{ label: string; run: ProcessRunResult }> {
344  const attempt = (argv: readonly string[]): Promise<ProcessRunResult> => $.process.run(argv, { cwd: ctx.root, env: GIT_ENV, timeoutMs: INSPECTION_TIMEOUT_MS })
345  let first: ProcessRunResult | undefined
346  let failure: unknown
347  try {
348    first = await attempt(step.argv)
349    if (first.exitCode === 0 || step.orElse === undefined) return { label: step.label, run: first }
350  } catch (error) {
351    if (step.orElse === undefined) throw error
352    failure = error
353  }
354  try {
355    const second = await attempt(step.orElse.argv)
356    if (second.exitCode === 0) return { label: step.orElse.label, run: second }
357  } catch {
358    // The fallback failing leaves the first attempt's outcome.
359  }
360  if (first === undefined) throw failure
361  return { label: step.label, run: first }
362}
363
364/**
365 * Runs Galadriel's inspections: only what the table planned, read-only, each
366 * on its own timeout. A failure is no preview, never a decision.
367 */
368async function previewOf($: EngineInterface, plan: readonly Inspection[], ctx: Context): Promise<string | undefined> {
369  const results: InspectionResult[] = []
370  for (const step of plan) {
371    try {
372      if (step.kind === 'path') {
373        const stat = await $.fs.stat(step.path).catch(() => undefined)
374        if (stat === undefined) {
375          results.push({ kind: 'path', label: step.label, state: 'missing' })
376        } else if (stat.kind === 'dir') {
377          const entries = await $.fs.list(step.path)
378          const names = entries.slice(0, MAX_LISTED).map(entry => (entry.kind === 'dir' ? `${entry.name}/` : entry.name))
379          results.push({ kind: 'path', label: step.label, state: 'dir', entries: names, total: entries.length })
380        } else {
381          results.push({ kind: 'path', label: step.label, state: 'file', size: stat.size })
382        }
383      } else {
384        const { label, run } = await runGitInspection($, step, ctx)
385        results.push({ kind: 'git', label, exitCode: run.exitCode, stdout: run.stdout.slice(0, 50_000) })
386      }
387    } catch {
388      results.push({ kind: 'failed', label: step.label })
389    }
390  }
391  const preview = formatPreview(results, settings.previewLines)
392  return preview === undefined ? undefined : redact(preview, ctx.patterns)
393}
394
395/**
396 * What a file tool would change, as a diff against the file as it stands,
397 * redacted. Reading the file is the only I/O; nothing runs.
398 */
399async function fileDiffOf($: EngineInterface, call: Call, cwd: string, realPath: string | undefined, ctx: Context): Promise<{ path: string; diff: CallDiff } | undefined> {
400  const field = FILE_PATH_FIELDS[call.tool]
401  const given = field === undefined ? undefined : call.input[field]
402  if (field === undefined) return undefined
403  if (typeof given !== 'string') return { path: '(no path)', diff: callDiff(call, '(no path)', { state: 'unreadable' }, settings.diffLines) }
404  const absolute = realPath ?? resolve(given, cwd, ctx.home)
405  const path = relativeTo(absolute, ctx.root) || absolute
406  let current: Current
407  if (!(await $.fs.exists(absolute).catch(() => true))) {
408    current = { state: 'missing' }
409  } else {
410    const content = await $.fs.read(absolute).catch(() => undefined)
411    current = typeof content === 'string' ? { state: 'text', text: content } : { state: 'unreadable' }
412  }
413  const diff = callDiff(call, path, current, settings.diffLines)
414  return { path, diff: { ...diff, diff: redact(diff.diff, ctx.patterns) } }
415}
416
417/** The `.sql` files a database call names, read when they sit inside the project, redacted and cut. */
418async function sqlFilesOf($: EngineInterface, files: readonly { word: string; cwd?: string }[], cwd: string, ctx: Context): Promise<SqlPiece[]> {
419  const pieces: SqlPiece[] = []
420  for (const file of files) {
421    const path = resolve(file.word.replace(/^['"]|['"]$/g, ''), file.cwd ?? cwd, ctx.home)
422    if (!isInside(path, ctx.root)) continue
423    // Inside by name is not enough: a link may land outside the project.
424    const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
425    if (real === undefined || !isInside(real, ctx.realRoot)) continue
426    const content = await $.fs.read(real).catch(() => undefined)
427    if (typeof content === 'string') {
428      pieces.push({ label: relativeTo(path, ctx.root) ?? path, text: redact(truncate(content, ARAGORN_LIMITS.sqlLines, ARAGORN_LIMITS.sqlChars), ctx.patterns) })
429    }
430  }
431  return pieces
432}
433
434/** What the seated member is given: the context its profile asks for, and nothing else. */
435async function briefOf(
436  $: EngineInterface,
437  seat: Seat,
438  call: Call,
439  classification: Classification,
440  cwd: string,
441  realPath: string | undefined,
442  state: CouncilSession,
443  preview: string | undefined,
444  ctx: Context,
445): Promise<Brief> {
446  const ruleReasons = [...new Set(classification.findings.map(finding => finding.reason))]
447  const shown = callText(call, ctx.patterns)
448  const latestPrompt = state.latestPrompt
449  const withPreview = preview !== undefined ? { preview } : {}
450  // Routing seats the diff reviewer on file tools only, which always have a diff.
451  const file = seat.member === 'gandalf' || seat.profile === 'git' ? undefined : await fileDiffOf($, call, cwd, realPath, ctx)
452  if (seat.member === 'legolas' && file !== undefined) {
453    return { member: 'legolas', context: { tool: call.tool, path: file.path, diff: file.diff, ruleReasons, latestPrompt, ...withPreview } }
454  }
455  if (seat.member === 'aragorn' && seat.profile === 'git') {
456    return {
457      member: 'aragorn',
458      profile: 'git',
459      context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, protectedBranches: ctx.loaded.compiled.config.protectedBranches, ...withPreview },
460    }
461  }
462  if (seat.member === 'aragorn') {
463    const found = sqlOf(classification)
464    const inline = found.inline.map(piece => ({ label: piece.label, text: redact(piece.text, ctx.patterns) }))
465    const sql = [...inline, ...(await sqlFilesOf($, found.files, cwd, ctx))]
466    const production = productionHits([shown, ...sql.map(piece => piece.text)].join('\n'), ctx.loaded.compiled.production)
467    return {
468      member: 'aragorn',
469      profile: 'database',
470      context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, sql, production, ...(file !== undefined && { diff: file.diff }) },
471    }
472  }
473  const scripts = await scriptsOf($, classification, cwd, state.written, ctx)
474  return { member: 'gandalf', context: { tool: call.tool, call: shown, ruleReasons, latestPrompt, scripts, ...withPreview } }
475}
476
477/**
478 * One model review, for any member: its own system prompt and prompt, the
479 * model's request settings, a deadline passed as the call's own timeout,
480 * and the strict verdict parse. Every failure is a review without a verdict.
481 */
482async function review($: EngineInterface, brief: Brief, model: string, deadlineMs: number, signal: AbortSignal): Promise<Review> {
483  const nonce = newNonce()
484  const request = requestOf(brief, nonce)
485  const limits = profileOf(model)
486  const who = text(whoOf(brief.member, brief.profile))
487  try {
488    const reply = await $.model.complete(
489      {
490        model,
491        system: request.system,
492        prompt: request.prompt,
493        maxTokens: limits.maxTokens,
494        ...(limits.effort !== undefined && { effort: limits.effort }),
495        timeoutMs: deadlineMs,
496      },
497      { signal },
498    )
499    const usage = reply.usage
500    const tokens =
501      usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
502    if (!reply.isAnswered) {
503      const problem =
504        reply.reason === 'aborted'
505          ? 'it ran out of time or was interrupted'
506          : reply.reason === 'api-error'
507            ? `the API answered with an error (${reply.error})`
508            : 'it gave an empty reply'
509      if (reply.reason === 'api-error' && ['model_not_found', 'invalid_request', 'authentication_failed'].includes(reply.error)) {
510        warnOnce($, `model:${model}`, text('notice.modelFailed', { who, model, problem }))
511      }
512      return { ok: false, problem, model, tokens }
513    }
514    const parsed = parseVerdict(reply.text)
515    return parsed.ok
516      ? { ok: true, verdict: parsed.verdict, model, tokens }
517      : { ok: false, problem: `malformed verdict: ${parsed.problem}`, model, tokens }
518  } catch (error) {
519    const problem = `the request was refused: ${error instanceof Error ? error.message : String(error)}`
520    warnOnce($, `model:${model}`, text('notice.modelFailed', { who, model, problem }))
521    return { ok: false, problem, model, tokens: 0 }
522  }
523}
524
525/** The checked-out branch, for whether a merge lands on a protected one; unknown on any failure. */
526async function currentBranchOf($: EngineInterface, root: string): Promise<string | undefined> {
527  try {
528    const run = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, env: GIT_ENV, timeoutMs: INSPECTION_TIMEOUT_MS })
529    const branch = run.stdout.trim()
530    return run.exitCode === 0 && branch !== '' ? branch : undefined
531  } catch {
532    return undefined
533  }
534}
535
536/**
537 * What a push would send or a merge bring in, for the diff reviewer: one
538 * read-only `git diff` from Galadriel's table, cut to the diff limit and
539 * redacted, with the range that was read (the table's fallback range when the
540 * first can't be read). Undefined when git can't say.
541 */
542async function rangeDiffOf($: EngineInterface, step: Extract<Inspection, { kind: 'git' }>, ctx: Context): Promise<{ label: string; diff: string } | undefined> {
543  try {
544    const { label, run } = await runGitInspection($, step, ctx)
545    if (run.exitCode !== 0) return undefined
546    const diff = run.stdout.slice(0, LEGOLAS_LIMITS.diffChars * 4).trimEnd()
547    return { label, diff: redact(truncate(diff === '' ? '(no changes)' : diff, settings.diffLines, LEGOLAS_LIMITS.diffChars), ctx.patterns) }
548  } catch {
549    return undefined
550  }
551}
552
553/**
554 * Runs one of the project's checks from the rules file, by argument vector,
555 * in the project root. Its own timeout ends it (a clock timer, which costs
556 * the hook no budget while the child's output is awaited); so does `stop`
557 * once the council has blocked, and Esc, through the dispatch's signal.
558 */
559async function runCheck($: EngineInterface, command: GimliCommand, root: string, stop: AbortSignal): Promise<GimliRun> {
560  const started = await $.clock.now()
561  let ended: 'timeout' | 'stopped' | undefined
562  let exit: ProcessSpawnResult | undefined
563  let output = ''
564  let isEnded: () => void = () => undefined
565  const endedEarly = new Promise<void>(resolve => {
566    isEnded = resolve
567  })
568  const stream = $.process.spawn({ argv: command.argv, cwd: root })
569  const end = (why: 'timeout' | 'stopped'): void => {
570    if (ended !== undefined || exit !== undefined) return
571    ended = why
572    isEnded()
573    // Ending the loop is what kills the child.
574    void stream.return(undefined as never).catch(() => undefined)
575  }
576  const timer = $.clock.after(command.timeoutMs, () => end('timeout'))
577  const onStop = (): void => end('stopped')
578  stop.addEventListener('abort', onStop)
579  if (stop.aborted) onStop()
580  const reading = (async () => {
581    for await (const chunk of stream) output = keepTail(output, chunk.text)
582    if (ended === undefined) exit = await stream.result
583  })().catch(() => {
584    // It could not start, or its stream broke: no exit code, so it does not pass.
585  })
586  try {
587    // Ended early, the run is over even if the stream is slow to close.
588    await Promise.race([reading, endedEarly])
589  } finally {
590    timer.cancel()
591    stop.removeEventListener('abort', onStop)
592  }
593  const ms = (await $.clock.now()) - started
594  return {
595    name: command.name,
596    status: statusOf(ended, exit),
597    ...(ended === undefined && exit !== undefined && { code: exit.code, signal: exit.signal }),
598    tail: tailOf(output),
599    ms,
600  }
601}
602
603/** One seat at the full council: who, and the brief it reviews (none: it sits out, saying why). */
604type Sitting = { seat: CouncilSeat; who: StringKey; brief?: Brief; skip?: string }
605
606type Held = { voices: Voice[]; tokens: number[]; runs: GimliRun[] }
607
608/** What `convene` reports as it goes, for the debate record: a voice resolved, or a check ended. */
609type Progress = { kind: 'voice'; index: number; voice: Voice } | { kind: 'check'; run: GimliRun }
610
611/**
612 * The full council. The project's checks start first and run on their own
613 * timeouts (decision 3); the model members run in parallel, or one at a time
614 * stopping at the first block, under one shared deadline passed to each
615 * request as the time remaining. Each member sees only its own brief.
616 */
617async function convene(
618  $: EngineInterface,
619  sittings: readonly Sitting[],
620  model: string,
621  deadlineMs: number,
622  isSequential: boolean,
623  checks: readonly GimliCommand[],
624  root: string,
625  signal: AbortSignal,
626  progress: (event: Progress) => Promise<void>,
627): Promise<Held> {
628  const started = await $.clock.now()
629  const stop = new AbortController()
630  let isCheckFailed = false
631  // The debate record is only watching: a note that fails or lags changes nothing here.
632  const noted: Promise<void>[] = []
633  const note = (event: Progress): void => {
634    noted.push(Promise.resolve().then(() => progress(event)).catch(() => undefined))
635  }
636  const checking = Promise.all(
637    checks.map(command =>
638      runCheck($, command, root, stop.signal).then(run => {
639        if (isGimliBlock(run)) {
640          isCheckFailed = true
641          stop.abort()
642        }
643        note({ kind: 'check', run })
644        return run
645      }),
646    ),
647  )
648  const tokens: number[] = sittings.map(() => 0)
649  const baseOf = (sitting: Sitting) => ({ who: sitting.who, member: sitting.seat.member, ...(sitting.seat.member === 'aragorn' && { profile: sitting.seat.profile }) })
650  const ask = async (sitting: Sitting, index: number): Promise<Voice> => {
651    const base = baseOf(sitting)
652    if (sitting.brief === undefined) return { kind: 'skipped', ...base, why: sitting.skip ?? '' }
653    const remaining = Math.floor(deadlineMs - ((await $.clock.now()) - started))
654    if (remaining < 1) return { kind: 'failed', ...base, problem: text('council.deadline') }
655    const result = await review($, sitting.brief, model, remaining, signal)
656    tokens[index] = result.tokens
657    return result.ok ? { kind: 'verdict', ...base, verdict: result.verdict } : { kind: 'failed', ...base, problem: result.problem }
658  }
659  const resolved = (index: number, voice: Voice): Voice => {
660    note({ kind: 'voice', index, voice })
661    return voice
662  }
663  let voices: Voice[]
664  if (isSequential) {
665    voices = []
666    for (const [index, sitting] of sittings.entries()) {
667      const isBlocked = isCheckFailed || voices.some(voice => voice.kind === 'failed' || (voice.kind === 'verdict' && voice.verdict.verdict === 'block'))
668      voices.push(
669        resolved(index, isBlocked ? { kind: 'skipped', ...baseOf(sitting), why: text('council.stopped') } : await ask(sitting, index)),
670      )
671    }
672  } else {
673    voices = await Promise.all(sittings.map(async (sitting, index) => resolved(index, await ask(sitting, index))))
674  }
675  // A check can only add a block: once a member has blocked, one still running cannot matter.
676  if (hasRealBlock(voices)) stop.abort()
677  const runs = await checking
678  await Promise.all(noted)
679  return { voices, tokens, runs }
680}
681
682/** Puts the call to the user; where nobody can be asked, says so. */
683async function escalate($: EngineInterface, question: string, withAllowlist: boolean): Promise<Answer | Unanswered> {
684  const surfaces = await $.session.surfaces().catch(() => [])
685  if (surfaces.length === 0) return 'unavailable'
686  try {
687    // One mode for the labels offered and the labels compared: they must match exactly.
688    const mode = currentMode()
689    const answer = await $.ui.ask(question, { options: optionsOf(mode, withAllowlist), header: text('ask.rollHeader', {}, mode) })
690    return interpretAnswer(answer, mode, withAllowlist)
691  } catch (error) {
692    return interpretRejection(error)
693  }
694}
695
696/** The second confirm before an allowlist entry is written: only "Add it" adds. */
697async function confirmAllowlist($: EngineInterface, finding: GollumFinding): Promise<boolean> {
698  const question = text('ask.confirmAllowlist', { label: finding.label, entry: finding.fingerprint, path: OVERRIDES_PATH, field: 'gollum.allowlist' })
699  try {
700    const answer = await $.ui.ask(question, { options: [text('ask.confirmAdd'), text('ask.confirmCancel')], header: text('ask.header') })
701    return answer === text('ask.confirmAdd')
702  } catch {
703    return false
704  }
705}
706
707/**
708 * Rewrites the project rules file with one confirmed change, read fresh: a
709 * broken file is left alone, and a result that doesn't validate isn't written.
710 */
711async function writeOverrides($: EngineInterface, root: string, change: (current: string | undefined) => FileEdit): Promise<FileEdit> {
712  const path = `${root}/${OVERRIDES_PATH}`
713  try {
714    const current = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
715    const edit = change(current)
716    if (edit.ok) await $.fs.write(path, edit.text)
717    return edit
718  } catch (error) {
719    return { ok: false, problem: error instanceof Error ? error.message : String(error) }
720  }
721}
722
723/**
724 * After "allow once" and a call that ran: offers the allow rule for it, showing
725 * the exact JSON, and writes it only on "Add the rule" (then reloads, decision
726 * 9). Any other answer, or dismissing, declines it for the session. Returns the
727 * id of the rule written.
728 */
729async function offerRule($: EngineInterface, call: Call, classification: Classification, where: ClassifyContext, ctx: Context, hadSecret: boolean): Promise<string | undefined> {
730  const state = sessionOf(await read($, session))
731  const suggested = suggestRule(call, classification, ctx.loaded.compiled, where, { hadSecret, declined: state.declinedRules })
732  if (suggested.kind === 'none') return undefined
733  const { rule, key } = suggested.suggestion
734  if ((await $.session.surfaces().catch(() => [])).length === 0) return undefined
735  const question = text('ask.suggestRule', { path: OVERRIDES_PATH, json: ruleJson(rule) })
736  const answer = await $.ui
737    .ask(question, { options: [text('ask.suggestAdd'), text('ask.suggestDecline')], header: text('ask.header') })
738    .catch(() => undefined)
739  if (answer !== text('ask.suggestAdd')) {
740    await update($, session, value => declineRule(sessionOf(value), key))
741    return undefined
742  }
743  const written = await writeOverrides($, ctx.root, current => withRule(current, rule))
744  if (!written.ok) {
745    $.ui.log(text('notice.ruleFailed', { problem: written.problem }))
746    return undefined
747  }
748  await reloadContext($)
749  $.ui.log(text('notice.ruleWritten', { id: rule.id, path: OVERRIDES_PATH }))
750  return rule.id
751}
752
753async function appendAudit($: EngineInterface, record: AuditRecord, root: string): Promise<void> {
754  const path = resolve(settings.auditLogPath, root)
755  const folder = path.slice(0, path.lastIndexOf('/'))
756  if (!isGitignoreChecked) {
757    isGitignoreChecked = true
758    const ignore = `${folder}/.gitignore`
759    if (isInside(path, root) && !(await $.fs.exists(ignore))) await $.fs.write(ignore, AUDIT_GITIGNORE)
760  }
761  const line = auditLine(record)
762  const current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
763  const maxBytes = settings.auditMaxKb * 1024
764  const older: (string | undefined)[] = []
765  if (current !== '' && current.length + line.length > maxBytes) {
766    for (let n = 1; n < ROTATED_FILES - 1; n++) {
767      const rotated = `${path}.${n}`
768      older.push((await $.fs.exists(rotated)) ? await $.fs.read(rotated) : undefined)
769    }
770  }
771  for (const write of appendPlan(path, current, line, maxBytes, older)) await $.fs.write(write.path, write.text)
772}
773
774/** Appends one audit line; writes are serialized, and a failed write never changes a decision. */
775async function audit($: EngineInterface, record: AuditRecord, root: string): Promise<void> {
776  const queued = auditQueue.then(() => appendAudit($, record, root))
777  auditQueue = queued.catch((error: unknown) => {
778    $.ui.log(`Council: audit write failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
779  })
780  await auditQueue
781}
782
783/** Notes a file Claude wrote, once the write has run. */
784async function noteWrite($: EngineInterface, call: Call, cwd: string, home: string | undefined, result: ToolCallResult): Promise<void> {
785  const field = FILE_PATH_FIELDS[call.tool]
786  const given = field === undefined ? undefined : call.input[field]
787  const isWritten = typeof given === 'string' && result.deny === undefined && result.isError !== true
788  if (isWritten) await update($, session, value => noteWritten(sessionOf(value), resolve(given, cwd, home)))
789}
790
791/** The label by the prompt where SessionMode is not drawn (other surfaces): the status line. */
792async function syncIndicator($: EngineInterface): Promise<void> {
793  const state = sessionOf(await read($, session))
794  const label = state.bypass ? text('mode.bypass') : isShadow(state, settings.shadowMode) ? text('mode.shadow') : undefined
795  const surfaces = await $.session.surfaces().catch(() => [])
796  if (surfaces.some(surface => surface !== 'terminal' && surface !== 'desktop')) $.ui.status(label)
797}
798
799/** Shows `/council` output to the user: in its pane where one draws, else as transcript lines. Never to Claude. */
800async function show($: EngineInterface, output: Output): Promise<void> {
801  const surfaces = await $.session.surfaces().catch(() => [])
802  if (surfaces.length > 0) {
803    await update($, panel, () => output)
804    const opened = await $.ui.open({ id: PANE_ID, title: output.title }).catch(() => undefined)
805    if (opened?.isPlaced === true) return
806  }
807  $.ui.log(output.title)
808  for (const line of output.lines) $.ui.log(line)
809}
810
811/** Rows as a column of Text: a theme key for colour, never a raw colour, so a theme change reaches them. */
812function drawRows(Box: Parameters<typeof h>[0], Text: Parameters<typeof h>[0], rows: readonly Row[], wrap: 'wrap' | 'truncate'): RenderElement {
813  const lines = rows.map(row =>
814    h(Text, { wrap, ...(row.color !== undefined && { color: row.color }), ...(row.bold === true && { bold: true }), ...(row.dim === true && { dimColor: true }) }, row.text),
815  )
816  return h(Box, { flexDirection: 'column' }, ...lines) as RenderElement
817}
818
819/**
820 * Opens the debate pane unasked, once per session: the engine itself keeps an
821 * unasked open undrawn below 144 columns, so the mod never measures the terminal.
822 * A refused open is ignored; it is never retried.
823 */
824async function openDebatePane($: EngineInterface): Promise<void> {
825  if (sessionOf(await read($, session)).debateOpened) return
826  // Claimed before the open, so a parallel call does not open it too.
827  await update($, session, value => markDebateOpened(sessionOf(value)))
828  if ((await $.session.surfaces().catch(() => [])).length === 0) return
829  await $.ui.open({ id: DEBATE_PANE_ID, title: text('debate.title'), rows: DEBATE_PANE_ROWS }).catch(() => undefined)
830}
831
832/** `/council debate`: the pane at any width (it answers the person's command), else the same rows as transcript lines. */
833async function showDebate($: EngineInterface): Promise<void> {
834  const surfaces = await $.session.surfaces().catch(() => [])
835  if (surfaces.length > 0) {
836    const opened = await $.ui.open({ id: DEBATE_PANE_ID, title: text('debate.title'), rows: DEBATE_PANE_ROWS }).catch(() => undefined)
837    if (opened?.isPlaced === true) return
838  }
839  $.ui.log(text('debate.title'))
840  for (const row of debateRows(sessionOf(await read($, session)), undefined, currentMode())) $.ui.log(row.text)
841}
842
843/**
844 * The epic drop (themed mode, a pushed or merged call the full council
845 * approved): the row hides itself once `epicUntil` passes, whatever happens to
846 * the timer, which is only a redraw trigger (a hot reload cancels it).
847 */
848async function epicDrop($: EngineInterface): Promise<void> {
849  const until = (await $.clock.now()) + EPIC_MS
850  await update($, session, value => withEpic(sessionOf(value), until))
851  $.ui.toast(text('epic.toast'), { timeoutMs: EPIC_MS })
852  $.clock.after(EPIC_MS + 100, () => {
853    void (async () => {
854      try {
855        const now = await $.clock.now()
856        await update($, session, value => clearEpic(sessionOf(value), now))
857      } catch {
858        // The row hides itself at its time anyway.
859      }
860    })()
861  })
862}
863
864/** `/council report`: the rotated files (oldest first), then the current one; an unreadable file is named and left out. */
865async function reportFrom($: EngineInterface, path: string): Promise<Output> {
866  const files: string[] = []
867  const unreadable: string[] = []
868  for (const file of [...Array.from({ length: ROTATED_FILES - 1 }, (_, i) => `${path}.${ROTATED_FILES - 1 - i}`), path]) {
869    try {
870      if (await $.fs.exists(file)) files.push(await $.fs.read(file))
871    } catch (error) {
872      unreadable.push(`${file}: ${error instanceof Error ? error.message : String(error)}`)
873    }
874  }
875  return reportOutput(files, unreadable)
876}
877
878/** A one-request check that a model answers: an API error is a no, a timeout unsure. */
879async function probeModel($: EngineInterface, model: string): Promise<{ ok: true } | { ok: false; isCertain: boolean; problem: string }> {
880  try {
881    const reply = await $.model.complete({ model, prompt: 'Reply with the single word OK.', maxTokens: 16, timeoutMs: PROBE_TIMEOUT_MS })
882    const usage = reply.usage
883    await update($, session, value => addTokens(sessionOf(value), usage.input_tokens + usage.output_tokens))
884    if (reply.isAnswered || reply.reason === 'empty-reply') return { ok: true }
885    if (reply.reason === 'api-error') return { ok: false, isCertain: true, problem: reply.error }
886    return { ok: false, isCertain: false, problem: 'no answer in time' }
887  } catch (error) {
888    return { ok: false, isCertain: true, problem: error instanceof Error ? error.message : String(error) }
889  }
890}
891
892function modelChoice(slot: ModelSlot, ctx: Context, sessionModels: Readonly<Partial<Record<ModelSlot, string>>>): ModelChoice {
893  const project = ctx.loaded.compiled.config.models[slot]
894  const sessionModel = sessionModels[slot]
895  return resolveModel(slot, {
896    settings: settingsModel(slot),
897    ...(project !== undefined && { project }),
898    ...(sessionModel !== undefined && { session: sessionModel }),
899  })
900}
901
902async function councilModel($: EngineInterface, command: Extract<CouncilCommand, { kind: 'model' }>): Promise<Output> {
903  const title = text('cmd.title')
904  if (!isSlot(command.slot)) return { title, lines: [text('cmd.modelBadSlot', { slot: command.slot, slots: MODEL_SLOTS.join(', ') })] }
905  const slot = command.slot
906  const ctx = await contextOf($)
907  const before = sessionOf(await read($, session))
908  if (command.model === 'default') {
909    const after = await update($, session, value => withSessionModel(sessionOf(value), slot, undefined))
910    const now = modelChoice(slot, ctx, after.models)
911    return { title, lines: [text('cmd.modelCleared', { id: slot, model: now.model, source: now.source })] }
912  }
913  if (!MODEL_ID.test(command.model)) return { title, lines: [text('cmd.modelBadId', { model: command.model })] }
914
915  const lines: string[] = []
916  const probe = await probeModel($, command.model)
917  if (!probe.ok && probe.isCertain) {
918    const previous = modelChoice(slot, ctx, before.models)
919    return { title, lines: [text('cmd.modelProbeFailed', { model: command.model, problem: probe.problem, id: slot, previous: previous.model })] }
920  }
921  await update($, session, value => withSessionModel(sessionOf(value), slot, command.model))
922  lines.push(text('cmd.modelSet', { id: slot, model: command.model }))
923  if (!probe.ok) lines.push(text('cmd.modelProbeUnsure', { model: command.model, problem: probe.problem }))
924  if (command.save) {
925    const row = CONFIG_ROWS[slot]
926    if (row === undefined) {
927      lines.push(text('cmd.modelNotSaved', { problem: text('cmd.modelNoRow', { id: slot }) }))
928    } else if (!PICKER_OPTIONS.includes(command.model)) {
929      lines.push(text('cmd.modelNotSaved', { problem: text('cmd.modelNotOption', { options: PICKER_OPTIONS.join(', ') }) }))
930    } else {
931      const saved = await $.config.set({ key: row, value: command.model }).catch((error: unknown) => ({
932        deny: error instanceof Error ? error.message : String(error),
933      }))
934      lines.push(saved.deny === undefined ? text('cmd.modelSaved') : text('cmd.modelNotSaved', { problem: saved.deny }))
935    }
936  }
937  return { title, lines }
938}
939
940async function councilOutput($: EngineInterface, command: Exclude<CouncilCommand, { kind: 'debate' }>): Promise<Output> {
941  const title = text('cmd.title')
942  switch (command.kind) {
943    case 'status': {
944      const ctx = await contextOf($)
945      const state = sessionOf(await read($, session))
946      const mode = state.bypass ? 'bypass' : isShadow(state, settings.shadowMode) ? 'shadow' : 'enforcing'
947      return statusOutput({
948        session: state,
949        mode,
950        members: {
951          gandalf: { enabled: settings.gandalfEnabled, choice: modelChoice('gandalf', ctx, state.models) },
952          legolas: { enabled: settings.legolasEnabled, choice: modelChoice('legolas', ctx, state.models) },
953          aragorn: { enabled: settings.aragornEnabled, choice: modelChoice('aragorn', ctx, state.models) },
954        },
955        council: { enabled: settings.councilEnabled, choice: modelChoice('council', ctx, state.models), sequential: settings.councilSequential },
956        gimli: { enabled: settings.gimliEnabled, commands: ctx.loaded.compiled.config.gimli.commands.length },
957        gollumEnabled: settings.gollumEnabled,
958        galadrielEnabled: settings.galadrielEnabled,
959        tokenBudget: settings.tokenBudget,
960      })
961    }
962    case 'bypass':
963      await update($, session, value => withBypass(sessionOf(value), command.on))
964      await syncIndicator($)
965      return { title, lines: [text(command.on ? 'cmd.bypassOn' : 'cmd.bypassOff')] }
966    case 'shadow':
967      await update($, session, value => withShadow(sessionOf(value), command.on))
968      await syncIndicator($)
969      return { title, lines: [text(command.on ? 'cmd.shadowOn' : 'cmd.shadowOff')] }
970    case 'log': {
971      const ctx = await contextOf($)
972      const path = resolve(settings.auditLogPath, ctx.root)
973      try {
974        const logText = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
975        return logOutput(logText, command.count)
976      } catch (error) {
977        return { title, lines: [text('cmd.logUnreadable', { problem: error instanceof Error ? error.message : String(error) })] }
978      }
979    }
980    case 'rules': {
981      const ctx = await contextOf($)
982      return rulesOutput(ctx.loaded.compiled, ctx.loaded.origin, ctx.loaded.errors)
983    }
984    case 'test': {
985      // Classification only: nothing the command names is run, read or stat'ed.
986      const ctx = await contextOf($)
987      const cwd = await $.session.cwd()
988      const call: Call = { tool: 'Bash', input: { command: command.command } }
989      const where = { root: ctx.root, cwd, ...(ctx.home !== undefined && { home: ctx.home }) }
990      const classification = classify(call, ctx.loaded.compiled, where)
991      const operation = classification.tier === 'allow' ? undefined : operationOf(call, classification, where)
992      const state = sessionOf(await read($, session))
993      const seated = classification.tier === 'review' ? route(call, classification, enabledMembers()) : undefined
994      const reviewer =
995        seated === undefined
996          ? undefined
997          : { route: seated, ...(seated.kind === 'member' && { choice: modelChoice(seated.member, ctx, state.models) }) }
998      // The current branch is not read: a merge counts as one into a protected branch.
999      const big = bigOperationOf(classification, ctx.loaded.compiled)
1000      const council =
1001        big === undefined
1002          ? undefined
1003          : {
1004              big,
1005              enabled: settings.councilEnabled,
1006              seats: councilSeats(call, classification, enabledMembers(), { ranges: settings.galadrielEnabled }),
1007              choice: modelChoice('council', ctx, state.models),
1008              checks: settings.gimliEnabled ? ctx.loaded.compiled.config.gimli.commands.map(check => check.name) : [],
1009              isBranchAssumed: big.entry === 'merge-to-protected' && needsCurrentBranch(classification, ctx.loaded.compiled),
1010            }
1011      return testOutput(redact(command.command, ctx.patterns), classification, operation, reviewer, council)
1012    }
1013    case 'models': {
1014      const ctx = await contextOf($)
1015      const state = sessionOf(await read($, session))
1016      const choices = Object.fromEntries(MODEL_SLOTS.map(slot => [slot, modelChoice(slot, ctx, state.models)])) as Record<ModelSlot, ModelChoice>
1017      return modelsOutput(choices)
1018    }
1019    case 'model':
1020      return councilModel($, command)
1021    case 'reload': {
1022      const ctx = await reloadContext($)
1023      const lines = [text('cmd.reloaded', { count: ctx.loaded.compiled.rules.length, origin: ctx.loaded.origin })]
1024      if (ctx.loaded.errors.length > 0) lines.push(text('cmd.configErrors', { errors: ctx.loaded.errors.join('; ') }))
1025      return { title, lines }
1026    }
1027    case 'report': {
1028      const ctx = await contextOf($)
1029      return reportFrom($, resolve(settings.auditLogPath, ctx.root))
1030    }
1031    case 'usage':
1032      return { title, lines: [text(command.key, command.params ?? {}), ...(command.key === 'cmd.unknown' ? [text('cmd.help')] : [])] }
1033  }
1034}
1035
1036/** The call's arguments as text for a reviewer or the user: redacted, never whole files. */
1037function callText(call: Call, patterns: readonly SecretPattern[]): string {
1038  if (SHELL_TOOLS.has(call.tool) && typeof call.input.command === 'string') return redact(call.input.command, patterns)
1039  const field = FILE_PATH_FIELDS[call.tool]
1040  if (field !== undefined) {
1041    const { [field]: path, ...rest } = call.input
1042    return redact(`${String(path)}\n${JSON.stringify(rest, null, 1)}`, patterns)
1043  }
1044  return redact(JSON.stringify(call.input, null, 1), patterns)
1045}
1046
1047function callOf(e: Readonly<Record<string, unknown>>): Call {
1048  const { tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...input } = e
1049  return { tool: String(tool), input }
1050}
1051
1052export const register: Register = (on, options) => {
1053  settings = settingsOf(options)
1054  setMode(settings.plainMode ? 'plain' : 'themed')
1055
1056  on('session.start', async ($, e, next) => {
1057    const ctx = await contextOf($).catch(() => undefined)
1058    await $.command
1059      .register({
1060        name: COMMAND,
1061        description: 'The council: status, bypass, shadow mode, log, rules, test a command, models, reload, report, debate pane',
1062        argumentHint: '[on|off|shadow on|off|log [n]|rules|test "<cmd>"|model [<member> <model> [--save]]|reload|report|debate]',
1063      })
1064      .catch((error: unknown) => {
1065        $.ui.log(text('notice.commandFailed', { problem: error instanceof Error ? error.message : String(error) }))
1066      })
1067    // Decision 6: suggest shadow mode once, while the council has logged nothing.
1068    if (ctx !== undefined && !isShadow(sessionOf(await read($, session)), settings.shadowMode)) {
1069      const isEmpty = !(await $.fs.exists(resolve(settings.auditLogPath, ctx.root)).catch(() => true))
1070      if (isEmpty) warnOnce($, 'shadow-suggest', text('notice.shadowSuggest'), text('notice.shadowSuggest'))
1071    }
1072    await syncIndicator($).catch(() => undefined)
1073    return next(e)
1074  })
1075
1076  on('prompt.submit', async ($, e, next) => {
1077    if (HUMAN_ORIGINS.has(e.origin.kind)) {
1078      const patterns = (await contextOf($).catch(() => undefined))?.patterns
1079      await update($, session, value => resetForPrompt(sessionOf(value), redact(e.text, patterns)))
1080    }
1081    return next(e)
1082  })
1083
1084  // Output goes to the user only (decision 1): no text for Claude to read.
1085  on('command.run', { command: COMMAND }, async ($, e) => {
1086    const command = parseCouncil(e.args)
1087    if (command.kind === 'debate') await showDebate($)
1088    else await show($, await councilOutput($, command))
1089    return {}
1090  }).catch(($, e) => {
1091    $.ui.log(text('cmd.help'))
1092    return {}
1093  })
1094
1095  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1096    const { Box, Text } = $.ui.resolve(e)
1097    const shown = await read($, panel)
1098    // Called directly rather than as JSX, so this module stays a .ts file.
1099    return h(Box, { flexDirection: 'column' }, ...shown.lines.map(line => h(Text, { wrap: 'wrap' }, line))) as RenderElement
1100  })
1101
1102  // The debate pane draws the agent in view (the main conversation: none) and only reads state.
1103  on('ui.render', { component: 'Pane', requestId: DEBATE_PANE_ID }, async ($, e) => {
1104    const { Box, Text } = $.ui.resolve(e)
1105    const state = sessionOf(await read($, session))
1106    return drawRows(Box, Text, debateRows(state, e.props.view.agentId, currentMode()), 'wrap')
1107  })
1108
1109  // The council check, and the epic drop: a row each, only while there is one (terminal and desktop).
1110  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1111    if (e.props.hasSurvey) return next(e)
1112    const state = sessionOf(await read($, session))
1113    const rows = bandRows(state, e.props.view.agentId, await $.clock.now(), currentMode())
1114    if (rows === undefined) return next(e)
1115    const { Box, Text } = $.ui.resolve(e)
1116    return drawRows(Box, Text, rows, 'truncate')
1117  })
1118
1119  // The mode label by the prompt while bypass or shadow is on (terminal and desktop).
1120  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1121    const state = sessionOf(await read($, session))
1122    const label = state.bypass ? text('mode.bypass') : isShadow(state, settings.shadowMode) ? text('mode.shadow') : undefined
1123    return label === undefined ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
1124  })
1125
1126  on('tool.call', async ($, e, next) => {
1127    const started = Date.now()
1128    const call = callOf(e as unknown as Readonly<Record<string, unknown>>)
1129
1130    /** The one way a call passes: never with the hook's own budget spent. */
1131    const proceed = async (): Promise<ToolCallResult> =>
1132      next.budget.remainingMs < BUDGET_GUARD_MS
1133        ? { deny: refusalText({ who: 'who.council', verdict: 'error', reason: text('reason.internal'), alternative: text('alternative.ask') }) }
1134        : next(e)
1135
1136    const ctx = await contextOf($)
1137    const cwd = await $.session.cwd()
1138    const realPath = await realPathOf($, call, cwd, ctx)
1139    const where = {
1140      root: ctx.root,
1141      cwd,
1142      ...(ctx.home !== undefined && { home: ctx.home }),
1143      ...(realPath !== undefined && { realPath }),
1144    }
1145    const classification = classify(call, ctx.loaded.compiled, where)
1146
1147    if (classification.tier === 'allow') {
1148      // High-confidence secrets refuse an allowed call too (DESIGN §13.2). It has
1149      // no operation, so no rounds, no failed attempt and no state write; low
1150      // findings are ignored here, as the mod never asks on an allowed call.
1151      if (settings.gollumEnabled) {
1152        const scan = await scanCall(call, ctx.patterns, ctx.loaded.compiled.config.gollum.allowlist)
1153        const first = scan.high[0]
1154        if (first !== undefined) {
1155          const deny = refusalText({ who: 'who.gollum', verdict: 'block', reason: text('reason.secretHigh', { label: first.label, snippet: first.snippet }), alternative: text('alternative.removeSecret') })
1156          await audit(
1157            $,
1158            {
1159              ts: new Date().toISOString(),
1160              tool: call.tool,
1161              fingerprint: await fingerprintOf(call.tool, call.input),
1162              opKey: null,
1163              tier: 'allow',
1164              ruleId: classification.decided?.ruleId ?? null,
1165              member: 'gollum',
1166              profile: null,
1167              model: null,
1168              verdict: 'block',
1169              reason: scan.high.map(finding => finding.label).join(', '),
1170              shadow: false,
1171              bypass: false,
1172              decision: null,
1173              outcome: 'refused',
1174              latencyMs: Date.now() - started,
1175              tokens: 0,
1176              ...(e.agentId !== undefined && { agentId: e.agentId }),
1177            },
1178            ctx.root,
1179          )
1180          return { deny }
1181        }
1182      }
1183      const result = await proceed()
1184      await noteWrite($, call, cwd, ctx.home, result).catch(() => undefined)
1185      return result
1186    }
1187
1188    // A gated call: everything from here is audited.
1189    const state = sessionOf(await read($, session))
1190    // The prompt this call belongs to. A review runs while `next(e)` is in
1191    // flight; if the user sends a new prompt meanwhile, resetForPrompt bumps
1192    // the epoch, and a round, wipe or cached approve written afterwards must
1193    // not land in that new prompt. `thisPrompt` guards those writes.
1194    const epoch = state.promptEpoch
1195    const thisPrompt = (value: CouncilSession | undefined): boolean => sessionOf(value).promptEpoch === epoch
1196    const fingerprint = await fingerprintOf(call.tool, call.input)
1197    const operation = operationOf(call, classification, where)
1198    const decided = classification.decided
1199    const shadow = isShadow(state, settings.shadowMode)
1200    const shownCall = `${call.tool}: ${callText(call, ctx.patterns)}`
hooks/audit.ts 127 lines
1import { redact } from './redact.js'
2
3/**
4 * The audit log: one JSONL line per gated call. It holds no file contents,
5 * diffs or secrets: the call itself appears only as its fingerprint, and the
6 * reasons are redacted and cut.
7 */
8
9export type Decision = 'allow-once' | 'allowlist' | 'keep-blocked' | 'instruction' | 'dismissed' | 'chat' | 'unavailable'
10
11export type AuditRecord = {
12  ts: string
13  tool: string
14  /** SHA-256 of the tool and its whitespace-collapsed arguments, cut to 16 hex digits. */
15  fingerprint: string
16  /** The operation key: what the call attempts, so rephrased retries share it. */
17  opKey: string | null
18  tier: string
19  ruleId: string | null
20  member: string | null
21  profile: string | null
22  model: string | null
23  verdict: string | null
24  reason: string | null
25  shadow: boolean
26  bypass: boolean
27  /** The user's answer, when the call was escalated. */
28  decision: Decision | null
29  /**
30   * What happened to the call: it ran, ran and errored, was refused by the
31   * council, was refused by the person at Claude Code's own permission
32   * prompt, or was denied by that check with nobody asked.
33   */
34  outcome: 'ran' | 'error' | 'refused' | 'refused-by-user' | 'denied-by-permission'
35  /** An identical call was approved earlier this prompt, so no reviewer ran. */
36  cached?: true
37  /**
38   * A big operation's full council: the entry that made it big, each member's
39   * own verdict (or `failed`, `skipped`) and tokens, each check's result. No output.
40   */
41  council?: {
42    entry: string
43    voices: readonly { member: string; profile: string | null; verdict: string; tokens: number }[]
44    checks: readonly { name: string; status: string; ms: number }[]
45  }
46  /** The whole call, from the hook's start to the result: tool run and the user's answers included. */
47  latencyMs: number
48  /** The model review alone (one member, or the full council), when one ran. */
49  reviewMs?: number
50  /** The id of the allow rule the user added for this call after allowing it once. */
51  ruleAdded?: string
52  tokens: number
53  /** Present for a subagent's call. */
54  agentId?: string
55}
56
57const MAX_REASON_CHARS = 300
58
59/** The one line written for a record, newline included. */
60export function auditLine(record: AuditRecord): string {
61  const reason =
62    record.reason === null
63      ? null
64      : (() => {
65          const clean = redact(record.reason).replace(/\s+/g, ' ').trim()
66          return clean.length > MAX_REASON_CHARS ? `${clean.slice(0, MAX_REASON_CHARS - 1)}…` : clean
67        })()
68  return `${JSON.stringify({ ...record, reason })}\n`
69}
70
71// Fields whose whitespace carries no meaning, so a reformatted value is the
72// same call. A shell command is one; a file's content is NOT (indentation is
73// significant in YAML, Python, Makefiles), so it is hashed exactly, or a
74// re-indented write would reuse an earlier approve without review.
75const INSIGNIFICANT_WHITESPACE = new Set(['command'])
76
77/** Sorts keys; collapses whitespace only in fields where it has no meaning. */
78function canonical(value: unknown, collapseWhitespace = false): unknown {
79  if (typeof value === 'string') return collapseWhitespace ? value.replace(/\s+/g, ' ').trim() : value
80  if (Array.isArray(value)) return value.map(item => canonical(item, collapseWhitespace))
81  if (typeof value === 'object' && value !== null) {
82    return Object.fromEntries(
83      Object.keys(value)
84        .sort()
85        .map(key => [key, canonical((value as Record<string, unknown>)[key], INSIGNIFICANT_WHITESPACE.has(key))]),
86    )
87  }
88  return value
89}
90
91/** The text a fingerprint hashes: tool name plus arguments, canonicalised. */
92export const fingerprintText = (tool: string, input: unknown): string => `${tool}\u0000${JSON.stringify(canonical(input))}`
93
94export async function fingerprintOf(tool: string, input: unknown): Promise<string> {
95  const bytes = new TextEncoder().encode(fingerprintText(tool, input))
96  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
97  return Array.from(digest.slice(0, 8), byte => byte.toString(16).padStart(2, '0')).join('')
98}
99
100export const ROTATED_FILES = 3
101
102/**
103 * The writes that append `line` to a log holding `current`: the new current
104 * file, plus the rotated files when the line would take it past `maxBytes`.
105 * `older` holds `<path>.1` .. `<path>.N-1` as read (undefined when absent).
106 */
107export function appendPlan(
108  path: string,
109  current: string,
110  line: string,
111  maxBytes: number,
112  older: readonly (string | undefined)[],
113): { path: string; text: string }[] {
114  const size = new TextEncoder().encode(current).length + new TextEncoder().encode(line).length
115  if (current === '' || size <= maxBytes) return [{ path, text: current + line }]
116  const writes: { path: string; text: string }[] = []
117  for (let n = ROTATED_FILES - 1; n >= 1; n--) {
118    const source = n === 1 ? current : older[n - 2]
119    if (source !== undefined) writes.push({ path: `${path}.${n}`, text: source })
120  }
121  writes.push({ path, text: line })
122  return writes
123}
124
125/** The `.gitignore` written beside the log, so it is never committed. */
126export const AUDIT_GITIGNORE = '# Written by the council mod: the audit log stays local.\n*\n'
127
hooks/config/defaults.ts 321 lines
1import type { Config, Rule } from './types.js'
2
3/**
4 * The shipped rules. Shell `command` patterns match one part of a compound
5 * command at a time, after quotes are removed and wrappers (`sudo`, `env`,
6 * `nice`, `timeout`, ...) and leading `NAME=value` assignments are stripped,
7 * with git's global options (`-C dir`, `-c k=v`) dropped. They are anchored
8 * at the program name.
9 *
10 * The rules are the safety boundary; the model members are a second opinion.
11 */
12
13const SHELL = ['Bash', 'Monitor'] as const
14const FILES = ['Write', 'Edit', 'NotebookEdit'] as const
15
16const BLOCK: readonly Rule[] = [
17  {
18    id: 'rm-recursive-outside-repo',
19    tier: 'block',
20    tools: SHELL,
21    check: 'rm-outside-repo',
22    reason:
23      'Recursive delete of the filesystem root, the home directory or a path outside the project.',
24  },
25  {
26    id: 'force-push-protected-branch',
27    tier: 'block',
28    tools: SHELL,
29    check: 'force-push-protected',
30    reason: 'Force push (or delete) of a protected branch.',
31  },
32  {
33    id: 'destructive-sql-production',
34    tier: 'block',
35    tools: SHELL,
36    check: 'destructive-sql-production',
37    reason: 'DROP or TRUNCATE against a target that looks like production.',
38  },
39  {
40    id: 'raw-disk-write',
41    tier: 'block',
42    tools: SHELL,
43    check: 'raw-disk-write',
44    reason: 'Writes directly to a disk device.',
45  },
46]
47
48const ASK: readonly Rule[] = [
49  {
50    id: 'privileged',
51    tier: 'ask',
52    tools: SHELL,
53    check: 'privileged',
54    reason: 'Runs with elevated privileges (sudo, doas, su).',
55  },
56]
57
58const REVIEW: readonly Rule[] = [
59  {
60    id: 'sql-file-write',
61    tier: 'review',
62    tools: FILES,
63    path: String.raw`\.sql$`,
64    member: 'aragorn',
65    profile: 'database',
66    reason: 'Writes or edits SQL.',
67  },
68  {
69    id: 'file-write',
70    tier: 'review',
71    tools: FILES,
72    member: 'legolas',
73    reason: 'Writes or edits a file.',
74  },
75  {
76    id: 'shell-delete',
77    tier: 'review',
78    tools: SHELL,
79    command: String.raw`^(rm|rmdir|unlink|shred|srm|trash|trash-put|rimraf|del-cli|trash-cli)(\s|$)`,
80    reason: 'Deletes files.',
81  },
82  {
83    id: 'shell-find-delete',
84    tier: 'review',
85    tools: SHELL,
86    command: String.raw`^find\s.*\s(-delete|-exec(dir)?\s|-ok(dir)?\s)`,
87    reason: 'Finds files and deletes or runs a command on each.',
88  },
89  {
90    id: 'shell-move',
91    tier: 'review',
92    tools: SHELL,
93    command: String.raw`^mv(\s|$)`,
94    reason: 'Moves or renames files, which can overwrite.',
95  },
96  {
97    id: 'shell-overwrite-tools',
98    tier: 'review',
99    tools: SHELL,
100    command: String.raw`^(dd|mkfs(\.\w+)?|truncate|wipefs|fdisk|parted|sfdisk)(\s|$)`,
101    reason: 'Overwrites or truncates data in place.',
102  },
103  {
104    id: 'shell-recursive-permissions',
105    tier: 'review',
106    tools: SHELL,
107    command: String.raw`^(chmod|chown|chgrp)\s(.*\s)?(-[a-zA-Z]*R[a-zA-Z]*|--recursive)(\s|$)`,
108    reason: 'Changes permissions or ownership recursively.',
109  },
110  {
111    id: 'shell-in-place-edit',
112    tier: 'review',
113    tools: SHELL,
114    command: String.raw`^(sed|perl|ruby)\s(.*\s)?(-[a-zA-Z]*i[a-zA-Z]*|--in-place)(\S*)(\s|$)`,
115    reason: 'Edits files in place.',
116  },
117  {
118    id: 'shell-redirect-write',
119    tier: 'review',
120    tools: SHELL,
121    check: 'redirect-write',
122    reason: 'Writes or overwrites a file through a shell redirect.',
123  },
124  {
125    id: 'shell-tee',
126    tier: 'review',
127    tools: SHELL,
128    command: String.raw`^tee(\s|$)`,
129    reason: 'Writes a file with tee.',
130  },
131  {
132    id: 'git-remote-or-history',
133    tier: 'review',
134    tools: SHELL,
135    command: String.raw`^git\s(push|merge|rebase|reset|tag|filter-branch|filter-repo|cherry-pick|revert|update-ref|replace)(\s|$)|^git\scommit\s(.*\s)?--amend(\s|$)`,
136    member: 'aragorn',
137    profile: 'git',
138    reason: 'Changes a remote or rewrites git history.',
139  },
140  {
141    id: 'git-config-write',
142    tier: 'review',
143    tools: SHELL,
144    check: 'git-config-write',
145    member: 'aragorn',
146    profile: 'git',
147    reason: 'Writes git configuration (.git/config), which can set hooks, a pager or an SSH command that runs code.',
148  },
149  {
150    id: 'git-config-injection',
151    tier: 'review',
152    tools: SHELL,
153    check: 'git-config-injection',
154    member: 'aragorn',
155    profile: 'git',
156    reason: 'Passes git a configuration key, exec-path or bisect command that can run arbitrary code.',
157  },
158  {
159    id: 'dangerous-env-assignment',
160    tier: 'review',
161    tools: SHELL,
162    check: 'dangerous-env-assignment',
163    reason: 'Sets an environment variable that changes what a later program loads or runs.',
164  },
165  {
166    id: 'git-discard',
167    tier: 'review',
168    tools: SHELL,
169    command: String.raw`^git\s(clean|restore|worktree\sremove)(\s|$)|^git\sstash\s(drop|clear)(\s|$)|^git\sbranch\s(.*\s)?-[a-zA-Z]*D[a-zA-Z]*(\s|$)|^git\sbranch\s(.*\s)?(--delete|-d)\s(.*\s)?(--force|-f)(\s|$)|^git\scheckout\s(.*\s)?(--|-f|--force|\.)(\s|$)|^git\sgc\s(.*\s)?--prune|^git\sreflog\s(expire|delete)(\s|$)`,
170    reason: 'Discards git work that may not be recoverable.',
171  },
172  {
173    id: 'database-client',
174    tier: 'review',
175    tools: SHELL,
176    command: String.raw`^(psql|pg_restore|dropdb|createdb|mysql|mariadb|mysqladmin|sqlite3|mongo|mongosh|mongorestore|redis-cli|cqlsh|sqlcmd|clickhouse(-client)?)(\s|$)`,
177    member: 'aragorn',
178    profile: 'database',
179    reason: 'Runs a database client.',
180  },
181  {
182    id: 'database-migration',
183    tier: 'review',
184    tools: SHELL,
185    command: String.raw`^((npx|bunx|pnpm(\sexec|\sdlx)?|yarn)\s)?(prisma\s(migrate|db\spush|db\sexecute)|knex\smigrate|sequelize(-cli)?\sdb:|typeorm\smigration|drizzle-kit\s(push|migrate|drop)|alembic\s(upgrade|downgrade|stamp)|flyway|liquibase|goose|dbmate|atlas\s(migrate|schema\sapply)|migrate\s)|^(python\d*(\.\d+)?\s)?(\S*/)?manage\.py\s(migrate|flush|sqlflush|reset_db)|^((bundle\sexec|bin/)\s?)?(rails|rake)\sdb:`,
186    member: 'aragorn',
187    profile: 'database',
188    reason: 'Runs a database migration.',
189  },
190  {
191    id: 'infrastructure',
192    tier: 'review',
193    tools: SHELL,
194    command: String.raw`^(terraform|tofu)\s(apply|destroy|import|taint|state\s(rm|mv|push))|^kubectl\s(delete|apply|replace|patch|scale|drain|cordon|rollout\sundo|set)|^helm\s(install|upgrade|uninstall|delete|rollback)|^docker\s((container|image|volume|network|system|builder)\s)?(rm|rmi|prune)|^docker[\s-]compose\s(.*\s)?down|^(aws|gcloud|az)\s.*\s(delete|remove|rm|destroy|terminate)`,
195    reason: 'Changes or deletes infrastructure.',
196  },
197  {
198    id: 'network-write',
199    tier: 'review',
200    tools: SHELL,
201    command: String.raw`^curl\s(.*\s)?(-X\s?(POST|PUT|PATCH|DELETE)|--request\s(POST|PUT|PATCH|DELETE)|-d|--data(-\w+)?|-F|--form|-T|--upload-file)(\s|$|=)|^wget\s(.*\s)?--(post|method|body)`,
202    reason: 'Sends data to a remote service.',
203  },
204  {
205    id: 'remote-copy',
206    tier: 'review',
207    tools: SHELL,
208    command: String.raw`^(scp|sftp)(\s|$)|^rsync\s(.*\s)?--(delete\S*|remove-source-files)(\s|$)`,
209    reason: 'Copies to another machine, or syncs with deletion.',
210  },
211  {
212    id: 'publish',
213    tier: 'review',
214    tools: SHELL,
215    command: String.raw`^(npm|pnpm|yarn)\s(.*\s)?publish(\s|$)|^cargo\spublish|^twine\supload|^gem\spush|^gh\s(release\s(create|delete|upload)|repo\s(delete|archive|edit)|pr\smerge|api\s.*-X\s?(POST|PUT|PATCH|DELETE))`,
216    reason: 'Publishes or changes something outside this machine.',
217  },
218  {
219    id: 'script-shell',
220    tier: 'review',
221    tools: SHELL,
222    command: String.raw`^(bash|sh|zsh|dash|ksh|fish|csh|tcsh)(\s|$)`,
223    reason: 'Runs a shell script, inline shell code or code piped into a shell.',
224  },
225  {
226    id: 'script-inline',
227    tier: 'review',
228    tools: SHELL,
229    command: String.raw`^(python\d*(\.\d+)?|node|nodejs|deno|bun|ruby|perl|php|lua|Rscript|osascript|pwsh|powershell)\s(.*\s)?(-c|-e|--eval|-p|--print|-r|-|-Command)(\s|$)`,
230    reason: 'Runs inline code.',
231  },
232  {
233    id: 'script-file',
234    tier: 'review',
235    tools: SHELL,
236    command: String.raw`^(python\d*(\.\d+)?|node|nodejs|deno(\srun)?|bun(\srun)?|ruby|perl|php|lua|Rscript|ts-node|tsx)\s(-\S+\s)*[^-\s]\S*\.(py|js|mjs|cjs|ts|mts|cts|rb|pl|php|lua|R)(\s|$)`,
237    reason: 'Runs a script file.',
238  },
239  {
240    id: 'script-path',
241    tier: 'review',
242    tools: SHELL,
243    command: String.raw`^[^\s/]*/\S*(\s|$)`,
244    reason: 'Runs a program or script by its path.',
245  },
246  {
247    id: 'script-eval',
248    tier: 'review',
249    tools: SHELL,
250    command: String.raw`^(eval|source|\.|xargs|parallel)(\s|$)|^psql\s(.*\s)?(-f|--file)(\s|=|$)`,
251    reason: 'Evaluates code built at run time or from a file (eval, source, xargs, parallel).',
252  },
253  {
254    id: 'remote-exec',
255    tier: 'review',
256    tools: SHELL,
257    command: String.raw`^(ssh|nc|ncat|netcat|telnet)(\s|$)|^rsync\s(.*\s)?(-e|--rsync-path)(\s|=)|^docker\s(.*\s)?(exec|run)(\s|$)|^docker[\s-]compose\s(.*\s)?(exec|run)(\s|$)|^(podman|nerdctl)\s(.*\s)?(exec|run)(\s|$)|^kubectl\s(.*\s)?exec(\s|$)|^(heroku|fly|flyctl)\s(.*\s)?(run|ssh)(\s|$)|^gcloud\s(.*\s)?ssh(\s|$)|^aws\s(.*\s)?(ssm\s(start-session|send-command))`,
258    reason: 'Runs a command on another machine or inside a container.',
259  },
260  {
261    id: 'sandbox-disabled',
262    tier: 'review',
263    tools: ['Bash'],
264    input: String.raw`"dangerouslyDisableSandbox":true`,
265    reason: 'Runs a command with the sandbox disabled.',
266  },
267  {
268    id: 'mcp-mutating',
269    tier: 'review',
270    tools: [String.raw`/^mcp__.+__.*(delete|remove|drop|merge|push|write|update|create|send|execute|exec|run|deploy|publish|destroy|purge|truncate|move|rename|upload|post|insert|modify|set)/i`],
271    reason: 'Calls an MCP tool whose name says it changes something.',
272  },
273]
274
275export const SHIPPED: Config = {
276  schemaVersion: 1,
277  rules: [...BLOCK, ...ASK, ...REVIEW],
278  disableRules: [],
279  protectedPaths: [
280    '**/.env',
281    '**/.env.*',
282    '.github/workflows/**',
283    '.gitlab-ci.yml',
284    '.circleci/**',
285    '.buildkite/**',
286    '**/Jenkinsfile',
287    '**/migrations/**',
288    'db/migrate/**',
289    '.git/**',
290    '.claude/council-of-elrond/**',
291    '.claude/settings.json',
292    '.claude/settings.local.json',
293  ],
294  protectedBranches: ['main', 'master', 'production', 'prod', 'release/*', 'trunk'],
295  productionPatterns: [
296    String.raw`(^|[^a-z0-9])prod(uction)?([^a-z0-9]|$)`,
297    String.raw`(^|[^a-z0-9])live([^a-z0-9]|$)`,
298    String.raw`(^|[^a-z0-9])primary([^a-z0-9]|$)`,
299  ],
300  models: {},
301  gollum: { patterns: [], allowlist: [] },
302  // A push, a merge into a protected branch, a migration: the full council.
303  bigOperations: [String.raw`/^git\s+push(\s|$)/`, 'merge-to-protected', 'database-migration'],
304  gimli: { commands: [] },
305}
306
307/**
308 * The built-in model per slot: the latest Sonnet as the floor, the latest
309 * Opus where the stakes are higher. Aliases resolve to the newest model of
310 * that family the running Claude Code knows.
311 */
312export const BUILT_IN_MODELS = {
313  gandalf: 'sonnet',
314  legolas: 'sonnet',
315  aragorn: 'opus',
316  council: 'opus',
317} as const
318
319/** The project overrides file, relative to the project root. */
320export const OVERRIDES_PATH = '.claude/council-of-elrond/rules.json'
321
hooks/config/schema.ts 525 lines
1import { globToRegExp, protectedMatcher, slashRegex, toolMatcher } from '../rules/globs.js'
2import type { ProtectedMatcher } from '../rules/globs.js'
3import { SHIPPED } from './defaults.js'
4import {
5  BIG_CHECKS,
6  CHECKS,
7  MEMBERS,
8  MODEL_SLOTS,
9  PROFILES,
10  SECRET_LEVELS,
11  TIERS,
12} from './types.js'
13import type {
14  BigCheck,
15  CheckName,
16  Config,
17  GimliCommand,
18  GollumPattern,
19  MemberName,
20  ModelSlot,
21  Overrides,
22  Profile,
23  Rule,
24  RuleSource,
25  Tier,
26} from './types.js'
27
28export const SCHEMA_VERSION = 1
29
30export type CompiledRule = Rule & {
31  source: RuleSource
32  matchesTool: (tool: string) => boolean
33  commandRe?: RegExp
34  pathRe?: RegExp
35  inputRe?: RegExp
36}
37
38/** A big-operation entry, compiled: which rule, which command pattern, or which check. */
39export type BigOperation =
40  | { kind: 'rule'; entry: string; ruleId: string }
41  | { kind: 'command'; entry: string; re: RegExp }
42  | { kind: 'check'; entry: string; check: BigCheck }
43
44export type CompiledConfig = {
45  config: Config
46  /** Project rules first, in file order, then the enabled shipped rules. */
47  rules: readonly CompiledRule[]
48  protectedPaths: readonly ProtectedMatcher[]
49  protectedBranches: readonly RegExp[]
50  production: readonly RegExp[]
51  bigOperations: readonly BigOperation[]
52}
53
54export type LoadedConfig = {
55  compiled: CompiledConfig
56  /** Where the effective config came from. */
57  origin: 'shipped' | 'shipped+project'
58  /** Problems with the overrides file, by field; non-empty means it was ignored. */
59  errors: readonly string[]
60}
61
62const TOP_KEYS = [
63  'schemaVersion',
64  'rules',
65  'disableRules',
66  'protectedPaths',
67  'protectedBranches',
68  'productionPatterns',
69  'models',
70  'gollum',
71  'bigOperations',
72  'gimli',
73] as const
74
75const GIMLI_KEYS = ['commands'] as const
76const GIMLI_COMMAND_KEYS = ['name', 'argv', 'timeoutMs'] as const
77
78/** A check command's timeout: 120 s unless set, between 1 s and 10 minutes (the most a process may run). */
79export const GIMLI_TIMEOUT = { default: 120_000, min: 1_000, max: 600_000 } as const
80
81const MAX_GIMLI_COMMANDS = 8
82
83const GOLLUM_KEYS = ['patterns', 'allowlist'] as const
84const GOLLUM_PATTERN_KEYS = ['id', 'level', 'regex', 'label'] as const
85
86/** A fingerprint as the dialog shows it; anything else in the allowlist is an exact string. */
87export const SECRET_FINGERPRINT = /^sha256:[0-9a-f]{16}$/
88
89const MAX_ALLOWLIST_ENTRY = 500
90
91const RULE_KEYS = [
92  'id',
93  'tier',
94  'tools',
95  'command',
96  'path',
97  'input',
98  'check',
99  'member',
100  'profile',
101  'reason',
102] as const
103
104export const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._\-[\]@:/]{0,99}$/
105
106const isObject = (value: unknown): value is Record<string, unknown> =>
107  typeof value === 'object' && value !== null && !Array.isArray(value)
108
109const includes = <T extends string>(list: readonly T[], value: unknown): value is T =>
110  typeof value === 'string' && (list as readonly string[]).includes(value)
111
112function regexError(source: unknown, flags = ''): string | undefined {
113  if (typeof source !== 'string' || source === '') return 'must be a non-empty regex string'
114  try {
115    new RegExp(source, flags)
116    return undefined
117  } catch (error) {
118    return `invalid regex: ${error instanceof Error ? error.message : String(error)}`
119  }
120}
121
122function stringList(value: unknown, field: string, errors: string[]): string[] {
123  if (!Array.isArray(value)) {
124    errors.push(`${field}: must be a list of strings`)
125    return []
126  }
127  value.forEach((item, index) => {
128    if (typeof item !== 'string' || item === '') {
129      errors.push(`${field}[${index}]: must be a non-empty string`)
130    }
131  })
132  return value.filter((item): item is string => typeof item === 'string' && item !== '')
133}
134
135function validateRule(raw: unknown, field: string, errors: string[]): Rule | undefined {
136  if (!isObject(raw)) {
137    errors.push(`${field}: must be an object`)
138    return undefined
139  }
140  const before = errors.length
141  for (const key of Object.keys(raw)) {
142    if (!includes(RULE_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
143  }
144  if (typeof raw.id !== 'string' || raw.id === '') errors.push(`${field}.id: required string`)
145  if (!includes(TIERS, raw.tier)) errors.push(`${field}.tier: one of ${TIERS.join(', ')}`)
146  const tools = stringList(raw.tools, `${field}.tools`, errors)
147  if (Array.isArray(raw.tools) && raw.tools.length === 0) {
148    errors.push(`${field}.tools: name at least one tool`)
149  }
150  tools.forEach((pattern, index) => {
151    try {
152      toolMatcher(pattern)
153    } catch (error) {
154      errors.push(`${field}.tools[${index}]: ${error instanceof Error ? error.message : String(error)}`)
155    }
156  })
157  for (const key of ['command', 'path', 'input'] as const) {
158    if (raw[key] !== undefined) {
159      const problem = regexError(raw[key])
160      if (problem !== undefined) errors.push(`${field}.${key}: ${problem}`)
161    }
162  }
163  if (raw.check !== undefined && !includes(CHECKS, raw.check)) {
164    errors.push(`${field}.check: one of ${CHECKS.join(', ')}`)
165  }
166  if (raw.member !== undefined && !includes(MEMBERS, raw.member)) {
167    errors.push(`${field}.member: one of ${MEMBERS.join(', ')}`)
168  }
169  if (raw.profile !== undefined) {
170    if (!includes(PROFILES, raw.profile)) {
171      errors.push(`${field}.profile: one of ${PROFILES.join(', ')}`)
172    } else if (raw.member !== 'aragorn') {
173      errors.push(`${field}.profile: only Aragorn (member "aragorn") has profiles`)
174    }
175  }
176  if (typeof raw.reason !== 'string' || raw.reason.trim() === '') {
177    errors.push(`${field}.reason: required string`)
178  }
179  if (errors.length > before) return undefined
180  return {
181    id: raw.id as string,
182    tier: raw.tier as Tier,
183    tools,
184    ...(raw.command !== undefined && { command: raw.command as string }),
185    ...(raw.path !== undefined && { path: raw.path as string }),
186    ...(raw.input !== undefined && { input: raw.input as string }),
187    ...(raw.check !== undefined && { check: raw.check as CheckName }),
188    ...(raw.member !== undefined && { member: raw.member as MemberName }),
189    ...(raw.profile !== undefined && { profile: raw.profile as Profile }),
190    reason: (raw.reason as string).trim(),
191  }
192}
193
194function validateGollum(raw: unknown, errors: string[]): Overrides['gollum'] {
195  if (!isObject(raw)) {
196    errors.push('gollum: must be an object')
197    return undefined
198  }
199  for (const key of Object.keys(raw)) {
200    if (!includes(GOLLUM_KEYS, key)) errors.push(`gollum.${key}: unknown field`)
201  }
202  const out: { patterns?: GollumPattern[]; allowlist?: string[] } = {}
203  if (raw.patterns !== undefined) {
204    if (!Array.isArray(raw.patterns)) {
205      errors.push('gollum.patterns: must be a list')
206    } else {
207      const seen = new Set<string>()
208      out.patterns = []
209      raw.patterns.forEach((item, index) => {
210        const field = `gollum.patterns[${index}]`
211        if (!isObject(item)) {
212          errors.push(`${field}: must be an object`)
213          return
214        }
215        const before = errors.length
216        for (const key of Object.keys(item)) {
217          if (!includes(GOLLUM_PATTERN_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
218        }
219        if (typeof item.id !== 'string' || item.id === '') errors.push(`${field}.id: required string`)
220        else if (seen.has(item.id)) errors.push(`${field}.id: "${item.id}" is used twice`)
221        if (!includes(SECRET_LEVELS, item.level)) errors.push(`${field}.level: one of ${SECRET_LEVELS.join(', ')}`)
222        const problem = regexError(item.regex, 'g')
223        if (problem !== undefined) errors.push(`${field}.regex: ${problem}`)
224        else if (new RegExp(item.regex as string).test('')) errors.push(`${field}.regex: must not match empty text`)
225        if (typeof item.label !== 'string' || item.label.trim() === '') errors.push(`${field}.label: required string`)
226        if (errors.length > before) return
227        seen.add(item.id as string)
228        out.patterns?.push({
229          id: item.id as string,
230          level: item.level as GollumPattern['level'],
231          regex: item.regex as string,
232          label: (item.label as string).trim(),
233        })
234      })
235    }
236  }
237  if (raw.allowlist !== undefined) {
238    const entries = stringList(raw.allowlist, 'gollum.allowlist', errors)
239    entries.forEach((entry, index) => {
240      if (entry.length > MAX_ALLOWLIST_ENTRY) errors.push(`gollum.allowlist[${index}]: longer than ${MAX_ALLOWLIST_ENTRY} characters`)
241      else if (!SECRET_FINGERPRINT.test(entry) && entry.length < 6) {
242        errors.push(`gollum.allowlist[${index}]: an exact secret of at least 6 characters, or a sha256: fingerprint`)
243      }
244    })
245    out.allowlist = entries
246  }
247  return out
248}
249
250function validateGimli(raw: unknown, errors: string[]): Overrides['gimli'] {
251  if (!isObject(raw)) {
252    errors.push('gimli: must be an object')
253    return undefined
254  }
255  for (const key of Object.keys(raw)) {
256    if (!includes(GIMLI_KEYS, key)) errors.push(`gimli.${key}: unknown field`)
257  }
258  if (raw.commands === undefined) return {}
259  if (!Array.isArray(raw.commands)) {
260    errors.push('gimli.commands: must be a list')
261    return undefined
262  }
263  if (raw.commands.length > MAX_GIMLI_COMMANDS) errors.push(`gimli.commands: at most ${MAX_GIMLI_COMMANDS} commands`)
264  const seen = new Set<string>()
265  const commands: GimliCommand[] = []
266  raw.commands.forEach((item, index) => {
267    const field = `gimli.commands[${index}]`
268    if (!isObject(item)) {
269      errors.push(`${field}: must be an object`)
270      return
271    }
272    const before = errors.length
273    for (const key of Object.keys(item)) {
274      if (!includes(GIMLI_COMMAND_KEYS, key)) errors.push(`${field}.${key}: unknown field`)
275    }
276    if (typeof item.name !== 'string' || item.name.trim() === '') errors.push(`${field}.name: required string`)
277    else if (seen.has(item.name.trim())) errors.push(`${field}.name: "${item.name.trim()}" is used twice`)
278    if (!Array.isArray(item.argv) || item.argv.length === 0) {
279      errors.push(`${field}.argv: a non-empty list of strings (the command and its arguments; no shell)`)
280    } else {
281      item.argv.forEach((word, i) => {
282        if (typeof word !== 'string' || (i === 0 && word.trim() === '')) errors.push(`${field}.argv[${i}]: must be a${i === 0 ? ' non-empty' : ''} string`)
283      })
284    }
285    const timeout = item.timeoutMs
286    if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout < GIMLI_TIMEOUT.min || timeout > GIMLI_TIMEOUT.max)) {
287      errors.push(`${field}.timeoutMs: a whole number of milliseconds from ${GIMLI_TIMEOUT.min} to ${GIMLI_TIMEOUT.max}`)
288    }
289    if (errors.length > before) return
290    const name = (item.name as string).trim()
291    seen.add(name)
292    commands.push({ name, argv: [...(item.argv as string[])], timeoutMs: (timeout as number | undefined) ?? GIMLI_TIMEOUT.default })
293  })
294  return { commands }
295}
296
297/**
298 * A big-operation entry: `/regex/flags`, a named check, or a rule id the
299 * merged config holds (shipped, or the file's own).
300 */
301function validateBigOperations(raw: unknown, ruleIds: ReadonlySet<string>, errors: string[]): string[] {
302  const entries = stringList(raw, 'bigOperations', errors)
303  entries.forEach((entry, index) => {
304    const field = `bigOperations[${index}]`
305    if (entry.startsWith('/')) {
306      try {
307        if (slashRegex(entry) === undefined) errors.push(`${field}: a regex is written /source/flags`)
308      } catch (error) {
309        errors.push(`${field}: invalid regex: ${error instanceof Error ? error.message : String(error)}`)
310      }
311    } else if (!includes(BIG_CHECKS, entry) && !ruleIds.has(entry)) {
312      errors.push(`${field}: "${entry}" is not a rule id, a /regex/ or one of ${BIG_CHECKS.join(', ')}`)
313    }
314  })
315  return entries
316}
317
318/**
319 * Validates the parsed overrides file against this build's schema, reporting
320 * every problem by field. Any problem means the whole file is ignored.
321 */
322export function validateOverrides(
323  raw: unknown,
324  shipped: Config = SHIPPED,
325): { overrides: Overrides; errors: [] } | { overrides: undefined; errors: string[] } {
326  const errors: string[] = []
327  if (!isObject(raw)) {
328    return { overrides: undefined, errors: ['(file): must be a JSON object'] }
329  }
330  if (raw.schemaVersion !== SCHEMA_VERSION) {
331    return {
332      overrides: undefined,
333      errors: [
334        `schemaVersion: ${JSON.stringify(raw.schemaVersion)} is not a version this build reads (${SCHEMA_VERSION})`,
335      ],
336    }
337  }
338  for (const key of Object.keys(raw)) {
339    if (!includes(TOP_KEYS, key)) errors.push(`${key}: unknown field`)
340  }
341
342  const shippedIds = new Map(shipped.rules.map(rule => [rule.id, rule]))
343  const overrides: Overrides = { schemaVersion: 1 }
344
345  if (raw.rules !== undefined) {
346    if (!Array.isArray(raw.rules)) {
347      errors.push('rules: must be a list')
348    } else {
349      const seen = new Set<string>()
350      const rules: Rule[] = []
351      raw.rules.forEach((item, index) => {
352        const rule = validateRule(item, `rules[${index}]`, errors)
353        if (rule === undefined) return
354        if (seen.has(rule.id)) errors.push(`rules[${index}].id: "${rule.id}" is used twice`)
355        if (includes(BIG_CHECKS, rule.id)) errors.push(`rules[${index}].id: "${rule.id}" is the name of a big-operation check`)
356        if (shippedIds.has(rule.id)) {
357          errors.push(
358            `rules[${index}].id: "${rule.id}" is a shipped rule's id; disable it with disableRules and give yours another id`,
359          )
360        }
361        seen.add(rule.id)
362        rules.push(rule)
363      })
364      overrides.rules = rules
365    }
366  }
367
368  if (raw.disableRules !== undefined) {
369    const ids = stringList(raw.disableRules, 'disableRules', errors)
370    ids.forEach((id, index) => {
371      const rule = shippedIds.get(id)
372      if (rule === undefined) {
373        errors.push(`disableRules[${index}]: "${id}" is not a shipped rule`)
374      } else if (rule.tier === 'block') {
375        errors.push(`disableRules[${index}]: "${id}" is a block rule; block rules cannot be disabled`)
376      }
377    })
378    overrides.disableRules = ids
379  }
380
381  if (raw.protectedPaths !== undefined) {
382    overrides.protectedPaths = stringList(raw.protectedPaths, 'protectedPaths', errors)
383  }
384  if (raw.protectedBranches !== undefined) {
385    overrides.protectedBranches = stringList(raw.protectedBranches, 'protectedBranches', errors)
386  }
387  if (raw.productionPatterns !== undefined) {
388    const patterns = stringList(raw.productionPatterns, 'productionPatterns', errors)
389    patterns.forEach((pattern, index) => {
390      const problem = regexError(pattern, 'i')
391      if (problem !== undefined) errors.push(`productionPatterns[${index}]: ${problem}`)
392    })
393    overrides.productionPatterns = patterns
394  }
395
396  if (raw.models !== undefined) {
397    if (!isObject(raw.models)) {
398      errors.push('models: must be an object')
399    } else {
400      const models: Partial<Record<ModelSlot, string>> = {}
401      for (const [slot, model] of Object.entries(raw.models)) {
402        if (!includes(MODEL_SLOTS, slot)) {
403          errors.push(`models.${slot}: unknown slot (one of ${MODEL_SLOTS.join(', ')})`)
404        } else if (typeof model !== 'string' || !MODEL_ID.test(model)) {
405          errors.push(`models.${slot}: must be a model alias or id`)
406        } else {
407          models[slot] = model
408        }
409      }
410      overrides.models = models
411    }
412  }
413
414  if (raw.gollum !== undefined) {
415    const gollum = validateGollum(raw.gollum, errors)
416    if (gollum !== undefined) overrides.gollum = gollum
417  }
418
419  if (raw.bigOperations !== undefined) {
420    const ruleIds = new Set([...shippedIds.keys(), ...(overrides.rules ?? []).map(rule => rule.id)])
421    overrides.bigOperations = validateBigOperations(raw.bigOperations, ruleIds, errors)
422  }
423
424  if (raw.gimli !== undefined) {
425    const gimli = validateGimli(raw.gimli, errors)
426    if (gimli !== undefined) overrides.gimli = gimli
427  }
428
429  return errors.length > 0
430    ? { overrides: undefined, errors }
431    : { overrides, errors: [] }
432}
433
434const union = (a: readonly string[], b: readonly string[] = []): string[] => [
435  ...new Set([...a, ...b]),
436]
437
438/**
439 * Overrides win: project rules are tried before shipped ones, and project
440 * models stand. Lists only add to the shipped ones, so a project can widen
441 * the protection but never narrow it; block rules cannot be disabled.
442 */
443export function mergeConfig(shipped: Config, overrides: Overrides): Config {
444  const disabled = new Set(overrides.disableRules ?? [])
445  return {
446    schemaVersion: 1,
447    rules: [
448      ...(overrides.rules ?? []),
449      ...shipped.rules.filter(rule => rule.tier === 'block' || !disabled.has(rule.id)),
450    ],
451    disableRules: [...disabled],
452    protectedPaths: union(shipped.protectedPaths, overrides.protectedPaths),
453    protectedBranches: union(shipped.protectedBranches, overrides.protectedBranches),
454    productionPatterns: union(shipped.productionPatterns, overrides.productionPatterns),
455    models: { ...shipped.models, ...overrides.models },
456    gollum: {
457      patterns: [...shipped.gollum.patterns, ...(overrides.gollum?.patterns ?? [])],
458      allowlist: union(shipped.gollum.allowlist, overrides.gollum?.allowlist),
459    },
460    bigOperations: union(shipped.bigOperations, overrides.bigOperations),
461    gimli: { commands: [...shipped.gimli.commands, ...(overrides.gimli?.commands ?? [])] },
462  }
463}
464
465function compileBigOperation(entry: string): BigOperation {
466  const re = slashRegex(entry)
467  if (re !== undefined) return { kind: 'command', entry, re }
468  return includes(BIG_CHECKS, entry) ? { kind: 'check', entry, check: entry } : { kind: 'rule', entry, ruleId: entry }
469}
470
471const branchToRegExp = (branch: string): RegExp => globToRegExp(branch)
472
473export function compileConfig(config: Config, projectRuleIds: ReadonlySet<string>): CompiledConfig {
474  const rules = config.rules.map((rule): CompiledRule => {
475    const matchers = rule.tools.map(toolMatcher)
476    return {
477      ...rule,
478      source: projectRuleIds.has(rule.id) ? 'project' : 'shipped',
479      matchesTool: tool => matchers.some(matches => matches(tool)),
480      ...(rule.command !== undefined && { commandRe: new RegExp(rule.command) }),
481      ...(rule.path !== undefined && { pathRe: new RegExp(rule.path) }),
482      ...(rule.input !== undefined && { inputRe: new RegExp(rule.input) }),
483    }
484  })
485  return {
486    config,
487    rules,
488    protectedPaths: config.protectedPaths.map(protectedMatcher),
489    protectedBranches: config.protectedBranches.map(branchToRegExp),
490    production: config.productionPatterns.map(pattern => new RegExp(pattern, 'i')),
491    bigOperations: config.bigOperations.map(compileBigOperation),
492  }
493}
494
495/**
496 * The effective config from the overrides file's text (undefined: no file).
497 * A file that does not parse or validate is ignored whole, and the shipped
498 * defaults stand, with the errors reported. It never turns the gate off.
499 */
500export function loadConfig(fileText: string | undefined, shipped: Config = SHIPPED): LoadedConfig {
501  const shippedOnly = (errors: readonly string[]): LoadedConfig => ({
502    compiled: compileConfig(shipped, new Set()),
503    origin: 'shipped',
504    errors,
505  })
506  if (fileText === undefined) return shippedOnly([])
507
508  let raw: unknown
509  try {
510    raw = JSON.parse(fileText)
511  } catch (error) {
512    return shippedOnly([`(file): not valid JSON: ${error instanceof Error ? error.message : String(error)}`])
513  }
514  const result = validateOverrides(raw, shipped)
515  if (result.overrides === undefined) return shippedOnly(result.errors)
516
517  const merged = mergeConfig(shipped, result.overrides)
518  const projectIds = new Set((result.overrides.rules ?? []).map(rule => rule.id))
519  try {
520    return { compiled: compileConfig(merged, projectIds), origin: 'shipped+project', errors: [] }
521  } catch (error) {
522    return shippedOnly([`(file): ${error instanceof Error ? error.message : String(error)}`])
523  }
524}
525
hooks/config/types.ts 133 lines
1/**
2 * The council's configuration: the shipped defaults (defaults.ts) merged with
3 * the project overrides file (.claude/council-of-elrond/rules.json).
4 */
5
6/** Tiers, least strict first: the strictest across a call's parts wins. */
7export const TIERS = ['allow', 'review', 'ask', 'block'] as const
8export type Tier = (typeof TIERS)[number]
9
10export const MEMBERS = ['gandalf', 'legolas', 'aragorn'] as const
11export type MemberName = (typeof MEMBERS)[number]
12
13export const PROFILES = ['git', 'database'] as const
14export type Profile = (typeof PROFILES)[number]
15
16/** Model slots: one per model member, plus the full council's. */
17export const MODEL_SLOTS = ['gandalf', 'legolas', 'aragorn', 'council'] as const
18export type ModelSlot = (typeof MODEL_SLOTS)[number]
19
20/**
21 * Checks written in code, for what a pattern cannot say safely: they read the
22 * parsed words of a shell part rather than its text.
23 */
24export const CHECKS = [
25  'rm-outside-repo',
26  'force-push-protected',
27  'destructive-sql-production',
28  'privileged',
29  'redirect-write',
30  'git-config-write',
31  'git-config-injection',
32  'dangerous-env-assignment',
33  'raw-disk-write',
34] as const
35export type CheckName = (typeof CHECKS)[number]
36
37export type Rule = {
38  /** Unique id, shown in /council rules and the audit log. */
39  id: string
40  tier: Tier
41  /**
42   * The tools the rule covers: an exact name (`Bash`), a glob (`mcp__*`), or a
43   * regex written `/source/flags`.
44   */
45  tools: readonly string[]
46  /** Regex matched against each part of a shell command (Bash, Monitor). */
47  command?: string
48  /** Regex matched against the call's file path, relative to the project root. */
49  path?: string
50  /** Regex matched against the call's input as JSON. */
51  input?: string
52  /** A check in code (CHECKS) that must also hold. */
53  check?: CheckName
54  /** Who reviews a `review` match; Gandalf when absent. */
55  member?: MemberName
56  profile?: Profile
57  /** Plain-English reason, given to Claude and to you. Never the pattern. */
58  reason: string
59}
60
61export type Config = {
62  schemaVersion: 1
63  rules: readonly Rule[]
64  /** Ids of shipped non-block rules to switch off. */
65  disableRules: readonly string[]
66  /** Globs, relative to the project root: always the ask tier. */
67  protectedPaths: readonly string[]
68  /** Branch names or globs: force pushes to them are blocked. */
69  protectedBranches: readonly string[]
70  /** Regexes (case-insensitive) that make a target look like production. */
71  productionPatterns: readonly string[]
72  /** Per-slot model: an alias (`sonnet`, `opus`, `fable`) or a full id. */
73  models: Readonly<Partial<Record<ModelSlot, string>>>
74  /** The secrets scan's extra patterns and its allowlist. */
75  gollum: GollumConfig
76  /**
77   * What goes to the full council instead of one member: a rule id, a command
78   * regex written `/source/flags` (matched against each shell part), or a
79   * named check (BIG_CHECKS).
80   */
81  bigOperations: readonly string[]
82  /** The project's own checks the full council runs (tests, lint, typecheck). */
83  gimli: GimliConfig
84}
85
86/** Big-operation entries decided in code: a merge whose target is a protected branch. */
87export const BIG_CHECKS = ['merge-to-protected'] as const
88export type BigCheck = (typeof BIG_CHECKS)[number]
89
90export type GimliCommand = {
91  /** Shown to the user and to Claude (`tests`, `lint`). */
92  name: string
93  /** The command by its argument vector: no shell. */
94  argv: readonly string[]
95  /** How long it may run, in milliseconds (default 120 s, at most 600 s). */
96  timeoutMs: number
97}
98
99export type GimliConfig = {
100  commands: readonly GimliCommand[]
101}
102
103export const SECRET_LEVELS = ['high', 'low'] as const
104
105export type GollumPattern = {
106  id: string
107  level: (typeof SECRET_LEVELS)[number]
108  /** Regex source, matched globally against what the call would write or run. */
109  regex: string
110  /** What the finding is called in the dialog and refusal (`deploy key`). */
111  label: string
112}
113
114export type GollumConfig = {
115  patterns: readonly GollumPattern[]
116  /** Exact secret strings, or `sha256:` fingerprints the dialog shows. Never regexes. */
117  allowlist: readonly string[]
118}
119
120/** What the project overrides file may hold: every field optional. */
121export type Overrides = Partial<Omit<Config, 'schemaVersion' | 'gollum' | 'gimli'>> & {
122  schemaVersion: 1
123  gollum?: Partial<GollumConfig>
124  gimli?: Partial<GimliConfig>
125}
126
127export type RuleSource = 'shipped' | 'project'
128
129export const strictness = (tier: Tier): number => TIERS.indexOf(tier)
130
131export const stricter = (a: Tier, b: Tier): Tier =>
132  strictness(a) >= strictness(b) ? a : b
133
hooks/config/write.ts 59 lines
1import { validateOverrides } from './schema.js'
2import type { Rule } from './types.js'
3
4/**
5 * Edits to the project overrides file the mod makes itself, only after the
6 * user confirmed the exact entry. Pure: the file's text in, the new text out.
7 * A file that does not read as a valid overrides file is never rewritten, and
8 * an edit whose result does not validate is not made.
9 */
10
11export type FileEdit = { ok: true; text: string } | { ok: false; problem: string }
12
13type Raw = Record<string, unknown>
14
15const isObject = (value: unknown): value is Raw => typeof value === 'object' && value !== null && !Array.isArray(value)
16
17/**
18 * Reads the file (none, or blank: a new one), applies `change` to the parsed
19 * object, validates the result and writes it back pretty-printed. Spreading
20 * the parsed object keeps the user's key order; new keys go last.
21 */
22export function editOverrides(fileText: string | undefined, change: (raw: Raw) => Raw): FileEdit {
23  let raw: Raw
24  if (fileText === undefined || fileText.trim() === '') {
25    raw = { schemaVersion: 1 }
26  } else {
27    let parsed: unknown
28    try {
29      parsed = JSON.parse(fileText)
30    } catch {
31      return { ok: false, problem: 'the rules file is not valid JSON' }
32    }
33    if (!isObject(parsed)) return { ok: false, problem: 'the rules file is not a JSON object' }
34    raw = parsed
35  }
36  const before = validateOverrides(raw)
37  if (before.overrides === undefined) return { ok: false, problem: `the rules file has errors (${before.errors.join('; ')})` }
38  const next = change(raw)
39  const after = validateOverrides(next)
40  if (after.overrides === undefined) return { ok: false, problem: `the entry does not validate (${after.errors.join('; ')})` }
41  return { ok: true, text: `${JSON.stringify(next, null, 2)}\n` }
42}
43
44/** Adds one entry to `gollum.allowlist`, keeping the rest of the file and its key order. */
45export const withAllowlistEntry = (fileText: string | undefined, entry: string): FileEdit =>
46  editOverrides(fileText, raw => {
47    const gollum = isObject(raw.gollum) ? raw.gollum : {}
48    const allowlist = Array.isArray(gollum.allowlist) ? (gollum.allowlist as unknown[]) : []
49    return { ...raw, gollum: { ...gollum, allowlist: allowlist.includes(entry) ? allowlist : [...allowlist, entry] } }
50  })
51
52/**
53 * Adds one rule after the file's own rules: rules the user wrote decide
54 * first. An id already taken (the file changed since the rule was offered)
55 * fails validation, and nothing is written.
56 */
57export const withRule = (fileText: string | undefined, rule: Rule): FileEdit =>
58  editOverrides(fileText, raw => ({ ...raw, rules: [...(Array.isArray(raw.rules) ? (raw.rules as unknown[]) : []), rule] }))
59
hooks/elrond/commands.ts 294 lines
1import type { CouncilSession } from '../../types'
2import type { CompiledConfig } from '../config/schema.js'
3import { MEMBERS, MODEL_SLOTS, TIERS } from '../config/types.js'
4import type { MemberName, ModelSlot } from '../config/types.js'
5import { whoOf } from '../members/brief.js'
6import type { Classification } from '../rules/classify.js'
7import type { Big, CouncilSeat } from './council.js'
8import { councilSeatId } from './council.js'
9import { median } from '../state.js'
10import { text } from '../strings.js'
11import type { StringKey } from '../strings.js'
12import type { ModelChoice } from './models.js'
13import type { Operation } from './operations.js'
14import { attemptsOf } from './operations.js'
15import type { Route, Seat } from './routing.js'
16
17/**
18 * `/council`: one command, its subcommands parsed from the raw argument
19 * string, and its output built as lines. The output is for the user only:
20 * register.ts draws it in a pane or logs it, and never hands it to Claude.
21 */
22
23export type CouncilCommand =
24  | { kind: 'status' }
25  | { kind: 'bypass'; on: boolean }
26  | { kind: 'shadow'; on: boolean }
27  | { kind: 'log'; count: number }
28  | { kind: 'rules' }
29  | { kind: 'test'; command: string }
30  | { kind: 'models' }
31  | { kind: 'model'; slot: string; model: string; save: boolean }
32  | { kind: 'reload' }
33  | { kind: 'report' }
34  | { kind: 'debate' }
35  | { kind: 'usage'; key: StringKey; params?: Record<string, string> }
36
37export type Output = { title: string; lines: string[] }
38
39const DEFAULT_LOG = 10
40const MAX_LOG = 50
41
42/** Splits on spaces, keeping single- or double-quoted runs whole. */
43export function words(args: string): string[] {
44  const out: string[] = []
45  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
46  for (const match of args.matchAll(re)) out.push(match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : (match[2] ?? match[3] ?? ''))
47  return out
48}
49
50export function parseCouncil(args: string): CouncilCommand {
51  const trimmed = args.trim()
52  const [sub = '', ...rest] = words(trimmed)
53  switch (sub.toLowerCase()) {
54    case '':
55    case 'status':
56      return { kind: 'status' }
57    case 'on':
58      return { kind: 'bypass', on: false }
59    case 'off':
60      return { kind: 'bypass', on: true }
61    case 'shadow': {
62      const value = (rest[0] ?? '').toLowerCase()
63      return value === 'on' || value === 'off' ? { kind: 'shadow', on: value === 'on' } : { kind: 'usage', key: 'cmd.shadowUsage' }
64    }
65    case 'log': {
66      const n = Number(rest[0] ?? DEFAULT_LOG)
67      return { kind: 'log', count: Number.isInteger(n) && n > 0 ? Math.min(n, MAX_LOG) : DEFAULT_LOG }
68    }
69    case 'rules':
70      return { kind: 'rules' }
71    case 'test': {
72      // Everything after `test`, one layer of quotes removed: the command as typed.
73      const raw = trimmed.slice(trimmed.indexOf(sub) + sub.length).trim()
74      const command = /^(["'])([\s\S]*)\1$/.exec(raw)?.[2] ?? raw
75      return command === '' ? { kind: 'usage', key: 'cmd.testUsage' } : { kind: 'test', command }
76    }
77    case 'model':
78    case 'models': {
79      if (rest.length === 0) return { kind: 'models' }
80      const save = rest.includes('--save')
81      const [slot, model] = rest.filter(word => word !== '--save')
82      if (slot === undefined || model === undefined) return { kind: 'usage', key: 'cmd.modelUsage', params: { slots: MODEL_SLOTS.join(', ') } }
83      return { kind: 'model', slot: slot.toLowerCase(), model, save }
84    }
85    case 'reload':
86      return { kind: 'reload' }
87    case 'report':
88      return { kind: 'report' }
89    case 'debate':
90      return { kind: 'debate' }
91    default:
92      return { kind: 'usage', key: 'cmd.unknown', params: { sub } }
93  }
94}
95
96export const isSlot = (value: string): value is ModelSlot => (MODEL_SLOTS as readonly string[]).includes(value)
97
98export const WHO_OF_SLOT: Readonly<Record<ModelSlot, StringKey>> = {
99  gandalf: 'who.gandalf',
100  legolas: 'who.legolas',
101  aragorn: 'who.aragorn',
102  council: 'who.council',
103}
104
105export type StatusInput = {
106  session: CouncilSession
107  mode: 'enforcing' | 'shadow' | 'bypass'
108  members: Readonly<Record<MemberName, { enabled: boolean; choice: ModelChoice }>>
109  council: { enabled: boolean; choice: ModelChoice; sequential: boolean }
110  gimli: { enabled: boolean; commands: number }
111  gollumEnabled: boolean
112  galadrielEnabled: boolean
113  tokenBudget: number
114}
115
116const ZERO_COUNTS = { approved: 0, revised: 0, blocked: 0, failed: 0 }
117
118export function statusOutput(input: StatusInput): Output {
119  const { session } = input
120  const attempts = attemptsOf(session)
121  const typical = median(session.reviewMs)
122  const onOff = (on: boolean): string => text(on ? 'cmd.on' : 'cmd.off')
123  return {
124    title: text('cmd.title'),
125    lines: [
126      text('cmd.mode', { mode: text(`cmd.mode.${input.mode}`) }),
127      text('cmd.members'),
128      ...MEMBERS.map(member =>
129        text('cmd.member', {
130          who: text(whoOf(member)),
131          id: member,
132          state: onOff(input.members[member].enabled),
133          model: input.members[member].choice.model,
134          source: input.members[member].choice.source,
135          ...(session.counts[member] ?? ZERO_COUNTS),
136        }),
137      ),
138      text('cmd.council', {
139        who: text('who.fullCouncil'),
140        state: onOff(input.council.enabled),
141        model: input.council.choice.model,
142        source: input.council.choice.source,
143        order: text(input.council.sequential ? 'cmd.council.sequential' : 'cmd.council.parallel'),
144        ...(session.counts.council ?? ZERO_COUNTS),
145      }),
146      text('cmd.gimli', {
147        who: text('who.gimli'),
148        state: onOff(input.gimli.enabled),
149        count: input.gimli.commands,
150        ...(session.counts.gimli ?? ZERO_COUNTS),
151      }),
152      text('cmd.memberCode', { who: text('who.gollum'), state: onOff(input.gollumEnabled) }),
153      text('cmd.memberCode', { who: text('who.galadriel'), state: onOff(input.galadrielEnabled) }),
154      text('cmd.attempts', attempts),
155      text('cmd.tokens', { spent: session.tokensSpent, budget: input.tokenBudget }),
156      typical === undefined
157        ? text('cmd.noReviews')
158        : text('cmd.median', { time: `${(typical / 1000).toFixed(1)} s`, reviews: session.reviewMs.length }),
159    ],
160  }
161}
162
163/** The last `count` audit lines, newest last. Unparseable lines are skipped. */
164export function logOutput(logText: string, count: number): Output {
165  const records = logText
166    .split('\n')
167    .filter(line => line.trim() !== '')
168    .flatMap(line => {
169      try {
170        return [JSON.parse(line) as Record<string, unknown>]
171      } catch {
172        return []
173      }
174    })
175    .slice(-count)
176  if (records.length === 0) return { title: text('cmd.title'), lines: [text('cmd.logEmpty')] }
177  const str = (value: unknown, fallback = '-'): string => (typeof value === 'string' && value !== '' ? value : fallback)
178  return {
179    title: text('cmd.logTitle', { n: records.length }),
180    lines: records.map(record =>
181      text('cmd.logLine', {
182        ts: str(record.ts).replace('T', ' ').slice(0, 19),
183        tool: str(record.tool),
184        tier: str(record.tier),
185        who: str(record.member, record.tier === 'block' ? 'rules' : '-'),
186        verdict: str(record.verdict, '-') + (record.shadow === true ? ' (shadow)' : '') + (record.cached === true ? ' (cached)' : ''),
187        decision: typeof record.decision === 'string' ? `, you: ${record.decision}` : '',
188        outcome: str(record.outcome),
189        reason: str(record.reason, ''),
190      }),
191    ),
192  }
193}
194
195export function rulesOutput(compiled: CompiledConfig, origin: string, errors: readonly string[]): Output {
196  const rules = [...compiled.rules].sort((a, b) => TIERS.indexOf(b.tier) - TIERS.indexOf(a.tier))
197  const list = (key: StringKey, items: readonly string[]): string[] =>
198    items.length === 0 ? [] : [text('cmd.listLine', { name: text(key), items: items.join(', ') })]
199  return {
200    title: text('cmd.rulesTitle', { origin }),
201    lines: [
202      ...(errors.length > 0 ? [text('cmd.configErrors', { errors: errors.join('; ') })] : []),
203      ...rules.map(rule => text('cmd.ruleLine', { tier: rule.tier.padEnd(6), id: rule.id, source: rule.source, reason: rule.reason })),
204      ...list('cmd.list.protectedPaths', compiled.config.protectedPaths),
205      ...list('cmd.list.protectedBranches', compiled.config.protectedBranches),
206      ...list('cmd.list.production', compiled.config.productionPatterns),
207      ...list('cmd.list.secretPatterns', compiled.config.gollum.patterns.map(pattern => `${pattern.id} (${pattern.level})`)),
208      ...list('cmd.list.allowlist', compiled.config.gollum.allowlist.map(entry => (entry.startsWith('sha256:') ? entry : `${entry.slice(0, 3)}…`))),
209    ],
210  }
211}
212
213/** A seat as typed in config: `aragorn/git`, `gandalf`. */
214const seatId = (seat: Seat): string => (seat.profile !== undefined ? `${seat.member}/${seat.profile}` : seat.member)
215
216/** Who would review, for `/council test`: the route, and the model of the member it seats. */
217export type Reviewer = { route: Route; choice?: ModelChoice }
218
219/** A big operation, for `/council test`: who would sit, on which model, and the checks that would run. */
220export type CouncilPreview = {
221  big: Big
222  enabled: boolean
223  seats: readonly CouncilSeat[]
224  choice: ModelChoice
225  checks: readonly string[]
226  /** A merge counted as big without the current branch being checked. */
227  isBranchAssumed: boolean
228}
229
230const seatLine = (seat: CouncilSeat): string =>
231  seat.member === 'legolas' && seat.range !== undefined
232    ? text('cmd.seatRange', { who: text(whoOf(seat.member)), id: seat.member, kind: seat.range })
233    : text('cmd.seat', { who: text(whoOf(seat.member, seat.member === 'aragorn' ? seat.profile : undefined)), id: councilSeatId(seat) })
234
235function councilLines(council: CouncilPreview): string[] {
236  const who = text('who.fullCouncil')
237  if (council.seats.length === 0) return [text('cmd.testCouncilNobody', { entry: council.big.entry, who })]
238  return [
239    text('cmd.testCouncil', { who, entry: council.big.entry, model: council.choice.model, source: council.choice.source, seats: council.seats.map(seatLine).join('; ') }),
240    council.checks.length > 0
241      ? text('cmd.testCouncilChecks', { who: text('who.gimli'), names: council.checks.map(name => `"${name}"`).join(', ') })
242      : text('cmd.testCouncilNoChecks'),
243    ...(council.isBranchAssumed ? [text('cmd.testCouncilBranch', { who })] : []),
244  ]
245}
246
247export function testOutput(
248  command: string,
249  classification: Classification,
250  operation: Operation | undefined,
251  reviewer: Reviewer | undefined,
252  council?: CouncilPreview,
253): Output {
254  const decided = classification.decided
255  const lines = [text('cmd.testTier', { tier: classification.tier })]
256  if (decided === undefined) {
257    lines.push(text('cmd.testAllow'))
258  } else {
259    for (const finding of classification.findings) {
260      lines.push(text('cmd.testFinding', { subject: finding.subject, tier: finding.tier, rule: finding.ruleId, source: finding.source, reason: finding.reason }))
261    }
262    if (council?.enabled === true) {
263      lines.push(...councilLines(council))
264    } else if (classification.tier === 'review' && reviewer !== undefined) {
265      const { route } = reviewer
266      const wanted = route.wanted !== undefined ? `${text(whoOf(route.wanted.member, route.wanted.profile))} [${seatId(route.wanted)}]` : ''
267      if (route.kind === 'none') {
268        lines.push(text('cmd.testNoReviewer', { wanted }))
269      } else {
270        lines.push(
271          text('cmd.testReviewer', {
272            who: text(whoOf(route.member, route.profile)),
273            id: route.member,
274            profile: route.profile !== undefined ? `/${route.profile}` : '',
275            model: reviewer.choice?.model ?? '-',
276            source: reviewer.choice?.source ?? '-',
277          }),
278        )
279        if (route.fallback !== undefined) lines.push(text('cmd.testFallback', { wanted, why: text(`route.${route.fallback}`) }))
280      }
281      if (council !== undefined) lines.push(text('cmd.testCouncilOff', { entry: council.big.entry, who: text('who.fullCouncil') }))
282    }
283    if (operation !== undefined) lines.push(text('cmd.testOperation', { key: operation.key }))
284  }
285  return { title: text('cmd.testTitle', { command: command.length > 60 ? `${command.slice(0, 59)}…` : command }), lines }
286}
287
288export function modelsOutput(choices: Readonly<Record<ModelSlot, ModelChoice>>): Output {
289  return {
290    title: text('cmd.modelTitle'),
291    lines: MODEL_SLOTS.map(slot => text('cmd.modelLine', { id: slot, model: choices[slot].model, source: choices[slot].source })),
292  }
293}
294
hooks/elrond/combine.ts 126 lines
1import type { MemberName, Profile } from '../config/types.js'
2import { isGimliBlock } from '../members/gimli.js'
3import type { GimliRun } from '../members/gimli.js'
4import { VERDICTS } from '../members/shared.js'
5import type { Verdict, VerdictValue } from '../members/shared.js'
6import { text } from '../strings.js'
7import type { StringKey } from '../strings.js'
8import type { MemberOpinion } from './escalation.js'
9
10/**
11 * Combining the full council: the strictest verdict wins (block, then revise,
12 * then approve). A member that errored, timed out or answered malformed
13 * counts as a block from that member; a check that failed, timed out or
14 * could not start is a block too. Each reason is labelled with who gave it.
15 * Members never see each other's verdicts: this runs after all of them.
16 * Claude reads the reason and alternative, so every string here is asked for
17 * in plain mode, whatever the session's mode.
18 */
19
20/** One model member's part in the council. */
21export type Voice =
22  | { kind: 'verdict'; who: StringKey; member: MemberName; profile?: Profile; verdict: Verdict }
23  /** No verdict: an error, a timeout, a malformed reply. Counts as a block. */
24  | { kind: 'failed'; who: StringKey; member: MemberName; profile?: Profile; problem: string }
25  /** Never asked: the sequential council stopped at an earlier block, or there was nothing to review. */
26  | { kind: 'skipped'; who: StringKey; member: MemberName; profile?: Profile; why: string }
27
28export type Combined = {
29  verdict: VerdictValue
30  /** Every voice that didn't approve, labelled, one per line: the reason Claude and the user read. */
31  reason: string
32  /** The same without any check's output: what the audit log keeps. */
33  summary: string
34  /** The safer alternatives of those voices, labelled. */
35  alternative: string
36  /** Blocked only because members gave no verdict: no member blocked or asked to revise, and every check passed. */
37  isFailureOnly: boolean
38  /** How many model members gave a verdict or failed; zero means nobody reviewed. */
39  reviewed: number
40  /** Each voice and check, for the question put to the user. */
41  opinions: MemberOpinion[]
42}
43
44const rank = (verdict: VerdictValue): number => VERDICTS.indexOf(verdict)
45
46const valueOf = (voice: Voice): VerdictValue | undefined =>
47  voice.kind === 'verdict' ? voice.verdict.verdict : voice.kind === 'failed' ? 'block' : undefined
48
49/** What a check run says, in a line. */
50export function runLine(run: GimliRun): string {
51  switch (run.status) {
52    case 'passed':
53      return text('gimli.passed', { name: run.name }, 'plain')
54    case 'failed':
55      return run.code === null || run.code === undefined
56        ? text('gimli.killed', { name: run.name, signal: run.signal ?? 'a signal' }, 'plain')
57        : text('gimli.failed', { name: run.name, code: run.code }, 'plain')
58    case 'timed-out':
59      return text('gimli.timedOut', { name: run.name, seconds: Math.round(run.ms / 1000) }, 'plain')
60    case 'error':
61      return text('gimli.error', { name: run.name }, 'plain')
62    case 'stopped':
63      return text('gimli.stopped', { name: run.name }, 'plain')
64  }
65}
66
67const withTail = (run: GimliRun): string => (run.tail === '' ? runLine(run) : `${runLine(run)} ${text('gimli.tail', { tail: run.tail }, 'plain')}`)
68
69export function combine(voices: readonly Voice[], runs: readonly GimliRun[]): Combined {
70  let verdict: VerdictValue = 'approve'
71  const reasons: string[] = []
72  const summaries: string[] = []
73  const alternatives: string[] = []
74  let isRealBlock = false
75  for (const voice of voices) {
76    const value = valueOf(voice)
77    if (value === undefined || value === 'approve') continue
78    if (rank(value) > rank(verdict)) verdict = value
79    const who = text(voice.who, {}, 'plain')
80    if (voice.kind === 'verdict') {
81      isRealBlock = true
82      reasons.push(text('council.voice', { who, verdict: value, reason: voice.verdict.reason }, 'plain'))
83      summaries.push(reasons.at(-1) as string)
84      alternatives.push(text('council.voice', { who, verdict: value, reason: voice.verdict.safer_alternative }, 'plain'))
85    } else if (voice.kind === 'failed') {
86      reasons.push(text('council.noVerdict', { who, problem: voice.problem }, 'plain'))
87      summaries.push(reasons.at(-1) as string)
88    }
89  }
90  const failedRuns = runs.filter(isGimliBlock)
91  if (failedRuns.length > 0) {
92    verdict = 'block'
93    isRealBlock = true
94    for (const run of failedRuns) {
95      reasons.push(text('council.voice', { who: text('who.gimli', {}, 'plain'), verdict: 'block', reason: withTail(run) }, 'plain'))
96      summaries.push(text('council.voice', { who: text('who.gimli', {}, 'plain'), verdict: 'block', reason: runLine(run) }, 'plain'))
97    }
98    alternatives.push(text('alternative.gimli', { names: failedRuns.map(run => `"${run.name}"`).join(', ') }, 'plain'))
99  }
100  if (verdict !== 'approve' && alternatives.length === 0) alternatives.push(text('alternative.ask', {}, 'plain'))
101
102  const opinions: MemberOpinion[] = [
103    ...voices.flatMap((voice): MemberOpinion[] =>
104      voice.kind === 'verdict'
105        ? [{ who: voice.who, verdict: voice.verdict.verdict, reason: voice.verdict.reason }]
106        : voice.kind === 'failed'
107          ? [{ who: voice.who, problem: voice.problem }]
108          : [],
109    ),
110    ...runs.map((run): MemberOpinion => ({ who: 'who.gimli', verdict: isGimliBlock(run) ? 'block' : 'approve', reason: runLine(run) })),
111  ]
112  return {
113    verdict,
114    reason: reasons.map(line => `\n- ${line}`).join(''),
115    summary: summaries.join(' '),
116    alternative: alternatives.length === 1 ? (alternatives[0] as string) : alternatives.map(line => `\n- ${line}`).join(''),
117    isFailureOnly: verdict === 'block' && !isRealBlock,
118    reviewed: voices.filter(voice => voice.kind !== 'skipped').length,
119    opinions,
120  }
121}
122
123/** Whether the council has already blocked for real, so a check still running can no longer matter. */
124export const hasRealBlock = (voices: readonly Voice[]): boolean =>
125  voices.some(voice => voice.kind === 'verdict' && voice.verdict.verdict === 'block')
126
hooks/elrond/council.ts 115 lines
1import type { BigOperation, CompiledConfig } from '../config/schema.js'
2import type { Profile } from '../config/types.js'
3import { FILE_PATH_FIELDS } from '../rules/classify.js'
4import type { Call, Classification, Finding } from '../rules/classify.js'
5import type { ShellPart } from '../rules/shell.js'
6import type { Enabled } from './routing.js'
7import { seatOf } from './routing.js'
8
9/**
10 * The full council: which review-tier calls are big operations, and who sits
11 * for one. Pure: the current branch, the one fact a rule cannot see, comes in
12 * from register.ts.
13 */
14
15/** Why a call is a big operation: the config entry that matched, and the part it matched. */
16export type Big = { entry: string; subject: string }
17
18/** What decides a big operation besides the call: the current branch, when a merge needs it. */
19export type BigContext = {
20  /** The checked-out branch; undefined when unknown (a merge into it then counts as big). */
21  currentBranch?: string
22}
23
24const isGit = (part: ShellPart, sub: string): boolean => part.coreWords[0] === 'git' && part.coreWords[1] === sub
25
26/** A merge lands on the checked-out branch; a pull request merge on a base the command doesn't name. */
27const isMerge = (part: ShellPart): boolean =>
28  (isGit(part, 'merge') && !part.coreWords.slice(2).some(word => ['--abort', '--quit', '--continue'].includes(word))) ||
29  (part.coreWords[0] === 'gh' && part.coreWords[1] === 'pr' && part.coreWords[2] === 'merge')
30
31/** Whether working out a big operation needs the current branch: only a `git merge` does. */
32export const needsCurrentBranch = (classification: Classification, compiled: CompiledConfig): boolean =>
33  compiled.bigOperations.some(big => big.kind === 'check') &&
34  classification.findings.some(finding => finding.part !== undefined && isGit(finding.part, 'merge'))
35
36function matches(big: BigOperation, finding: Finding, compiled: CompiledConfig, context: BigContext): boolean {
37  if (big.kind === 'rule') return finding.ruleId === big.ruleId
38  const part = finding.part
39  if (part === undefined) return false
40  if (big.kind === 'command') return big.re.test(part.core)
41  // merge-to-protected: an unknown current branch counts, since the merge may land on a protected one.
42  if (!isMerge(part)) return false
43  if (part.coreWords[0] === 'gh') return true
44  const branch = context.currentBranch
45  return branch === undefined || branch === 'HEAD' || compiled.protectedBranches.some(re => re.test(branch))
46}
47
48/**
49 * The big operation a review-tier call is, if any: the first configured entry
50 * one of its review parts matches. Other tiers never reach the council.
51 */
52export function bigOperationOf(classification: Classification, compiled: CompiledConfig, context: BigContext = {}): Big | undefined {
53  if (classification.tier !== 'review') return undefined
54  for (const finding of classification.findings) {
55    if (finding.tier !== 'review') continue
56    const big = compiled.bigOperations.find(entry => matches(entry, finding, compiled, context))
57    if (big !== undefined) return { entry: big.entry, subject: finding.subject }
58  }
59  return undefined
60}
61
62/**
63 * One seat at the full council. The diff reviewer reviews a file tool's own
64 * diff, or the changes a push or merge would send or bring in (`range`).
65 */
66export type CouncilSeat =
67  | { member: 'gandalf' }
68  | { member: 'legolas'; range?: 'push' | 'merge' }
69  | { member: 'aragorn'; profile: Profile }
70
71/** The push or merge a diff reviewer could read the changes of, if the call has one. */
72export function rangeOf(classification: Classification): { kind: 'push' | 'merge'; part: ShellPart } | undefined {
73  for (const finding of classification.findings) {
74    const part = finding.part
75    if (part === undefined || finding.tier !== 'review') continue
76    if (isGit(part, 'push')) return { kind: 'push', part }
77    if (isGit(part, 'merge') && isMerge(part)) return { kind: 'merge', part }
78  }
79  return undefined
80}
81
82/**
83 * Who sits for a big operation: every enabled model member with something of
84 * this call to review. The general reviewer always; the git and database
85 * reviewer once per profile the call's parts ask for; the diff reviewer for a
86 * file change, or for a push or merge (the changes it would send or bring in,
87 * read by a fixed `git diff`, so only while the read-only preview is on).
88 * Each gets its own brief; none sees another's verdict.
89 */
90export function councilSeats(call: Call, classification: Classification, enabled: Enabled, options: { ranges: boolean }): CouncilSeat[] {
91  const seats: CouncilSeat[] = []
92  if (enabled.gandalf) seats.push({ member: 'gandalf' })
93  if (enabled.legolas) {
94    if (FILE_PATH_FIELDS[call.tool] !== undefined) seats.push({ member: 'legolas' })
95    else if (options.ranges) {
96      const range = rangeOf(classification)
97      if (range !== undefined) seats.push({ member: 'legolas', range: range.kind })
98    }
99  }
100  if (enabled.aragorn) {
101    const profiles = new Set<Profile>()
102    for (const finding of classification.findings) {
103      if (finding.tier !== 'review') continue
104      const seat = seatOf(finding)
105      if (seat.member === 'aragorn' && seat.profile !== undefined) profiles.add(seat.profile)
106    }
107    for (const profile of ['git', 'database'] as const) if (profiles.has(profile)) seats.push({ member: 'aragorn', profile })
108  }
109  return seats
110}
111
112/** A seat as typed in config and shown by `/council test`: `aragorn/git`, `gandalf`. */
113export const councilSeatId = (seat: CouncilSeat): string =>
114  seat.member === 'aragorn' ? `${seat.member}/${seat.profile}` : seat.member
115
hooks/elrond/escalation.ts 82 lines
1import { truncate } from '../members/shared.js'
2import { redact } from '../redact.js'
3import { text } from '../strings.js'
4import type { Mode, StringKey } from '../strings.js'
5
6/**
7 * The escalation: the question put to the user, and what their answer means.
8 * The dialog is the engine's AskUserQuestion: two labelled options and a
9 * free-text "Other", which reaches Claude as an instruction.
10 */
11
12export type MemberOpinion =
13  | { who: StringKey; verdict: string; reason: string }
14  | { who: StringKey; problem: string }
15
16export type Question = {
17  call: string
18  ruleReason: string
19  why: StringKey
20  opinions: readonly MemberOpinion[]
21  /** The read-only preview, when there is one. */
22  preview?: string
23  /** Possible secrets the scan found, already redacted. */
24  secrets?: readonly { label: string; snippet: string }[]
25  /** The dialog offers the allowlist (one finding exactly). */
26  canAllowlist?: boolean
27}
28
29export type Answer =
30  | { kind: 'allow-once' }
31  | { kind: 'keep-blocked' }
32  | { kind: 'allowlist' }
33  | { kind: 'instruction'; text: string }
34
35export type Unanswered = 'dismissed' | 'chat' | 'unavailable'
36
37const MAX_QUESTION_CHARS = 2_500
38
39const PREVIEW_LINES = 15
40
41/** The dialog's labels; a secrets question adds the allowlist. */
42export const optionsOf = (mode: Mode = 'plain', withAllowlist = false): string[] => [
43  text('ask.allowOnce', {}, mode),
44  ...(withAllowlist ? [text('ask.allowlist', {}, mode)] : []),
45  text('ask.keepBlocked', {}, mode),
46]
47
48export function questionText(question: Question, mode: Mode = 'plain'): string {
49  const lines = [
50    text('ask.title', {}, mode),
51    text(question.why, {}, mode),
52    text('ask.call', { call: truncate(redact(question.call), 12, 600) }, mode),
53    ...(question.ruleReason !== '' ? [text('ask.rule', { reason: question.ruleReason }, mode)] : []),
54    ...(question.secrets ?? []).map(secret =>
55      text('ask.secret', { label: secret.label, snippet: redact(secret.snippet) }, mode),
56    ),
57    ...question.opinions.map(opinion =>
58      'problem' in opinion
59        ? text('ask.failed', { who: text(opinion.who, {}, mode), problem: opinion.problem }, mode)
60        : text('ask.verdict', { who: text(opinion.who, {}, mode), verdict: opinion.verdict, reason: redact(opinion.reason) }, mode),
61    ),
62    ...(question.preview !== undefined
63      ? [text('ask.preview', { preview: truncate(redact(question.preview), PREVIEW_LINES, 900) }, mode)]
64      : []),
65    text(question.canAllowlist === true ? 'ask.closeSecret' : 'ask.close', {}, mode),
66  ]
67  const joined = lines.join('\n')
68  return joined.length > MAX_QUESTION_CHARS ? `${joined.slice(0, MAX_QUESTION_CHARS - 1)}…` : joined
69}
70
71/** Labels compare exactly; anything else the user typed is an instruction. */
72export function interpretAnswer(answer: string, mode: Mode = 'plain', withAllowlist = false): Answer {
73  if (answer === text('ask.allowOnce', {}, mode)) return { kind: 'allow-once' }
74  if (answer === text('ask.keepBlocked', {}, mode)) return { kind: 'keep-blocked' }
75  if (withAllowlist && answer === text('ask.allowlist', {}, mode)) return { kind: 'allowlist' }
76  return { kind: 'instruction', text: answer.trim() }
77}
78
79/** Why `$.ui.ask` rejected: the user chose to chat, or dismissed it. */
80export const interpretRejection = (error: unknown): Unanswered =>
81  /chat/i.test(error instanceof Error ? error.message : String(error)) ? 'chat' : 'dismissed'
82
hooks/elrond/models.ts 53 lines
1import { BUILT_IN_MODELS } from '../config/defaults.js'
2import type { ModelSlot } from '../config/types.js'
3
4/**
5 * Which model a slot runs on, from the first layer that sets one: a session
6 * switch, the user's /config row, the project rules file, the built-in.
7 */
8
9export type ModelSource = 'session' | 'settings' | 'project' | 'built-in'
10
11export type ModelChoice = { model: string; source: ModelSource }
12
13export type ModelLayers = {
14  session?: string
15  /** The /config row's value; `default` means unset. */
16  settings?: string
17  project?: string
18}
19
20const isSet = (value: string | undefined): value is string =>
21  value !== undefined && value.trim() !== '' && value !== 'default'
22
23export function resolveModel(slot: ModelSlot, layers: ModelLayers): ModelChoice {
24  if (isSet(layers.session)) return { model: layers.session, source: 'session' }
25  if (isSet(layers.settings)) return { model: layers.settings, source: 'settings' }
26  if (isSet(layers.project)) return { model: layers.project, source: 'project' }
27  return { model: BUILT_IN_MODELS[slot], source: 'built-in' }
28}
29
30/**
31 * The request settings that follow from a model, so choosing a model is the
32 * only decision. Thinking models spend output tokens thinking, so their cap
33 * leaves room; an unknown id is treated as a thinking model.
34 */
35export type ModelProfile = {
36  maxTokens: number
37  effort?: 'low'
38  deadlineMs: number
39}
40
41export function profileOf(model: string): ModelProfile {
42  const name = model.toLowerCase()
43  if (name.includes('haiku')) return { maxTokens: 400, deadlineMs: 20_000 }
44  if (name.includes('sonnet')) return { maxTokens: 2_000, effort: 'low', deadlineMs: 30_000 }
45  if (name.includes('fable') || name.includes('mythos')) return { maxTokens: 4_000, effort: 'low', deadlineMs: 90_000 }
46  if (name.includes('opus')) return { maxTokens: 2_000, effort: 'low', deadlineMs: 45_000 }
47  return { maxTokens: 2_000, effort: 'low', deadlineMs: 45_000 }
48}
49
50/** The deadline for one review: the user's override in seconds (0: from the model). */
51export const deadlineFor = (model: string, overrideSeconds: number): number =>
52  overrideSeconds > 0 ? overrideSeconds * 1000 : profileOf(model).deadlineMs
53
hooks/elrond/operations.ts 298 lines
1import { redact } from '../redact.js'
2import { FILE_PATH_FIELDS, SHELL_TOOLS } from '../rules/classify.js'
3import type { Call, Classification } from '../rules/classify.js'
4import { relativeTo, resolve } from '../rules/paths.js'
5import type { ShellPart } from '../rules/shell.js'
6
7/**
8 * Operations: what a call is trying to do, so a rephrased retry counts
9 * against the same attempt. The key is tool family + verb + normalised
10 * targets; the verb key drops the targets, to catch retries that change them.
11 */
12
13export type OperationContext = {
14  root: string
15  cwd: string
16  home?: string
17  /** A file tool's path with symbolic links resolved, when known. */
18  realPath?: string
19}
20
21export type Operation = { key: string; verbKey: string }
22
23/** Programs whose first word after the name is a subcommand that names the verb. */
24const SUBCOMMAND_PROGRAMS: ReadonlySet<string> = new Set([
25  'git', 'npm', 'pnpm', 'yarn', 'bun', 'cargo', 'docker', 'docker-compose', 'podman', 'kubectl', 'helm',
26  'terraform', 'tofu', 'gh', 'aws', 'gcloud', 'az', 'prisma', 'knex', 'alembic', 'rails', 'rake',
27  'flyway', 'liquibase', 'goose', 'dbmate', 'atlas', 'drizzle-kit', 'sequelize', 'typeorm', 'gem', 'twine',
28  'systemctl', 'launchctl', 'brew', 'apt', 'apt-get', 'dnf', 'yum', 'pip', 'pip3', 'uv', 'poetry',
29])
30
31/** Programs whose arguments are all paths, resolved so `./build` and `build/` match. */
32const PATH_PROGRAMS: ReadonlySet<string> = new Set([
33  'rm', 'rmdir', 'unlink', 'shred', 'srm', 'trash', 'mv', 'cp', 'ln', 'chmod', 'chown', 'chgrp',
34  'truncate', 'touch', 'mkdir', 'tee', 'find', 'sed', 'perl', 'ruby', 'bash', 'sh', 'zsh', 'source', '.',
35  'python', 'python3', 'node', 'deno', 'scp', 'rsync',
36])
37
38const SQL_CLIENTS: ReadonlySet<string> = new Set([
39  'psql', 'pg_restore', 'dropdb', 'createdb', 'mysql', 'mariadb', 'mysqladmin', 'sqlite3', 'mongo', 'mongosh',
40  'mongorestore', 'redis-cli', 'cqlsh', 'sqlcmd', 'clickhouse', 'clickhouse-client',
41])
42
43/** Options of SQL clients that name the database or its host. */
44const SQL_TARGET_OPTIONS: ReadonlySet<string> = new Set(['-d', '--dbname', '-h', '--host', '-D', '--database', '--db', '-n'])
45
46const MAX_TARGETS = 8
47const MAX_KEY_CHARS = 300
48
49const unquote = (word: string): string => word.replace(/^['"]|['"]$/g, '')
50
51const programOf = (part: ShellPart): string => {
52  const first = part.coreWords[0] ?? ''
53  return first.slice(first.lastIndexOf('/') + 1)
54}
55
56/** A path as the key spells it: relative to the root when inside it, no trailing slash. */
57function normalPath(word: string, cwd: string, context: OperationContext): string {
58  const absolute = resolve(unquote(word), cwd, context.home)
59  const relative = relativeTo(absolute, context.root)
60  const out = relative ?? absolute
61  return out === '' ? '.' : out.replace(/\/+$/, '') || '/'
62}
63
64const positionals = (words: readonly string[]): string[] => words.filter(word => !word.startsWith('-') && word !== '')
65
66/** `git push [options] [remote] [refspec...]`: remote plus destination branches. */
67function pushTargets(args: readonly string[]): string[] {
68  const words = positionals(args)
69  const remote = words[0] ?? '(default remote)'
70  const branches = words.slice(1).map(refspec => {
71    const destination = refspec.includes(':') ? refspec.slice(refspec.indexOf(':') + 1) : refspec
72    return destination.replace(/^\+/, '').replace(/^refs\/heads\//, '')
73  })
74  return [remote, ...(branches.length > 0 ? branches : ['(current branch)'])]
75}
76
77/** The database a SQL client talks to: option values, else its first positional. */
78function sqlTargets(args: readonly string[]): string[] {
79  const out: string[] = []
80  for (let i = 0; i < args.length; i++) {
81    const word = args[i] ?? ''
82    const eq = word.indexOf('=')
83    if (eq > 0 && SQL_TARGET_OPTIONS.has(word.slice(0, eq))) out.push(word.slice(eq + 1))
84    else if (SQL_TARGET_OPTIONS.has(word) && args[i + 1] !== undefined) out.push(args[++i] ?? '')
85    else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(word)) out.push(word)
86  }
87  if (out.length === 0) {
88    const first = positionals(args)[0]
89    if (first !== undefined) out.push(first)
90  }
91  return out.map(target => redact(unquote(target)))
92}
93
94/** Files a part's redirects write to (not `2>&1`, not /dev/null). */
95const writtenFiles = (part: ShellPart, cwd: string, context: OperationContext): string[] =>
96  part.redirects
97    .filter(redirect => redirect.op.includes('>') && !redirect.target.startsWith('&') && redirect.target !== '/dev/null')
98    .map(redirect => normalPath(redirect.target, cwd, context))
99
100/** One shell part's verb and targets. */
101export function partOperation(part: ShellPart, cwd: string, context: OperationContext): { verb: string; targets: string[] } {
102  const program = programOf(part)
103  const args = part.coreWords.slice(1)
104  if (program === 'git') {
105    // `git -C dir push`: skip global options to find the subcommand.
106    let index = 0
107    while (index < args.length && (args[index] ?? '').startsWith('-')) index += ['-C', '-c'].includes(args[index] ?? '') ? 2 : 1
108    const sub = args[index] ?? ''
109    const rest = args.slice(index + 1)
110    if (sub === 'push') return { verb: 'git push', targets: pushTargets(rest) }
111    return { verb: `git ${sub}`.trim(), targets: positionals(rest).map(unquote) }
112  }
113  if (SQL_CLIENTS.has(program)) return { verb: program, targets: sqlTargets(args) }
114  if (SUBCOMMAND_PROGRAMS.has(program)) {
115    const sub = positionals(args)[0]
116    const rest = sub === undefined ? [] : args.slice(args.indexOf(sub) + 1)
117    return { verb: sub === undefined ? program : `${program} ${sub}`, targets: positionals(rest).map(unquote) }
118  }
119  if (PATH_PROGRAMS.has(program)) {
120    return { verb: program, targets: [...positionals(args).map(word => normalPath(word, cwd, context)), ...writtenFiles(part, cwd, context)] }
121  }
122  return { verb: program, targets: [...positionals(args).map(unquote), ...writtenFiles(part, cwd, context)] }
123}
124
125const cap = (key: string): string => (key.length > MAX_KEY_CHARS ? `${key.slice(0, MAX_KEY_CHARS - 1)}…` : key)
126
127const sortedUnique = (items: readonly string[]): string[] => [...new Set(items)].sort()
128
129/**
130 * The operation a gated call attempts. Shell: each gated part's verb and
131 * targets (flags dropped, paths resolved, targets sorted); file tools share
132 * the `file` family keyed on the real path; anything else is its tool.
133 */
134export function operationOf(call: Call, classification: Classification, context: OperationContext): Operation {
135  if (SHELL_TOOLS.has(call.tool)) {
136    const parts = classification.findings.filter(finding => finding.part !== undefined)
137    if (parts.length === 0) {
138      const text = typeof call.input.command === 'string' ? call.input.command.replace(/\s+/g, ' ').trim() : ''
139      return { key: cap(`shell:${redact(text)}`), verbKey: 'shell:(unparsed)' }
140    }
141    const ops = parts.map(finding => partOperation(finding.part as ShellPart, finding.cwd ?? context.cwd, context))
142    const verbs = sortedUnique(ops.map(op => op.verb))
143    const key = sortedUnique(ops.map(op => `${op.verb} ${sortedUnique(op.targets).slice(0, MAX_TARGETS).join(' ')}`.trim()))
144    // The key can hold a target a command named verbatim (a push URL with a
145    // token, a connection string); it is written to the audit log, so redact.
146    return { key: cap(redact(`shell:${key.join(' ; ')}`)), verbKey: cap(`shell:${verbs.join(' ; ')}`) }
147  }
148  const field = FILE_PATH_FIELDS[call.tool]
149  const given = field === undefined ? undefined : call.input[field]
150  if (typeof given === 'string') {
151    const path = context.realPath !== undefined ? normalPath(context.realPath, '/', context) : normalPath(given, context.cwd, context)
152    return { key: cap(`file:${path}`), verbKey: `file:${call.tool}` }
153  }
154  return { key: cap(`tool:${call.tool}`), verbKey: cap(`tool:${call.tool}`) }
155}
156
157// ── Rounds, failed attempts, lockout ────────────────────────────────────────
158
159export const ROUND_CAP = 2
160export const KEY_WIPE_CAP = 3
161export const VERB_WIPE_CAP = 5
162
163export type OpState = { rounds: number; wipes: number }
164
165export type OpsState = {
166  ops: Readonly<Record<string, OpState>>
167  verbWipes: Readonly<Record<string, number>>
168}
169
170const MAX_OPS = 200
171
172const ZERO: OpState = { rounds: 0, wipes: 0 }
173
174export const opOf = (state: OpsState, key: string): OpState => state.ops[key] ?? ZERO
175
176/** Keeps the newest MAX_OPS entries, so a long prompt cannot grow state without bound. */
177function withOp<T extends OpsState>(state: T, key: string, op: OpState): T {
178  const { [key]: _old, ...rest } = state.ops
179  const entries = Object.entries({ ...rest, [key]: op })
180  return { ...state, ops: Object.fromEntries(entries.slice(-MAX_OPS)) }
181}
182
183export type Lockout = { kind: 'key' | 'verb'; wipes: number } | undefined
184
185/** Whether the operation is locked out: 3 failed attempts on its key, or 5 on its verb. */
186export function lockoutOf(state: OpsState, operation: Operation): Lockout {
187  const key = opOf(state, operation.key).wipes
188  if (key >= KEY_WIPE_CAP) return { kind: 'key', wipes: key }
189  const verb = state.verbWipes[operation.verbKey] ?? 0
190  if (verb >= VERB_WIPE_CAP) return { kind: 'verb', wipes: verb }
191  return undefined
192}
193
194/** One review verdict on the operation: a non-approve verdict uses a round. */
195export function noteRound<T extends OpsState>(state: T, key: string, isApprove: boolean): T {
196  if (isApprove) return state
197  const op = opOf(state, key)
198  return withOp(state, key, { ...op, rounds: op.rounds + 1 })
199}
200
201export const roundsLeft = (state: OpsState, key: string): number => Math.max(0, ROUND_CAP - opOf(state, key).rounds)
202
203/**
204 * Failed attempts since the user last wrote, for the status and the debate
205 * pane: how many (the per-verb counter, which only a new prompt clears), over
206 * how many operations, and how many of those are locked out.
207 */
208export function attemptsOf(state: OpsState): { count: number; ops: number; locked: number } {
209  const ops = Object.values(state.ops)
210  return {
211    count: Object.values(state.verbWipes).reduce((sum, n) => sum + n, 0),
212    ops: ops.length,
213    locked: ops.filter(op => op.wipes >= KEY_WIPE_CAP).length,
214  }
215}
216
217/** Out of rounds: the next attempt goes to the user, with no model call. */
218export const isOutOfRounds = (state: OpsState, key: string): boolean => opOf(state, key).rounds >= ROUND_CAP
219
220/** A failed attempt, counted on the key and on its verb. */
221export function noteWipe<T extends OpsState>(state: T, operation: Operation): T {
222  const op = opOf(state, operation.key)
223  const counted = withOp(state, operation.key, { ...op, wipes: op.wipes + 1 })
224  return { ...counted, verbWipes: { ...counted.verbWipes, [operation.verbKey]: (counted.verbWipes[operation.verbKey] ?? 0) + 1 } }
225}
226
227/** The user typed an instruction: the operation's rounds start over. */
228export function resetRounds<T extends OpsState>(state: T, key: string): T {
229  const op = opOf(state, key)
230  return op.rounds === 0 ? state : withOp(state, key, { ...op, rounds: 0 })
231}
232
233/** The call ran and succeeded: its own operation starts over (the verb counter stays). */
234export function resetOperation<T extends OpsState>(state: T, key: string): T {
235  if (state.ops[key] === undefined) return state
236  const { [key]: _done, ...rest } = state.ops
237  return { ...state, ops: rest }
238}
239
240// ── What happened to the call, and whether it counts as a failed attempt ────
241
242export type Outcome = 'ran' | 'error' | 'refused' | 'refused-by-user' | 'denied-by-permission'
243
244/**
245 * Claude Code's words when the person refuses a call at its own permission
246 * prompt (with or without feedback). Read from the 2.1.289 binary.
247 */
248const USER_REFUSAL: readonly RegExp[] = [
249  /the user doesn'?t want to proceed with this tool use/i,
250  /the user doesn'?t want to take this action/i,
251]
252
253/**
254 * Claude Code's words when its permission check stops a call with nobody
255 * asked: no one to ask (`-p`), or a deny rule. The first is verified live.
256 */
257const AUTOMATIC_DENIAL: readonly RegExp[] = [
258  /needs? approval/i,
259  // Seen on 2.1.294: a `git push` in `-p` ("This command requires approval").
260  /\brequires? approval\b/i,
261  /requested permissions? to (use|write|edit|read|run)/i,
262  /haven'?t granted it yet/i,
263  /permission to use .+ has been denied/i,
264]
265
266export const isUserRefusal = (text: string): boolean => USER_REFUSAL.some(re => re.test(text))
267
268export const isAutomaticDenial = (text: string): boolean => AUTOMATIC_DENIAL.some(re => re.test(text))
269
270export function outcomeOf(result: { deny?: string; isError?: boolean; text?: string }): Outcome {
271  if (result.deny !== undefined) return 'refused'
272  if (result.isError !== true) return 'ran'
273  const text = result.text ?? ''
274  if (isUserRefusal(text)) return 'refused-by-user'
275  return isAutomaticDenial(text) ? 'denied-by-permission' : 'error'
276}
277
278export type WipePolicy = {
279  /** A gated call that ran and errored counts (userConfig `toolErrorsAreWipes`). */
280  toolErrors: boolean
281}
282
283/**
284 * Whether a call that went through `next(e)` counts as a failed attempt. The
285 * person refusing at Claude Code's prompt always does, like "keep blocked";
286 * an automatic denial never does, since nobody chose it.
287 */
288export const isWipeOutcome = (outcome: Outcome, policy: WipePolicy): boolean =>
289  outcome === 'refused-by-user' || (outcome === 'error' && policy.toolErrors)
290
291// ── Cache ───────────────────────────────────────────────────────────────────
292
293const MAX_CACHE = 100
294
295/** Notes an approved fingerprint; the cache lives until the next prompt. */
296export const cacheApprove = (cache: readonly string[], fingerprint: string): string[] =>
297  [...cache.filter(known => known !== fingerprint), fingerprint].slice(-MAX_CACHE)
298