13 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 3a8f115613 Release version 0.2.0
🧪 Test / 🧪 Lock + lint (push) Waiting to run
2026-10-31 18:07:01 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 d2f7c19f13 feat(branch-gc): remove merged branches and worktrees through pkgmgr branch gc after the operator's pick
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:30:24 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 b417a1084c feat(subagent-model): pick a cheaper subagent model only at 90 % same-result confidence and log spawns with their quality
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:27:26 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 a4cbab98b1 docs(merge-fix): state that --path widens the search instead of narrowing --grep
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:21:51 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 44c88b4bf7 docs(skills): never cut a running job or agent short, not as chair and not at its time cap
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:19:05 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 9f7844ad6c fix(democracy): ignore heavy commands inside quotes and heredocs in the block hook
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:16:25 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 ed0ea62b52 fix(democracy): skip worktrees with a running make in the stop-stage cleanup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:14:53 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 b69a7f85d9 feat(democracy): admit test slots only below the configured CPU and RAM limits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 17:08:33 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 7aeeb6a6c7 feat(democracy): let the relayer of an operator rule record it in shared memory
🧪 Test / 🧪 Lock + lint (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 20:20:04 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 c873047a60 fix(changelog): warn when a workflow on HEAD already failed before a release
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 20:20:04 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 90b0a2ee88 fix(ci-artifacts): sign lint failures by their finding and ignore steps after the failure
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 20:20:04 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 b9ca192c51 fix(update): update every locked source and install for Claude Code only
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 20:20:04 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 2f56ce62ce fix(ci): pin ruff in pyproject for Dependabot and fix its target so local and CI lint agree
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 20:20:03 +01:00
27 changed files with 822 additions and 49 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: daily
+2 -1
View File
@@ -24,7 +24,8 @@ jobs:
- name: 📦 Install lint tools - name: 📦 Install lint tools
run: | run: |
pip install ruff python -m pip install --upgrade pip
pip install --group lint
sudo apt-get update && sudo apt-get install -y shellcheck sudo apt-get update && sudo apt-get install -y shellcheck
npm install -g markdownlint-cli2 npm install -g markdownlint-cli2
+2 -1
View File
@@ -29,7 +29,8 @@ jobs:
- name: 📦 Install lint tools - name: 📦 Install lint tools
run: | run: |
pip install ruff python -m pip install --upgrade pip
pip install --group lint
sudo apt-get update && sudo apt-get install -y shellcheck sudo apt-get update && sudo apt-get install -y shellcheck
npm install -g markdownlint-cli2 npm install -g markdownlint-cli2
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## [0.2.0] - 2026-10-31
Added:
- Git: *branch-gc* clears merged branches and worktrees with *pkgmgr*
- Agents: *subagent-model* uses a cheaper model only at 90 % same result
- Agents: *subagent-model* hooks log every spawn and its rated quality
Changed:
- Coordination: never cut a running job or agent short, not even at its cap
- Resources: *watch.py admit* grants test slots only below CPU and RAM limits
- Coordination: a relayer writes an operator rule to shared memory first
Fixed:
- Resources: stop-stage cleanup now skips worktrees where *make* still runs
- Resources: the block hook ignores heavy commands in quotes and heredocs
- Update: *make update* now updates every locked source, not just the first
- Update: *make update* installs for Claude Code only
- CI: *ci-artifacts* signs lint failures by finding, ignores later steps
- Release: *changelog* warns when a workflow on HEAD already failed
- CI: ruff pinned in *pyproject.toml*; local and CI lint now agree
Upgrade notes:
- 0.1.0 was never published (its CI failed); its upgrade notes follow
- Install: restart the agent after *make install* to load skills and hooks
- Resources: *watch.py* needs *~/.config/agents-council/config.json*
- Resources: add *busy_processes* and *load_limits* to that config
## [0.1.0] - 2026-10-30 ## [0.1.0] - 2026-10-30
Added: Added:
+9 -1
View File
@@ -19,6 +19,7 @@ flowchart LR
edit_hook([edit in a git worktree]) edit_hook([edit in a git worktree])
reminders([reminder hooks]) reminders([reminder hooks])
code_task([code task]) code_task([code task])
spawn([subagent spawn])
end end
subgraph commit [Commit] subgraph commit [Commit]
commit_next[commit-next] commit_next[commit-next]
@@ -34,6 +35,8 @@ flowchart LR
coordinate[coordinate] coordinate[coordinate]
funnel[funnel] funnel[funnel]
cleanup[cleanup] cleanup[cleanup]
branch_gc[branch-gc]
subagent_model[subagent-model]
end end
subgraph ci [CI] subgraph ci [CI]
observe[observe] observe[observe]
@@ -81,6 +84,7 @@ flowchart LR
reminders -.-> autotune reminders -.-> autotune
code_task -.-> no_defaults code_task -.-> no_defaults
code_task -.-> scope_guard code_task -.-> scope_guard
spawn -.-> subagent_model
commit_next --> merge_fix commit_next --> merge_fix
commit_next --> commit_no_verify commit_next --> commit_no_verify
@@ -94,8 +98,12 @@ flowchart LR
coordinate --> democracy coordinate --> democracy
cleanup --> democracy cleanup --> democracy
cleanup --> branch_gc
branch_gc --> democracy
funnel --> democracy funnel --> democracy
funnel --> dialectic funnel --> dialectic
funnel --> subagent_model
dialectic --> subagent_model
democracy --> merge_fix democracy --> merge_fix
observe --> triage observe --> triage
@@ -155,7 +163,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.cl
make project TARGET=/path/to/repo make project TARGET=/path/to/repo
``` ```
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `autotune` points you at `/autotune` at most once an hour, `dream` points you at `/dream` at most once every twelve hours per repository (stamped in that repository's git dir). A PreToolUse gate from the `democracy` skill blocks edits to files in a git worktree until the session holds a claim (`skills/democracy/scripts/council.py`), and its resource watch (`skills/democracy/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.config/agents-council/config.json` exists. Existing settings are preserved. Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `autotune` points you at `/autotune` at most once an hour, `dream` points you at `/dream` at most once every twelve hours per repository (stamped in that repository's git dir). A PreToolUse gate from the `democracy` skill blocks edits to files in a git worktree until the session holds a claim (`skills/democracy/scripts/council.py`), and its resource watch (`skills/democracy/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.config/agents-council/config.json` exists. Two hooks of the `subagent-model` skill (`skills/subagent-model/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Existing settings are preserved.
Restart your agent afterwards so it loads the new skills. Restart your agent afterwards so it loads the new skills.
+5
View File
@@ -0,0 +1,5 @@
[dependency-groups]
lint = ["ruff==0.17.3"]
[tool.ruff]
target-version = "py312"
+12 -10
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Enable the caveman and ponytail plugins in a Claude Code settings.json so // Enable the caveman and ponytail plugins in a Claude Code settings.json so
// their SessionStart hooks auto-activate both modes, register the autotune // their SessionStart hooks auto-activate both modes, register the autotune
// and dream reminder hooks, the democracy claim gate and the resource watch. Merges non-destructively: existing marketplaces, // and dream reminder hooks, the democracy claim gate, the resource watch and the subagent-model log. Merges non-destructively: existing marketplaces,
// plugins and hooks are preserved, unparseable files are left untouched. // plugins and hooks are preserved, unparseable files are left untouched.
"use strict"; "use strict";
@@ -60,21 +60,23 @@ if (!JSON.stringify(data.hooks.PreToolUse).includes("democracy/scripts/council.p
}); });
} }
const watch = path.join(path.dirname(settingsPath), "skills", "democracy", "scripts", "watch.py"); const SCRIPT_HOOKS = [
const WATCH_HOOKS = [ ["democracy/scripts/watch.py", "UserPromptSubmit", null, "watch"],
["UserPromptSubmit", null, "watch"], ["democracy/scripts/watch.py", "PostToolUse", "Bash", "watch"],
["PostToolUse", "Bash", "watch"], ["democracy/scripts/watch.py", "PreToolUse", "Bash", "block"],
["PreToolUse", "Bash", "block"], ["subagent-model/scripts/log.py", "PreToolUse", "Agent|Task", "spawn"],
["subagent-model/scripts/log.py", "PostToolUse", "Bash", "persist"],
]; ];
for (const [event, matcher, sub] of WATCH_HOOKS) { for (const [script, event, matcher, sub] of SCRIPT_HOOKS) {
data.hooks[event] = data.hooks[event] || []; data.hooks[event] = data.hooks[event] || [];
const registered = data.hooks[event].some((entry) => const registered = data.hooks[event].some((entry) =>
(entry.hooks || []).some((hook) => (entry.hooks || []).some((hook) =>
String(hook.command).includes("democracy/scripts/watch.py") && String(hook.command).endsWith(` ${sub}`))); String(hook.command).includes(script) && String(hook.command).endsWith(` ${sub}`)));
if (registered) continue; if (registered) continue;
const entry = { hooks: [{ type: "command", command: `python3 ${JSON.stringify(watch)} ${sub}` }] }; const file = path.join(path.dirname(settingsPath), "skills", ...script.split("/"));
const entry = { hooks: [{ type: "command", command: `python3 ${JSON.stringify(file)} ${sub}` }] };
data.hooks[event].push(matcher ? { matcher, ...entry } : entry); data.hooks[event].push(matcher ? { matcher, ...entry } : entry);
} }
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n"); fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
console.log(`skills: enabled caveman + ponytail plugins, the ${REMINDERS.join(" + ")} reminders, the democracy claim gate and resource watch in ${settingsPath}`); console.log(`skills: enabled caveman + ponytail plugins, the ${REMINDERS.join(" + ")} reminders, the democracy claim gate, the resource watch and the subagent-model log in ${settingsPath}`);
+1 -1
View File
@@ -30,6 +30,6 @@ fi
log "skills: updating from ${lockfile}..." log "skills: updating from ${lockfile}..."
jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do
log "skills: updating ${source}..." log "skills: updating ${source}..."
npx --yes skills add --yes "${source}" npx --yes skills add --yes --agent claude-code "${source}" </dev/null
done done
log "skills: update complete." log "skills: update complete."
+104
View File
@@ -0,0 +1,104 @@
---
name: branch-gc
description: >
Remove merged branches and worktrees with `pkgmgr branch gc`: dry run,
operator picks from the plan, then apply; unmerged items only one by one.
Trigger on /branch-gc, "branches aufräumen", "worktrees aufräumen",
"garbage collect".
---
`pkgmgr branch gc` decides what is merged and does the removing; this skill
decides what may go and who has to agree first. The dry run changes nothing,
and nothing is removed before the operator's pick in this run.
## Step 0: claim and chair
1. Claim `work/branch-gc-<repo>`, or `work/branch-gc-all` for every
repository, through the `democracy` skill.
2. The apply takes stacks down and ends with a host-wide
`docker image prune -f` and `docker builder prune -f`, which reach every
session's images and build cache. When another session holds the chair,
send it what, where and how long, and wait for the grant; the prune never
runs during a peer's build.
## Step 1: dry run
`pkgmgr branch gc --json`, with `--all` only when the operator asked for every
repository, output to `/tmp/branch-gc-<N>.json`. No `git fetch` of your own
before it: gc fetches without auto-maintenance, while a plain fetch on git
2.50 or later may prune the sandbox's bind-mount stubs. Every repository's
`warnings` (a failed fetch) go to the top of the plan. Sort the items:
- **Candidates**: `state` `merged-clean`, non-empty `actions`, and `skip`
null or `lock: liveness unknown`.
- **Step 5**: `state` `unmerged` or `dirty`.
- **Reported only**: `protected`, every other `skip`, and `stale` as one count
per repository.
Never override a state: a branch without own commits is `unmerged` even
though git sees its tip on the default branch, because it is fresh work.
`host_pid_namespace: false` means the dry run ran inside a sandbox: it saw no
processes and could not tell whether a lock is live. Say so above the plan;
the apply re-checks both on the host.
## Step 2: claims
Read `council.py list`. For every candidate whose branch or worktree a peer's
claim names, send that peer one message (Peer protocol) and wait up to 5
minutes. A kept or unanswered item leaves the plan as `keep (<peer>)`;
silence is not consent.
## Step 3: plan
One table:
| Repo | Branch or worktree | Merged by | Actions | Note |
Then ask with `AskUserQuestion`:
- **Call 1** (`multiSelect: true`): the candidates, grouped by repository,
nothing preselected. Each description names `merged_by` and every action
(stack with volumes, the `images`, worktree, local and remote branches), and
`lock checked at apply` where liveness was unknown.
- **Call 2** (`multiSelect: false`): `Apply: <n> items (Recommended)`,
`Revise the selection`, `Abort`. Revise repeats call 1.
## Step 4: apply
1. Re-read `council.py list` and drop every item claimed since the dry run.
2. `--apply` refuses outside the host PID namespace (exit 2) and deletes
remote branches, which is a push. Unless this session runs in the host PID
namespace and the project lets agents push, hand the operator one command
per repository, run from inside it, one `--branch` per picked item (the
worktree path for a detached worktree):
```bash
pkgmgr branch gc --apply --json --branch <name> --branch <name> | tee /tmp/branch-gc-apply-<N>.json
```
3. Read every item's `result` in that file: `done`, `done; image kept: <img>`
(still in use elsewhere), or `failed: <why>`.
Report a failure as it stands; never remove by hand what gc skipped or
refused.
## Step 5: unmerged and dirty
Never part of the apply. One `AskUserQuestion` question per item
(`multiSelect: false`): `Keep (Recommended)` and `Delete`, the description
naming what is lost (the item's `reason`: unique commits, dirty paths). For
each `Delete`, hand the operator
`pkgmgr branch purge --branch <name>`: it asks y/N per item and answers no
without a terminal, so it runs only in the operator's shell.
## Step 6: report
Removed, kept and skipped items, each with its reason, and
`docker system df` before and after. Report the release to the chair, tell
each peer whose line an item touched what went, and release the claim.
## Rules
- A pick from an earlier run does not carry over.
- Never widen the selection, and never route around a refusal through another
command or a peer.
+10 -2
View File
@@ -26,10 +26,16 @@ git log --no-merges --format='%h %s%n%b' <tag>..HEAD
commit it reverts inside the range. commit it reverts inside the range.
- A subject is a lead, not the change: read the diff (`git show --stat`, then - A subject is a lead, not the change: read the diff (`git show --stat`, then
the hunks) when the subject does not say what changed for the user. the hunks) when the subject does not say what changed for the user.
- When `gh` is available, check the previous tag's CI run - When `gh` is available, read the CI of the previous tag's commit and of HEAD,
(`gh run list --branch <tag> -L 1`). A failed run means that version was every workflow (`gh run list --commit <sha> --json
workflowName,status,conclusion`), not one picked by `-L 1`.
- Previous tag: a workflow that finished as a failure means that version was
never published: say so under *Upgrade notes* and carry its upgrade notes never published: say so under *Upgrade notes* and carry its upgrade notes
forward to this release. forward to this release.
- HEAD: a workflow that finished as a failure means pkgmgr would tag a
release that never goes stable, because its CI gate runs after the tag.
Queued, running or missing runs are normal; report only the failure, in
step 5.
## 2. Sort into sections ## 2. Sort into sections
@@ -130,6 +136,8 @@ used, shorten only the lines it names, and lint again.
`feat` → minor, otherwise patch). `feat` → minor, otherwise patch).
3. One line with the lint result (`markdownlint-cli2: 0 errors` or the manual 3. One line with the lint result (`markdownlint-cli2: 0 errors` or the manual
check that ran). check that ran).
4. Only when a workflow on HEAD finished as a failure: one line naming it and
its run, and the advice not to release before it is green.
Then stop. Never edit CHANGELOG.md or a version file, never run Then stop. Never edit CHANGELOG.md or a version file, never run
`pkgmgr release`, never tag: the operator releases. `pkgmgr release`, never tag: the operator releases.
+26 -5
View File
@@ -23,9 +23,13 @@ from pathlib import Path
FAILED = {"failure", "cancelled", "timed_out"} FAILED = {"failure", "cancelled", "timed_out"}
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
GENERIC = re.compile(r"Process completed with exit code|failed=0\b|FAILED - RETRYING") GENERIC = re.compile(r"failed=0\b|FAILED - RETRYING|^make(\[\d+\])?: \*\*\* \[.*\] Error \d+")
PROCESS_END = re.compile(r"##\[error\]Process completed with exit code")
ANNOTATION = re.compile(r"##\[error\]") ANNOTATION = re.compile(r"##\[error\]")
FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:") FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:")
LINT_AT = re.compile(r"\S+:\d+:\d+:? ((error|warning) )?[A-Z]{1,4}\d{3,4}\b")
LINT_CODE = re.compile(r"^[A-Z]{1,4}\d{3,4}\b")
LINT_ARROW = re.compile(r"^-->\s+\S+:\d+:\d+")
SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b") SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b")
TASK = re.compile(r"^TASK \[") TASK = re.compile(r"^TASK \[")
REFUSAL = "terminal escape sequences" REFUSAL = "terminal escape sequences"
@@ -49,6 +53,8 @@ def output_lines(text: str) -> list[tuple[str, str]]:
kept, echo, task = [], False, "" kept, echo, task = [], False, ""
for raw in text.splitlines(): for raw in text.splitlines():
line = clean(raw) line = clean(raw)
if PROCESS_END.search(line):
break
if line.startswith("##[group]Run "): if line.startswith("##[group]Run "):
echo = True echo = True
elif line.startswith("##[endgroup]"): elif line.startswith("##[endgroup]"):
@@ -60,13 +66,28 @@ def output_lines(text: str) -> list[tuple[str, str]]:
return kept return kept
def is_lint(lines: list[tuple[str, str]], index: int) -> bool:
line = lines[index][1]
if LINT_AT.search(line):
return True
following = lines[index + 1][1] if index + 1 < len(lines) else ""
return bool(LINT_CODE.match(line) and LINT_ARROW.match(following))
def signature(text: str) -> str: def signature(text: str) -> str:
lines = output_lines(text) lines = output_lines(text)
for pattern in (ANNOTATION, FATAL, SIGNAL): tiers = (
for task, line in reversed(lines): (lambda i: ANNOTATION.search(lines[i][1]), False),
if pattern.search(line): (lambda i: FATAL.search(lines[i][1]), True),
(lambda i: is_lint(lines, i), False),
(lambda i: SIGNAL.search(lines[i][1]), False),
)
for matches, with_task in tiers:
for index in reversed(range(len(lines))):
if matches(index):
task, line = lines[index]
found = ANNOTATION.sub("", line).strip() found = ANNOTATION.sub("", line).strip()
return normalise(f"{task} {found}" if pattern is FATAL and task else found) return normalise(f"{task} {found}" if with_task and task else found)
return normalise(lines[-1][1]) if lines else "<empty log>" return normalise(lines[-1][1]) if lines else "<empty log>"
+12 -5
View File
@@ -58,7 +58,8 @@ Read-only, in parallel, sizes included:
label, anonymous ones included. label, anonymous ones included.
- **Other agent dirs**: package and tool caches (pip, npm, pre-commit, act, - **Other agent dirs**: package and tool caches (pip, npm, pre-commit, act,
gh, buildx), worktrees with `git status --short` and whether their branch is gh, buildx), worktrees with `git status --short` and whether their branch is
merged or pushed, Ansible and build temp dirs. merged or pushed, Ansible and build temp dirs. With `pkgmgr` installed,
merged branches and worktrees go to the `branch-gc` skill instead.
- **Live use**: open handles (`fuser`, `lsof`) and running processes by name - **Live use**: open handles (`fuser`, `lsof`) and running processes by name
only (`ps -o pid,etime,comm`). Never print full command lines or only (`ps -o pid,etime,comm`). Never print full command lines or
environments: process arguments carry credentials. A sandboxed agent often environments: process arguments carry credentials. A sandboxed agent often
@@ -80,10 +81,12 @@ worktree or compose project name, a container label, an open handle, or
`unknown`. Then apply, first match wins: `unknown`. Then apply, first match wins:
1. Claimed by a peer: `keep`. 1. Claimed by a peer: `keep`.
2. Attributable to a peer that has not answered: `keep (no answer from 2. A job running on it (deploy, gate, test, swarm run, build): `keep
(running)`.
3. Attributable to a peer that has not answered: `keep (no answer from
<peer>)`. Silence is not consent. <peer>)`. Silence is not consent.
3. Archive duty: `archive first`. 4. Archive duty: `archive first`.
4. Everything else is a candidate with a risk class: 5. Everything else is a candidate with a risk class:
| Risk | Meaning | Typical items | | Risk | Meaning | Typical items |
| ---- | ------- | ------------- | | ---- | ------- | ------------- |
@@ -145,7 +148,11 @@ ticked items. Revise repeats call 1; nothing is deleted before `Execute`.
rest, then put the refused item to the operator again as its own option: an rest, then put the refused item to the operator again as its own option: an
explicit renewed selection may pass; if it is refused again, hand over the explicit renewed selection may pass; if it is refused again, hand over the
exact command with its list file for the operator to run. exact command with its list file for the operator to run.
- Running stacks go down through the project's teardown target, never by - Never stop, take down or kill a running job, and never ask its owner to, to
free space: not for a priority slot, not at a disk stop. Free idle items,
ask the operator for an exception, or wait; an owner's own disk guard only
alerts the chair, and only the operator can order a running job down.
- Idle running stacks go down through the project's teardown target, never by
removing their containers or volumes underneath them. removing their containers or volumes underneath them.
- Archive-first items: copy out, verify the copy, then delete. - Archive-first items: copy out, verify the copy, then delete.
- A delete failing on permissions (files owned by a container UID): use the - A delete failing on permissions (files owned by a container UID): use the
+18 -5
View File
@@ -76,8 +76,15 @@ records the result.
sends the chair what, where, for how long and its expected disk need, waits sends the chair what, where, for how long and its expected disk need, waits
for the grant, and reports the release. The chair keeps one queue, answers for the grant, and reports the release. The chair keeps one queue, answers
every request with its position, enforces the limits the operator set, every request with its position, enforces the limits the operator set,
checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`), checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`;
and never cancels a running grant to free capacity. test kinds also need CPU and RAM below the configured limits).
- A running job (deploy, gate, test, swarm run, build) is never cut short to
free capacity. The chair neither asks nor orders its owner to stop, take
down or kill it, not for an operator-prioritised slot and not for a
projected disk stop. It grants nothing new, frees idle items through the
`cleanup` flow, asks the operator for an exception, or waits. An owner's
own disk guard only alerts the chair and takes nothing down; only the
operator can order a running job down.
- Handover is explicit: the outgoing chair sends every peer the new holder, - Handover is explicit: the outgoing chair sends every peer the new holder,
the queue and the running grants, and keeps allocating until the incoming the queue and the running grants, and keeps allocating until the incoming
chair confirms. Two allocators never run at once. chair confirms. Two allocators never run at once.
@@ -94,9 +101,11 @@ stays silent below the thresholds.
- **Warn**: the chair and every session get the biggest consumers; the chair - **Warn**: the chair and every session get the biggest consumers; the chair
plans a cleanup and grants no large slot. plans a cleanup and grants no large slot.
- **Stop**: new heavy jobs are blocked in every session (teardown commands - **Stop**: new heavy jobs are blocked in every session (teardown commands
never), and the hook runs the configured low-risk cleanup by itself. never), and the hook runs the configured low-risk cleanup by itself; it
- **Critical**: additionally a desktop alarm to the operator. A running stack skips every worktree where a gate or deploy process still runs.
is torn down only with the operator's OK. - **Critical**: additionally a desktop alarm to the operator. Running jobs
and their stacks still keep running; only the operator can make an
exception.
- A measured fall that would reach the critical threshold within the - A measured fall that would reach the critical threshold within the
projection window raises the stage by one at once. projection window raises the stage by one at once.
- Caches together stay under their budget; the least recently used go first. - Caches together stay under their budget; the least recently used go first.
@@ -155,6 +164,10 @@ The one protocol for every exchange between agents; `coordinate`, `triage`,
permission: never ask a peer to run what your own session is denied; report permission: never ask a peer to run what your own session is denied; report
it to the operator. it to the operator.
- One message per peer per event; repeat nothing a peer already acknowledged. - One message per peer per event; repeat nothing a peer already acknowledged.
- Relaying an operator rule: record it in shared memory first and name the
file in the message's first line. A recipient records it only when no file
is named, and only after `council.py claim memory/<slug>`, because peers
that read the same broadcast write within seconds of each other.
- Release every claim when its work ends; a forgotten claim blocks the work - Release every claim when its work ends; a forgotten claim blocks the work
for everyone. for everyone.
+1 -1
View File
@@ -89,7 +89,7 @@ def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> N
"session": sid, "session": sid,
"runtime": runtime, "runtime": runtime,
"name": live_sessions().get(sid), "name": live_sessions().get(sid),
"since": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "since": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
"beat": time.time(), "beat": time.time(),
"work": work, "work": work,
} }
+68 -5
View File
@@ -25,8 +25,11 @@ projection_minutes, interval_seconds, cache_interval_seconds, heavy_commands,
never_block (regexes), cleanup (shell commands), worktree_cleanup (shell never_block (regexes), cleanup (shell commands), worktree_cleanup (shell
commands with {worktree}), worktrees_command (shell command printing the commands with {worktree}), worktrees_command (shell command printing the
running stacks' worktree paths, one per line), worktree_marker (path relative running stacks' worktree paths, one per line), worktree_marker (path relative
to a worktree that must exist), cache_dirs, cache_budget_percent, to a worktree that must exist), busy_processes (process names; a worktree
slot_need_gb {kind: GB}, notify (argv the alert text is appended to). where one of them runs is skipped), cache_dirs, cache_budget_percent,
slot_need_gb {kind: GB}, notify (argv the alert text is appended to),
load_limits {cpu_percent, mem_percent, sample_seconds, kinds}: admit for a
kind listed in kinds also needs CPU busy and RAM used below the limits.
""" """
from __future__ import annotations from __future__ import annotations
@@ -205,9 +208,17 @@ def cmd_watch(cfg: dict) -> int:
return 0 return 0
HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2(?=\s|\)|$)", re.DOTALL)
QUOTED = re.compile(r"'[^']*'|\"(?:\\.|[^\"\\])*\"")
def executable_text(command: str) -> str:
return QUOTED.sub("''", HEREDOC.sub("", command))
def cmd_block(cfg: dict) -> int: def cmd_block(cfg: dict) -> int:
payload = json.load(sys.stdin) payload = json.load(sys.stdin)
command = (payload.get("tool_input") or {}).get("command", "") command = executable_text((payload.get("tool_input") or {}).get("command", ""))
stage = read_json(STATE).get("stage", "ok") stage = read_json(STATE).get("stage", "ok")
if stage not in ("stop", "critical"): if stage not in ("stop", "critical"):
return 0 return 0
@@ -223,13 +234,49 @@ def cmd_block(cfg: dict) -> int:
return 0 return 0
def cpu_times() -> tuple[int, int]:
fields = [int(v) for v in Path("/proc/stat").read_text(encoding="utf-8").split("\n", 1)[0].split()[1:]]
return sum(fields), fields[3] + fields[4]
def cpu_busy_percent(before: tuple[int, int], after: tuple[int, int]) -> float:
total = after[0] - before[0]
idle = after[1] - before[1]
return 100.0 * (total - idle) / total
def mem_used_percent(meminfo: str) -> float:
values = {}
for line in meminfo.splitlines():
key, _, rest = line.partition(":")
if rest:
values[key] = int(rest.split()[0])
return 100.0 * (1 - values["MemAvailable"] / values["MemTotal"])
def load(cfg: dict) -> tuple[float, float]:
before = cpu_times()
time.sleep(cfg["load_limits"]["sample_seconds"])
cpu = cpu_busy_percent(before, cpu_times())
return cpu, mem_used_percent(Path("/proc/meminfo").read_text(encoding="utf-8"))
def cmd_admit(cfg: dict, args: argparse.Namespace) -> int: def cmd_admit(cfg: dict, args: argparse.Namespace) -> int:
need = args.need_gb if args.need_gb is not None else cfg["slot_need_gb"][args.kind] need = args.need_gb if args.need_gb is not None else cfg["slot_need_gb"][args.kind]
free, _ = disk(cfg) free, _ = disk(cfg)
left = free - need left = free - need
floor = cfg["thresholds_gb"]["stop"] floor = cfg["thresholds_gb"]["stop"]
verdict = "grant" if left >= floor else "deny" passed = [left >= floor]
print(f"{verdict}: free {free:.1f} GB, need {need} GB, leaves {left:.1f} GB (floor {floor} GB)") report = [f"free {free:.1f} GB, need {need} GB, leaves {left:.1f} GB (floor {floor} GB)"]
limits = cfg["load_limits"]
if args.kind in limits["kinds"]:
cpu, mem = load(cfg)
passed += [cpu < limits["cpu_percent"], mem < limits["mem_percent"]]
report.append(
f"cpu {cpu:.0f} % (limit {limits['cpu_percent']}), ram {mem:.0f} % (limit {limits['mem_percent']})"
)
verdict = "grant" if all(passed) else "deny"
print(f"{verdict}: {'; '.join(report)}")
return 0 if verdict == "grant" else 3 return 0 if verdict == "grant" else 3
@@ -275,9 +322,25 @@ def running_worktrees(cfg: dict) -> list[str]:
return [t for t in trees if os.path.exists(os.path.join(t, cfg["worktree_marker"]))] return [t for t in trees if os.path.exists(os.path.join(t, cfg["worktree_marker"]))]
def busy(tree: str, names: list[str]) -> bool:
root = os.path.realpath(tree)
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
if Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip() not in names:
continue
cwd = os.readlink(f"/proc/{pid}/cwd")
except OSError:
continue
if cwd == root or cwd.startswith(root + os.sep):
return True
return False
def run_cleanup(cfg: dict) -> list[str]: def run_cleanup(cfg: dict) -> list[str]:
commands = list(cfg["cleanup"]) commands = list(cfg["cleanup"])
for tree in running_worktrees(cfg): for tree in running_worktrees(cfg):
if busy(tree, cfg["busy_processes"]):
continue
commands += [t.format(worktree=shlex.quote(tree)) for t in cfg["worktree_cleanup"]] commands += [t.format(worktree=shlex.quote(tree)) for t in cfg["worktree_cleanup"]]
results = [] results = []
for command in commands: for command in commands:
+2 -1
View File
@@ -30,7 +30,8 @@ asking; ask only when an operator-only fact blocks the conclusion.
make or CLI helpers the project exposes); CI logs and artifacts; tests; docs. make or CLI helpers the project exposes); CI logs and artifacts; tests; docs.
Cite `file:line` and command output — never a bare assertion. Cite `file:line` and command output — never a bare assertion.
2. **Antithesis.** Spawn MULTIPLE independent skeptics (parallel subagents or a 2. **Antithesis.** Spawn MULTIPLE independent skeptics (parallel subagents or a
workflow) whose only job is to REFUTE the thesis. Each re-inspects the workflow, on the default model as the `subagent-model` skill requires)
whose only job is to REFUTE the thesis. Each re-inspects the
sources itself and defaults to "refuted" on any concrete hole. Give diverse sources itself and defaults to "refuted" on any concrete hole. Give diverse
skeptics diverse lenses: correctness, an alternate mechanism, does-it- skeptics diverse lenses: correctness, an alternate mechanism, does-it-
reproduce, does-the-fix-break-something-else. reproduce, does-the-fix-break-something-else.
+8 -5
View File
@@ -20,9 +20,11 @@ in a sidetree until it does, or leaves as a proposal.
## The stages ## The stages
1. **Brief.** One concern per agent. Name the goal, the exact base commit, the 1. **Brief.** One concern per agent, on the model the `subagent-model` skill
scope that may change, the evidence expected back, a hard time cap (default picks. Name the goal, the exact base commit, the
60 minutes) and what the agent must not do: no pushes, no gates or deploys scope that may change, the evidence expected back, a time cap (default
60 minutes) at which it reports its interim state, and what the agent must
not do: no pushes, no gates or deploys
unless the resource chair of the `democracy` skill granted them, read-only unless the resource chair of the `democracy` skill granted them, read-only
probes on shared live systems. probes on shared live systems.
2. **Sidetree.** The agent works in its own worktree on its own branch, 2. **Sidetree.** The agent works in its own worktree on its own branch,
@@ -93,8 +95,9 @@ below the bar never enters the main line on its own.
## Rules ## Rules
- Stop an agent at its time cap and ask for its findings; an uncapped brief can - At its time cap, ask an agent for its interim findings and let it run on;
run for hours and deliver nothing. never stop a running agent. A brief without a cap can run for hours without
a word.
- Run agents in parallel only when their concerns touch different files and - Run agents in parallel only when their concerns touch different files and
the resource chair grants the capacity; gates and deploys need the machine the resource chair grants the capacity; gates and deploys need the machine
to themselves. to themselves.
+2 -1
View File
@@ -35,7 +35,8 @@ one line so a wrong reading is visible before the search.
## 4. Search every ref ## 4. Search every ref
Search by keyword, by literal and by path in one call; they find different Search by keyword, by literal and by path in one call; they find different
things: things, and a commit matching any one of them is a hit, so `--path` widens
the search instead of narrowing `--grep`:
```bash ```bash
${CLAUDE_SKILL_DIR}/scripts/find_fix.py --grep '<keyword>' -S '<symbol or literal>' --path '<path or glob>' ${CLAUDE_SKILL_DIR}/scripts/find_fix.py --grep '<keyword>' -S '<symbol or literal>' --path '<path or glob>'
+72
View File
@@ -0,0 +1,72 @@
---
name: subagent-model
description: >
Pick `model` and `effort` per subagent from a fixed table: cheaper only on a
clear match at 90 % that the result equals the default's; log spawns and
quality. Apply AUTOMATICALLY before every Agent tool call.
---
A subagent runs on the main model and effort unless the spawn names others.
Set `model` and `effort` on an Agent call only when the task clearly matches a
row below and you are at least 90 % sure the cheaper setting delivers the same
result as the default: no lost findings, no rework. Cost counts per finished
task, retries included. Otherwise set neither, and the default stays.
## Table
| Task class | Subagent's task | `model` | `effort` | Evidence |
| --- | --- | --- | --- | --- |
| `lookup` | Read-only: find files, symbols or call sites, grep, list, read and quote, pull a log line or field, summarise a given text without judging it | `haiku` | `low` | Haiku 5.5 costs 1/40 of Opus 5.5 per token and is the speed tier; Anthropic names Haiku at low effort for simple, speed-critical and reading-heavy worker tasks; Explore and the cavecrew investigator already run on it |
| `mechanical-edit` | An edit the brief fully specifies (exact old and new text, a rename, deleting listed lines) in one or two files | `haiku` | `low` | nothing is left to decide, and the parent checks the diff |
| `judgement` | Everything else: root cause, skeptic or dialectic antithesis, review, security or legal checks, design, new or multi-file code, writing tests, performance work | omit | omit | no evidence at 90 % that a cheaper setting gives the same result |
Prices per million tokens in and out (claude-api reference, cached
2026-10-06): Haiku 5.5 $0.10 / $0.50, Sonnet 5.5 $2 / $10, Opus 5.5 $4 / $20,
Fable 5.1 $10 / $50.
## Spawn
1. Pick the class. A task that mixes classes, or whose class you hesitate
over, is `judgement`.
2. Start the brief with the line `task-class: <class>`; the spawn hook logs
it together with `model`, `effort` and the Agent `description`.
3. Set `model` and `effort` from the row; for `judgement` set neither.
- An agent type whose definition sets its own model (Explore, Plan, the
cavecrew agents) keeps it; never pass `model` or `effort` for it.
- Never pass `fable` or `opus`: neither is cheaper than the default.
- A Haiku agent that reports the task needs judgement, or returns an
incomplete result, is not retried on Haiku: the default continues it.
- Never change the default itself (settings, `CLAUDE_CODE_SUBAGENT_MODEL`,
agent definitions); this skill only chooses per call.
## Feedback
Once you have judged the subagent's result, rate it under the same Agent
`description`:
```bash
${CLAUDE_SKILL_DIR}/scripts/log.py feedback --description "<description>" --completed yes|no --tests pass|fail|none --redo yes|no
```
`--redo yes` when the default had to redo or finish the work. When the
operator later asks a follow-up question about that result or corrects it:
```bash
${CLAUDE_SKILL_DIR}/scripts/log.py followup --description "<description>" --text "<question or correction>"
```
All sessions share `~/.local/state/agents-council/subagent-model.jsonl`.
Inside a sandbox that cannot write it, the script prints the line as
`pending:` and the PostToolUse hook stores it.
## Evaluation
`log.py report` shows per task class, model and effort the runs, the rated
runs and the share with the same result: completed, not redone, no failing
test, no follow-up. A row is added, or a cheaper setting extended to a class,
only when the report shows at least 90 % over at least 20 rated runs, and only
after the operator confirms it (for example through `/autotune`). A row whose
rate falls below 90 % over 20 rated runs goes back to the default. `sonnet`
enters the table only this way, and a trial run on a setting the table does
not hold needs the operator's order.
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Log subagent spawns with their quality feedback, and evaluate the table.
Usage:
log.py spawn hook (PreToolUse on Agent): append the spawn line
log.py feedback --description D --completed yes|no --tests pass|fail|none --redo yes|no
rate the latest spawn with this Agent description
log.py followup --description D --text T
record an operator follow-up question or correction on it
log.py persist hook (PostToolUse on Bash): append the one line a sandboxed
feedback or followup printed as `pending: <json>`, only
when the command was that bare call and the line is
well-formed and from the hook's own session
log.py report runs, rated runs and same-result rate per task class,
model and effort; a row qualifies at >= 90 % over >= 20
Environment:
SUBAGENT_MODEL_LOG: the log shared by every session;
~/.local/state/agents-council/subagent-model.jsonl unless set.
CLAUDE_CODE_SESSION_ID: the session that rates its spawns.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import re
import shlex
import sys
from pathlib import Path
LOG = Path(
os.environ.get(
"SUBAGENT_MODEL_LOG",
str(Path.home() / ".local" / "state" / "agents-council" / "subagent-model.jsonl"),
)
)
TASK_CLASS = re.compile(r"^task-class:\s*(\S+)", re.MULTILINE)
PENDING = "pending: "
SCRIPT = "subagent-model/scripts/log.py"
CONTROL = re.compile(r"^[();<>|&]+$")
KEYS = {
"feedback": {"kind", "time", "session", "description", "completed", "tests", "redo"},
"followup": {"kind", "time", "session", "description", "text"},
}
QUALIFY_RUNS = 20
QUALIFY_RATE = 0.9
def now() -> str:
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
def append(line: dict) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(line, sort_keys=True) + "\n")
def record(line: dict) -> int:
try:
append(line)
except OSError:
print(PENDING + json.dumps(line, sort_keys=True))
return 0
def cmd_spawn() -> int:
payload = json.load(sys.stdin)
tool = payload.get("tool_input") or {}
match = TASK_CLASS.search(tool.get("prompt", ""))
append({
"kind": "spawn",
"time": now(),
"session": payload.get("session_id", ""),
"description": tool.get("description", ""),
"subagent_type": tool.get("subagent_type") or "general-purpose",
"task_class": match.group(1) if match else "unclassified",
"model": tool.get("model") or "default",
"effort": tool.get("effort") or "default",
})
return 0
def rated(kind: str, args: argparse.Namespace, **fields: object) -> dict:
session = os.environ.get("CLAUDE_CODE_SESSION_ID")
if not session:
sys.exit("log.py: CLAUDE_CODE_SESSION_ID is not set")
return {"kind": kind, "time": now(), "session": session, "description": args.description, **fields}
def invoked_kind(command: str) -> str | None:
if "$(" in command or "`" in command:
return None
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
try:
tokens = list(lexer)
except ValueError:
return None
if any(CONTROL.match(token) for token in tokens):
return None
if tokens[:1] in (["python3"], ["python"]):
tokens = tokens[1:]
if len(tokens) < 2 or not tokens[0].endswith(SCRIPT) or tokens[1] not in KEYS:
return None
return tokens[1]
def well_formed(line: object, kind: str, session: str) -> bool:
if not isinstance(line, dict) or set(line) != KEYS[kind] or line["kind"] != kind or line["session"] != session:
return False
if not all(isinstance(line[key], str) for key in ("time", "description")):
return False
if kind == "feedback":
return isinstance(line["completed"], bool) and isinstance(line["redo"], bool) and line["tests"] in ("pass", "fail", "none")
return isinstance(line["text"], str)
def cmd_persist() -> int:
payload = json.load(sys.stdin)
kind = invoked_kind((payload.get("tool_input") or {}).get("command", ""))
if not kind:
return 0
response = payload.get("tool_response")
stdout = response.get("stdout", "") if isinstance(response, dict) else str(response or "")
pending = [text[len(PENDING):] for text in stdout.splitlines() if text.startswith(PENDING)]
if len(pending) != 1:
return 0
try:
line = json.loads(pending[0])
except json.JSONDecodeError:
return 0
if well_formed(line, kind, payload.get("session_id", "")):
append(line)
return 0
def runs() -> list[dict]:
current: dict[tuple[str, str], dict] = {}
found: list[dict] = []
for text in LOG.read_text(encoding="utf-8").splitlines() if LOG.exists() else []:
line = json.loads(text)
key = (line["session"], line["description"])
if line["kind"] == "spawn":
current[key] = {**line, "feedback": None, "followups": 0}
found.append(current[key])
elif key in current and line["kind"] == "feedback":
current[key]["feedback"] = line
elif key in current and line["kind"] == "followup":
current[key]["followups"] += 1
return found
def same_result(run: dict) -> bool:
feedback = run["feedback"]
return feedback["completed"] and not feedback["redo"] and feedback["tests"] != "fail" and not run["followups"]
def cmd_report() -> int:
rows: dict[tuple[str, str, str], list[dict]] = {}
for run in runs():
rows.setdefault((run["task_class"], run["model"], run["effort"]), []).append(run)
print("task_class\tmodel\teffort\truns\trated\tsame\trate\tqualifies")
for (task_class, model, effort), group in sorted(rows.items()):
rated_runs = [r for r in group if r["feedback"]]
same = sum(map(same_result, rated_runs))
rate = same / len(rated_runs) if rated_runs else 0.0
qualifies = len(rated_runs) >= QUALIFY_RUNS and rate >= QUALIFY_RATE
print(f"{task_class}\t{model}\t{effort}\t{len(group)}\t{len(rated_runs)}\t{same}\t{rate:.0%}\t{'yes' if qualifies else 'no'}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("spawn")
feedback = sub.add_parser("feedback")
feedback.add_argument("--description", required=True)
feedback.add_argument("--completed", required=True, choices=("yes", "no"))
feedback.add_argument("--tests", required=True, choices=("pass", "fail", "none"))
feedback.add_argument("--redo", required=True, choices=("yes", "no"))
followup = sub.add_parser("followup")
followup.add_argument("--description", required=True)
followup.add_argument("--text", required=True)
sub.add_parser("persist")
sub.add_parser("report")
args = parser.parse_args()
if args.command == "spawn":
return cmd_spawn()
if args.command == "feedback":
return record(rated(
"feedback", args, completed=args.completed == "yes", tests=args.tests, redo=args.redo == "yes"
))
if args.command == "followup":
return record(rated("followup", args, text=args.text))
if args.command == "persist":
return cmd_persist()
return cmd_report()
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -55,7 +55,8 @@ jobs' logs, and a first clustering. The judgement in those steps stays yours.
5. **One funnel per cluster.** Hand every cluster you claimed in step 3 to the 5. **One funnel per cluster.** Hand every cluster you claimed in step 3 to the
`funnel` skill: one agent per cluster, working in its own worktree on its own `funnel` skill: one agent per cluster, working in its own worktree on its own
branch created from the exact current HEAD, with that cluster as its only branch created from the exact current HEAD, with that cluster as its only
concern, the artifact paths of step 4 in its brief and a hard time cap. concern, the artifact paths of step 4 in its brief and a time cap for its
interim report.
Steps 6 to 8 run inside that sidetree, so no cluster's edits land under Steps 6 to 8 run inside that sidetree, so no cluster's edits land under
another cluster's work or under a running gate or deploy. Run clusters in another cluster's work or under a running gate or deploy. Run clusters in
parallel only when their fixes touch different files and the machine has parallel only when their fixes touch different files and the machine has
+3 -3
View File
@@ -158,11 +158,11 @@ Python hot path with Rust:
## Delegating an optimization ## Delegating an optimization
Hand every optimization to an agent through the `funnel` skill: one sidetree Hand every optimization to an agent through the `funnel` skill: one sidetree
per candidate, branched from an exact commit, with a hard time cap and "measure per candidate, branched from an exact commit, with a time cap and "measure
first, report the time split before implementing". The agent probes live first, report the time split before implementing". The agent probes live
systems read-only and never starts gates or deploys. A broad brief without a systems read-only and never starts gates or deploys. A broad brief without a
cap can run for hours and deliver nothing; stop an agent at its cap and collect cap can run for hours without a word; at its cap, collect the agent's interim
its findings. findings and let it run on.
Before merging, run independent skeptics through the `dialectic` skill. The Before merging, run independent skeptics through the `dialectic` skill. The
antithesis must try to show that the candidate removes coverage, hides a antithesis must try to show that the candidate removes coverage, hides a
+14
View File
@@ -50,6 +50,20 @@ class TestCiFailures(unittest.TestCase):
'TASK [web-app-x : Wait for health] **** fatal: [localhost]: FAILED! => {"msg": "timeout <n>"}', 'TASK [web-app-x : Wait for health] **** fatal: [localhost]: FAILED! => {"msg": "timeout <n>"}',
) )
def test_lint_finding_beats_make_error_and_later_steps(self):
text = (
"Ran 41 tests in 1.0s\n"
"OK\n"
"UP017 [*] Use `datetime.UTC` alias\n"
" --> skills/democracy/scripts/council.py:92:40\n"
"Found 1 error.\n"
"make: *** [Makefile:15: test] Error 1\n"
"##[error]Process completed with exit code 2.\n"
"Post job cleanup.\n"
"SHA256 digest of uploaded artifact is 0123456789abcdef\n"
)
self.assertEqual(self.ci.signature(text), "UP<n> [*] Use `datetime.UTC` alias")
def test_generic_exit_code_line_is_not_the_signature(self): def test_generic_exit_code_line_is_not_the_signature(self):
text = "Error: disk full\n##[error]Process completed with exit code 1.\n" text = "Error: disk full\n##[error]Process completed with exit code 1.\n"
self.assertEqual(self.ci.signature(text), "Error: disk full") self.assertEqual(self.ci.signature(text), "Error: disk full")
+2
View File
@@ -132,6 +132,8 @@ class TestSettingsRegistration(unittest.TestCase):
self.assertEqual(entries.count("democracy/scripts/watch.py"), 1) self.assertEqual(entries.count("democracy/scripts/watch.py"), 1)
post = json.dumps(data["hooks"]["PostToolUse"]) post = json.dumps(data["hooks"]["PostToolUse"])
self.assertEqual(post.count("democracy/scripts/watch.py"), 1) self.assertEqual(post.count("democracy/scripts/watch.py"), 1)
self.assertEqual(gate.count("subagent-model/scripts/log.py"), 1)
self.assertEqual(post.count("subagent-model/scripts/log.py"), 1)
if __name__ == "__main__": if __name__ == "__main__":
+107
View File
@@ -0,0 +1,107 @@
"""Exercise the subagent-model log: spawn hook, feedback, sandbox persist, report."""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
LOG_PY = REPO_ROOT / "skills" / "subagent-model" / "scripts" / "log.py"
class TestSubagentModelLog(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.log = self.base / "state" / "subagent-model.jsonl"
def _run(self, *args: str, stdin: str = "", log: Path | None = None) -> subprocess.CompletedProcess:
env = {"SUBAGENT_MODEL_LOG": str(log or self.log), "CLAUDE_CODE_SESSION_ID": "s1"}
return subprocess.run(
[sys.executable, str(LOG_PY), *args], capture_output=True, text=True, env=env, input=stdin, check=False
)
def _spawn(self, description: str, prompt: str, **tool: str) -> None:
payload = {"session_id": "s1", "tool_input": {"description": description, "prompt": prompt, **tool}}
self.assertEqual(self._run("spawn", stdin=json.dumps(payload)).returncode, 0)
def _feedback(self, description: str, completed: str = "yes", redo: str = "no", tests: str = "none") -> None:
result = self._run(
"feedback", "--description", description, "--completed", completed, "--tests", tests, "--redo", redo
)
self.assertEqual(result.returncode, 0, result.stderr)
def _lines(self) -> list[dict]:
return [json.loads(text) for text in self.log.read_text(encoding="utf-8").splitlines()]
def _report(self) -> dict[str, list[str]]:
rows = self._run("report").stdout.splitlines()[1:]
return {row.split("\t")[0]: row.split("\t") for row in rows}
def test_spawn_logs_class_model_and_effort_or_the_defaults(self):
self._spawn("find callers", "task-class: lookup\nFind every caller.", model="haiku", effort="low")
self._spawn("fix it", "Find the root cause.")
first, second = self._lines()
self.assertEqual((first["task_class"], first["model"], first["effort"]), ("lookup", "haiku", "low"))
self.assertEqual((second["task_class"], second["model"], second["effort"]), ("unclassified", "default", "default"))
def _pending_feedback(self) -> str:
blocked = self.base / "file"
blocked.write_text("", encoding="utf-8")
result = self._run(
"feedback", "--description", "d", "--completed", "yes", "--tests", "none", "--redo", "no",
log=blocked / "log.jsonl",
)
self.assertTrue(result.stdout.startswith("pending: "), result.stdout)
return result.stdout
def _persist(self, command: str, stdout: str, session: str = "s1") -> None:
payload = {"session_id": session, "tool_input": {"command": command}, "tool_response": {"stdout": stdout}}
self.assertEqual(self._run("persist", stdin=json.dumps(payload)).returncode, 0)
def test_sandboxed_feedback_is_printed_and_stored_by_the_persist_hook(self):
stdout = self._pending_feedback()
self._persist("python3 /x/skills/subagent-model/scripts/log.py feedback --description 'd'", stdout)
self.assertEqual(self._lines()[0]["kind"], "feedback")
def test_persist_rejects_pending_lines_it_did_not_get_from_a_bare_log_call(self):
stdout = self._pending_feedback()
line = json.loads(stdout[len("pending: "):])
call = "/x/skills/subagent-model/scripts/log.py feedback --description d"
for command, output, session in (
("cat ci.log", stdout, "s1"),
(f"{call}; cat ci.log", stdout, "s1"),
(f"{call} | tee /tmp/x", stdout, "s1"),
(f"echo $({call})", stdout, "s1"),
(call, stdout, "other-session"),
(call, stdout + stdout, "s1"),
(call, "pending: " + json.dumps({**line, "extra": 1}), "s1"),
(call, "pending: " + json.dumps({**line, "completed": "yes"}), "s1"),
("/x/skills/subagent-model/scripts/log.py followup --description d", stdout, "s1"),
):
self._persist(command, output, session)
self.assertFalse(self.log.exists())
def test_report_qualifies_a_row_only_at_ninety_percent_over_twenty_rated_runs(self):
for i in range(20):
self._spawn(f"q{i}", "task-class: lookup", model="haiku", effort="low")
self._feedback(f"q{i}", redo="yes" if i < 2 else "no")
for i in range(19):
self._spawn(f"r{i}", "task-class: mechanical-edit", model="haiku", effort="low")
self._feedback(f"r{i}")
self._run("followup", "--description", "q5", "--text", "that list missed a caller")
report = self._report()
self.assertEqual(report["lookup"][3:], ["20", "20", "17", "85%", "no"])
self.assertEqual(report["mechanical-edit"][3:], ["19", "19", "19", "100%", "no"])
self._spawn("r19", "task-class: mechanical-edit", model="haiku", effort="low")
self._feedback("r19")
self.assertEqual(self._report()["mechanical-edit"][-1], "yes")
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""update.sh must reach every locked source and install for Claude Code only."""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
UPDATE = REPO_ROOT / "scripts" / "update.sh"
@unittest.skipUnless(shutil.which("jq") and shutil.which("bash"), "jq or bash missing")
class TestUpdate(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.bin = Path(self.tmp.name)
self.log = self.bin / "calls.log"
npx = self.bin / "npx"
npx.write_text(f'#!/bin/sh\ncat >/dev/null\necho "$@" >> "{self.log}"\n', encoding="utf-8")
npx.chmod(0o755)
for tool in ("jq", "sort"):
(self.bin / tool).symlink_to(shutil.which(tool))
def test_every_source_is_updated_for_claude_code_only(self):
lock = json.loads((REPO_ROOT / "skills-lock.json").read_text(encoding="utf-8"))
sources = sorted({entry["source"] for entry in lock["skills"].values()})
subprocess.run(
["bash", str(UPDATE)],
check=True,
capture_output=True,
env={"PATH": f"{self.bin}:/usr/bin:/bin"},
stdin=subprocess.DEVNULL,
)
calls = self.log.read_text(encoding="utf-8").splitlines()
self.assertEqual([c.split()[-1] for c in calls], sources)
for call in calls:
self.assertIn("--agent claude-code", call)
if __name__ == "__main__":
unittest.main()
+52 -1
View File
@@ -34,6 +34,11 @@ class TestStage(unittest.TestCase):
for free, stage in ((50, "ok"), (30, "warn"), (14, "stop"), (5, "critical")): for free, stage in ((50, "ok"), (30, "warn"), (14, "stop"), (5, "critical")):
self.assertEqual(self.watch.stage_for(free, [], self.cfg, now), stage) self.assertEqual(self.watch.stage_for(free, [], self.cfg, now), stage)
def test_cpu_and_ram_percentages(self):
self.assertEqual(self.watch.cpu_busy_percent((100, 40), (200, 60)), 80.0)
meminfo = "MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n"
self.assertEqual(self.watch.mem_used_percent(meminfo), 75.0)
def test_fast_fall_escalates_one_stage(self): def test_fast_fall_escalates_one_stage(self):
now = time.time() now = time.time()
self.assertEqual(self.watch.stage_for(32, [[now - 600, 48]], self.cfg, now), "stop") self.assertEqual(self.watch.stage_for(32, [[now - 600, 48]], self.cfg, now), "stop")
@@ -61,10 +66,12 @@ class TestWatchCommands(unittest.TestCase):
"worktree_cleanup": [], "worktree_cleanup": [],
"worktrees_command": "true", "worktrees_command": "true",
"worktree_marker": "Makefile", "worktree_marker": "Makefile",
"busy_processes": ["sleep"],
"cache_dirs": [str(self.cache)], "cache_dirs": [str(self.cache)],
"cache_budget_percent": 3, "cache_budget_percent": 3,
"slot_need_gb": {"swarm": 30}, "slot_need_gb": {"swarm": 30, "test": 0.001},
"notify": ["true"], "notify": ["true"],
"load_limits": {"cpu_percent": 101, "mem_percent": 101, "sample_seconds": 0.1, "kinds": ["test"]},
} }
self._write_config() self._write_config()
@@ -98,12 +105,41 @@ class TestWatchCommands(unittest.TestCase):
self._stage("warn") self._stage("warn")
self.assertEqual(self._run("block", stdin=self._bash("make compose-deploy")).returncode, 0) self.assertEqual(self._run("block", stdin=self._bash("make compose-deploy")).returncode, 0)
def test_block_ignores_heavy_words_inside_quotes_and_heredocs(self):
self.cfg["heavy_commands"] = [r"\bmake\s+(-C\s+\S+\s+)?(compose-deploy|test|swarm-\S+)(\s|$)"]
self._write_config()
self._stage("stop")
message = "$(cat <<'EOF'\nfix: run \"make test\" and make swarm-zombie\nEOF\n)"
for command in (
'git commit --no-verify -m "fix: passes the vars to make swarm-zombie app=x"',
"grep -n 'make test' Makefile",
f'git commit --no-verify -m "{message}"',
):
self.assertEqual(self._run("block", stdin=self._bash(command)).returncode, 0, command)
for command in (
"make -C x swarm-zombie app=y",
"env FOO=1 make swarm-zombie",
'make compose-deploy apps="web-app-x"',
):
self.assertEqual(self._run("block", stdin=self._bash(command)).returncode, 2, command)
def test_admit_keeps_the_stop_floor(self): def test_admit_keeps_the_stop_floor(self):
self.cfg["thresholds_gb"] = {"warn": 0, "stop": 0, "critical": 0} self.cfg["thresholds_gb"] = {"warn": 0, "stop": 0, "critical": 0}
self._write_config() self._write_config()
self.assertEqual(self._run("admit", "--need-gb", "0.001").returncode, 0) self.assertEqual(self._run("admit", "--need-gb", "0.001").returncode, 0)
self.assertEqual(self._run("admit", "--need-gb", "1e9").returncode, 3) self.assertEqual(self._run("admit", "--need-gb", "1e9").returncode, 3)
def test_admit_for_a_load_kind_also_needs_cpu_and_ram_below_the_limits(self):
self.cfg["thresholds_gb"] = {"warn": 0, "stop": 0, "critical": 0}
self._write_config()
self.assertEqual(self._run("admit", "--kind", "test").returncode, 0)
self.cfg["load_limits"]["cpu_percent"] = 0
self._write_config()
denied = self._run("admit", "--kind", "test")
self.assertEqual(denied.returncode, 3)
self.assertIn("cpu", denied.stdout)
self.assertEqual(self._run("admit", "--need-gb", "0.001").returncode, 0)
def test_warn_alert_reaches_each_session_once(self): def test_warn_alert_reaches_each_session_once(self):
self.cfg["thresholds_gb"] = {"warn": 1e9, "stop": -1, "critical": -2} self.cfg["thresholds_gb"] = {"warn": 1e9, "stop": -1, "critical": -2}
self._write_config() self._write_config()
@@ -141,6 +177,21 @@ class TestWatchCommands(unittest.TestCase):
self.assertEqual((marked / "cleaned").read_text(encoding="utf-8"), "x\n") self.assertEqual((marked / "cleaned").read_text(encoding="utf-8"), "x\n")
self.assertFalse((foreign / "cleaned").exists()) self.assertFalse((foreign / "cleaned").exists())
def test_full_cleanup_skips_a_worktree_where_a_busy_process_runs(self):
idle, gated = self.base / "idle", self.base / "gated"
for tree in (idle, gated):
(tree / "sub").mkdir(parents=True)
(tree / "Makefile").write_text("", encoding="utf-8")
gate = subprocess.Popen(["sleep", "60"], cwd=gated / "sub")
self.addCleanup(gate.wait)
self.addCleanup(gate.kill)
self.cfg["worktrees_command"] = f"echo {idle}; echo {gated}"
self.cfg["worktree_cleanup"] = ["echo x >> {worktree}/cleaned"]
self._write_config()
self.assertEqual(self._run("cleanup", "--full").returncode, 0)
self.assertTrue((idle / "cleaned").exists())
self.assertFalse((gated / "cleaned").exists())
def test_without_config_every_command_is_a_no_op(self): def test_without_config_every_command_is_a_no_op(self):
self.config.unlink() self.config.unlink()
result = self._run("status") result = self._run("status")