Compare commits
8
Commits
7aeeb6a6c7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8f115613 | ||
|
|
d2f7c19f13 | ||
|
|
b417a1084c | ||
|
|
a4cbab98b1 | ||
|
|
44c88b4bf7 | ||
|
|
9f7844ad6c | ||
|
|
ed0ea62b52 | ||
|
|
b69a7f85d9 |
No files matched your search
@@ -1,5 +1,36 @@
|
||||
# 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
|
||||
|
||||
Added:
|
||||
|
||||
@@ -19,6 +19,7 @@ flowchart LR
|
||||
edit_hook([edit in a git worktree])
|
||||
reminders([reminder hooks])
|
||||
code_task([code task])
|
||||
spawn([subagent spawn])
|
||||
end
|
||||
subgraph commit [Commit]
|
||||
commit_next[commit-next]
|
||||
@@ -34,6 +35,8 @@ flowchart LR
|
||||
coordinate[coordinate]
|
||||
funnel[funnel]
|
||||
cleanup[cleanup]
|
||||
branch_gc[branch-gc]
|
||||
subagent_model[subagent-model]
|
||||
end
|
||||
subgraph ci [CI]
|
||||
observe[observe]
|
||||
@@ -81,6 +84,7 @@ flowchart LR
|
||||
reminders -.-> autotune
|
||||
code_task -.-> no_defaults
|
||||
code_task -.-> scope_guard
|
||||
spawn -.-> subagent_model
|
||||
|
||||
commit_next --> merge_fix
|
||||
commit_next --> commit_no_verify
|
||||
@@ -94,8 +98,12 @@ flowchart LR
|
||||
|
||||
coordinate --> democracy
|
||||
cleanup --> democracy
|
||||
cleanup --> branch_gc
|
||||
branch_gc --> democracy
|
||||
funnel --> democracy
|
||||
funnel --> dialectic
|
||||
funnel --> subagent_model
|
||||
dialectic --> subagent_model
|
||||
democracy --> merge_fix
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+12
-10
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Enable the caveman and ponytail plugins in a Claude Code settings.json so
|
||||
// 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.
|
||||
"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 WATCH_HOOKS = [
|
||||
["UserPromptSubmit", null, "watch"],
|
||||
["PostToolUse", "Bash", "watch"],
|
||||
["PreToolUse", "Bash", "block"],
|
||||
const SCRIPT_HOOKS = [
|
||||
["democracy/scripts/watch.py", "UserPromptSubmit", null, "watch"],
|
||||
["democracy/scripts/watch.py", "PostToolUse", "Bash", "watch"],
|
||||
["democracy/scripts/watch.py", "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] || [];
|
||||
const registered = data.hooks[event].some((entry) =>
|
||||
(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;
|
||||
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);
|
||||
}
|
||||
|
||||
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}`);
|
||||
@@ -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.
|
||||
+12
-5
@@ -58,7 +58,8 @@ Read-only, in parallel, sizes included:
|
||||
label, anonymous ones included.
|
||||
- **Other agent dirs**: package and tool caches (pip, npm, pre-commit, act,
|
||||
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
|
||||
only (`ps -o pid,etime,comm`). Never print full command lines or
|
||||
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:
|
||||
|
||||
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.
|
||||
3. Archive duty: `archive first`.
|
||||
4. Everything else is a candidate with a risk class:
|
||||
4. Archive duty: `archive first`.
|
||||
5. Everything else is a candidate with a risk class:
|
||||
|
||||
| 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
|
||||
explicit renewed selection may pass; if it is refused again, hand over the
|
||||
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.
|
||||
- Archive-first items: copy out, verify the copy, then delete.
|
||||
- A delete failing on permissions (files owned by a container UID): use the
|
||||
|
||||
@@ -76,8 +76,15 @@ records the result.
|
||||
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
|
||||
every request with its position, enforces the limits the operator set,
|
||||
checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`),
|
||||
and never cancels a running grant to free capacity.
|
||||
checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`;
|
||||
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,
|
||||
the queue and the running grants, and keeps allocating until the incoming
|
||||
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
|
||||
plans a cleanup and grants no large slot.
|
||||
- **Stop**: new heavy jobs are blocked in every session (teardown commands
|
||||
never), and the hook runs the configured low-risk cleanup by itself.
|
||||
- **Critical**: additionally a desktop alarm to the operator. A running stack
|
||||
is torn down only with the operator's OK.
|
||||
never), and the hook runs the configured low-risk cleanup by itself; it
|
||||
skips every worktree where a gate or deploy process still runs.
|
||||
- **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
|
||||
projection window raises the stage by one at once.
|
||||
- Caches together stay under their budget; the least recently used go first.
|
||||
|
||||
@@ -25,8 +25,11 @@ projection_minutes, interval_seconds, cache_interval_seconds, heavy_commands,
|
||||
never_block (regexes), cleanup (shell commands), worktree_cleanup (shell
|
||||
commands with {worktree}), worktrees_command (shell command printing the
|
||||
running stacks' worktree paths, one per line), worktree_marker (path relative
|
||||
to a worktree that must exist), cache_dirs, cache_budget_percent,
|
||||
slot_need_gb {kind: GB}, notify (argv the alert text is appended to).
|
||||
to a worktree that must exist), busy_processes (process names; a worktree
|
||||
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
|
||||
@@ -205,9 +208,17 @@ def cmd_watch(cfg: dict) -> int:
|
||||
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:
|
||||
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")
|
||||
if stage not in ("stop", "critical"):
|
||||
return 0
|
||||
@@ -223,13 +234,49 @@ def cmd_block(cfg: dict) -> int:
|
||||
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:
|
||||
need = args.need_gb if args.need_gb is not None else cfg["slot_need_gb"][args.kind]
|
||||
free, _ = disk(cfg)
|
||||
left = free - need
|
||||
floor = cfg["thresholds_gb"]["stop"]
|
||||
verdict = "grant" if left >= floor else "deny"
|
||||
print(f"{verdict}: free {free:.1f} GB, need {need} GB, leaves {left:.1f} GB (floor {floor} GB)")
|
||||
passed = [left >= floor]
|
||||
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
|
||||
|
||||
|
||||
@@ -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"]))]
|
||||
|
||||
|
||||
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]:
|
||||
commands = list(cfg["cleanup"])
|
||||
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"]]
|
||||
results = []
|
||||
for command in commands:
|
||||
|
||||
@@ -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.
|
||||
Cite `file:line` and command output — never a bare assertion.
|
||||
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
|
||||
skeptics diverse lenses: correctness, an alternate mechanism, does-it-
|
||||
reproduce, does-the-fix-break-something-else.
|
||||
|
||||
@@ -20,9 +20,11 @@ in a sidetree until it does, or leaves as a proposal.
|
||||
|
||||
## The stages
|
||||
|
||||
1. **Brief.** One concern per agent. Name the goal, the exact base commit, the
|
||||
scope that may change, the evidence expected back, a hard time cap (default
|
||||
60 minutes) and what the agent must not do: no pushes, no gates or deploys
|
||||
1. **Brief.** One concern per agent, on the model the `subagent-model` skill
|
||||
picks. Name the goal, the exact base commit, the
|
||||
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
|
||||
probes on shared live systems.
|
||||
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
|
||||
|
||||
- Stop an agent at its time cap and ask for its findings; an uncapped brief can
|
||||
run for hours and deliver nothing.
|
||||
- At its time cap, ask an agent for its interim findings and let it run on;
|
||||
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
|
||||
the resource chair grants the capacity; gates and deploys need the machine
|
||||
to themselves.
|
||||
|
||||
@@ -35,7 +35,8 @@ one line so a wrong reading is visible before the search.
|
||||
## 4. Search every ref
|
||||
|
||||
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
|
||||
${CLAUDE_SKILL_DIR}/scripts/find_fix.py --grep '<keyword>' -S '<symbol or literal>' --path '<path or glob>'
|
||||
|
||||
@@ -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.
|
||||
Executable
+205
@@ -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())
|
||||
@@ -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
|
||||
`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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -158,11 +158,11 @@ Python hot path with Rust:
|
||||
## Delegating an optimization
|
||||
|
||||
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
|
||||
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
|
||||
its findings.
|
||||
cap can run for hours without a word; at its cap, collect the agent's interim
|
||||
findings and let it run on.
|
||||
|
||||
Before merging, run independent skeptics through the `dialectic` skill. The
|
||||
antithesis must try to show that the candidate removes coverage, hides a
|
||||
|
||||
@@ -132,6 +132,8 @@ class TestSettingsRegistration(unittest.TestCase):
|
||||
self.assertEqual(entries.count("democracy/scripts/watch.py"), 1)
|
||||
post = json.dumps(data["hooks"]["PostToolUse"])
|
||||
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__":
|
||||
|
||||
@@ -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()
|
||||
+52
-1
@@ -34,6 +34,11 @@ class TestStage(unittest.TestCase):
|
||||
for free, stage in ((50, "ok"), (30, "warn"), (14, "stop"), (5, "critical")):
|
||||
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):
|
||||
now = time.time()
|
||||
self.assertEqual(self.watch.stage_for(32, [[now - 600, 48]], self.cfg, now), "stop")
|
||||
@@ -61,10 +66,12 @@ class TestWatchCommands(unittest.TestCase):
|
||||
"worktree_cleanup": [],
|
||||
"worktrees_command": "true",
|
||||
"worktree_marker": "Makefile",
|
||||
"busy_processes": ["sleep"],
|
||||
"cache_dirs": [str(self.cache)],
|
||||
"cache_budget_percent": 3,
|
||||
"slot_need_gb": {"swarm": 30},
|
||||
"slot_need_gb": {"swarm": 30, "test": 0.001},
|
||||
"notify": ["true"],
|
||||
"load_limits": {"cpu_percent": 101, "mem_percent": 101, "sample_seconds": 0.1, "kinds": ["test"]},
|
||||
}
|
||||
self._write_config()
|
||||
|
||||
@@ -98,12 +105,41 @@ class TestWatchCommands(unittest.TestCase):
|
||||
self._stage("warn")
|
||||
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):
|
||||
self.cfg["thresholds_gb"] = {"warn": 0, "stop": 0, "critical": 0}
|
||||
self._write_config()
|
||||
self.assertEqual(self._run("admit", "--need-gb", "0.001").returncode, 0)
|
||||
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):
|
||||
self.cfg["thresholds_gb"] = {"warn": 1e9, "stop": -1, "critical": -2}
|
||||
self._write_config()
|
||||
@@ -141,6 +177,21 @@ class TestWatchCommands(unittest.TestCase):
|
||||
self.assertEqual((marked / "cleaned").read_text(encoding="utf-8"), "x\n")
|
||||
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):
|
||||
self.config.unlink()
|
||||
result = self._run("status")
|
||||
|
||||
Reference in new issue
Block a user