Council runner
src/council_runner.py
is the planner that turns a toolbox declaration into a concrete RunPlan
the hook system can execute.
Responsibilities
- Resolve the toolbox β merge global + per-repo config.
- Compute scope β walk the current diff or full repo, honoring
scope.analysisand optionalscope.filesglobs. - Graph-blast expansion β for
dynamicscope, add every file that imports a changed module (via the knowledge graph edge map). - Enforce budget β drop files until the plan fits within
budget.max_tokens(estimated by line count Γ heuristic). - Honor dedup β skip if the same file set was run within
dedup.window_secondsand policy isuser-configurable. - Persist β write the plan to
~/.claude/toolbox-runs/<plan_hash>.jsonfor downstream reads.
RunPlan
@dataclass(frozen=True)
class RunPlan:
plan_hash: str
toolbox: str
agents: tuple[str, ...]
files: tuple[str, ...]
source: str # "slash" | "pre-commit" | ...
guardrail: bool
budget: Budget
created_at: float
The plan_hash is deterministic (sha256 of toolbox|sorted(files)|agents),
which lets dedup work across triggers without any additional state.
CLI
# Build and persist a plan for the named toolbox
python -m council_runner build ship-it
# Build without persisting (useful for inspection)
python -m council_runner build ship-it --dry-run
# Show a previously persisted plan
python -m council_runner show <plan_hash>
# List recent plans
python -m council_runner list --limit 10
Budget estimation
Token estimates are intentionally rough. The runner assumes ~4 tokens per
line of source, then sorts files by recency (newest first) and greedily
takes until max_tokens is reached. If a single file exceeds the budget,
the plan is truncated rather than dropped β the council still runs on a
partial view.
This cheap estimate is fine because the council itself enforces its own
budgets; council_runner's job is just to stay in the right ballpark.
Dedup window
Dedup compares the sorted file list, not the plan hash β that way a toolbox and its re-run with a newer budget still dedup correctly.
Graph-blast expansion
For dynamic scope, council_runner reads the graph edge map produced
by scan_repo.py and walks imports one hop out from each changed file.
It stops at one hop to keep scope bounded; deep graph walks are reserved
for explicit full mode.
Related
- Hooks & triggers β how a plan gets executed.
- Verdicts & guardrails β what the council leaves behind.