From d43d545230143de0598554c41392f1baaa7a4758 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 8 Nov 2026 15:46:57 -0500 Subject: [PATCH] bstack: /ready verifies with a real run before review --- .agents/skills/verify-bercail/SKILL.md | 67 +++++++++++++ .agents/skills/verify-bercail/run.sh | 116 +++++++++++++++++++++++ bstack/agents/bstack-council.md | 19 ++-- bstack/agents/bstack-judge.md | 2 +- bstack/agents/bstack.md | 2 +- bstack/commands/council.md | 2 +- bstack/commands/ready.md | 18 ++-- bstack/instructions.md | 12 ++- config/agentic-dev/config.toml.example | 3 + docs/bstack/01-setup.md | 2 +- docs/bstack/02-the-bstack-agent.md | 6 +- docs/bstack/05-review.md | 58 +++++++----- docs/bstack/08-make-it-yours.md | 4 +- docs/cli.md | 2 +- docs/handoff.md | 2 +- lib/bstack.sh | 11 ++- lib/help.sh | 2 +- skills/bstack/SKILL.md | 10 +- skills/council/SKILL.md | 64 ++++++++----- skills/handoff/SKILL.md | 4 +- skills/handoff/scripts/handoff-stages.sh | 18 +++- tests/bstack_test.sh | 73 ++++++++++++++ tests/handoff_spawn_test.sh | 60 ++++++++++++ 23 files changed, 464 insertions(+), 93 deletions(-) create mode 100644 .agents/skills/verify-bercail/SKILL.md create mode 100755 .agents/skills/verify-bercail/run.sh diff --git a/.agents/skills/verify-bercail/SKILL.md b/.agents/skills/verify-bercail/SKILL.md new file mode 100644 index 0000000..4cceaca --- /dev/null +++ b/.agents/skills/verify-bercail/SKILL.md @@ -0,0 +1,67 @@ +--- +name: verify-bercail +description: >- + Drives this checkout's bercail CLI the way a user does, in a throwaway HOME: + deploy, doctor, desk restore --dry-run, handoff-spawn --info, bstack status. + Leaves evidence under .bercail/verify/. Use at /ready, or to prove a change to + the CLI, the deploy, handoff, desk restore, or bstack. +--- + +# verify-bercail + +One command runs the whole proof, in about a minute and a half: + +```bash +.agents/skills/verify-bercail/run.sh +``` + +It exits 0 when every step and check passed, and prints the evidence folder. + +## Launch + +`run.sh` makes a temp directory and points `HOME`, the `XDG_*` dirs and +`INSTALL_SRC` at it, with this checkout as the source. It clears the desk's +`HC_*` and `OPENCODE_CONFIG_DIR` variables, writes a `config.toml` with bstack on +and one pinned model, and runs `bercail reconfigure` to deploy into that HOME. + +`hc` is the one stand-in: a script that answers for one live desk at a bare +shell and logs every call. The real `hc` would start or reach a Herdr core. +`opencode` is the real binary, with no plan connected. + +## Doctor + +`bercail doctor` on that HOME. Exit 1 is expected, because the temp HOME has no +Herdr layout or shell block. Pass means it reached its bstack section. Exit 2 or +higher is a crash. + +## Drive + +| Step | Proves | +|------|--------| +| `bercail desk restore --dry-run` on a seeded record | says `would resume`, sends no `hc send`, leaves the record unchanged | +| `handoff-spawn --info` | prints JSON | +| `bercail bstack status` | says `bstack: on` | +| deployed files | `ready.md` has no council step, `/council` is there, no `bstack-council-*` seats | +| `council = true`, then `bercail reconfigure` | a `bstack-council-claude` seat appears | + +## Evidence + +`.bercail/verify//` (gitignored): + +- `summary.tsv`: step, exit code, command. +- `checks.tsv`: `ok` or `FAIL` for each claim about the state the run left. +- `.out`: each step's output. +- `hc-calls.log`: every call to the stand-in. +- `config.toml`: the config the run ended with. + +Quote `summary.tsv`'s exit codes and the folder path in the `/ready` report. + +## Cleanup + +The script's trap removes the temp directory, on failure too. It starts no +daemons, and evidence stays. Prune old `.bercail/verify/` folders by hand. + +## Not proven here + +A real Herdr core, a real resume typed into a desk, and connected plans. For +those, run `bercail desk restore --dry-run` and `bercail doctor` on the real HOME. diff --git a/.agents/skills/verify-bercail/run.sh b/.agents/skills/verify-bercail/run.sh new file mode 100755 index 0000000..36b48ed --- /dev/null +++ b/.agents/skills/verify-bercail/run.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Drive this checkout's bercail in a throwaway HOME. See SKILL.md. +# usage: run.sh [evidence-dir] default: /.bercail/verify/ +set -uo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +evidence="${1:-$repo/.bercail/verify/$(date +%Y%m%d-%H%M%S)}" +mkdir -p "$evidence" +evidence="$(cd "$evidence" && pwd)" +real_opencode="$(mise which opencode 2>/dev/null || command -v opencode || true)" + +# --- launch: nothing below can reach the real HOME or a real Herdr core --- +box="$(mktemp -d)" +trap 'rm -rf "$box"' EXIT +unset BASH_ENV HC_TERMINAL_ID HC_SESSION HC_SCOPE HC_TERMINAL_SOCKET OPENCODE_CONFIG_DIR +export __MISE_BASH_ENV_LOADED=1 +export HOME="$box/home" XDG_CONFIG_HOME="$box/home/.config" XDG_DATA_HOME="$box/home/.local/share" XDG_CACHE_HOME="$box/home/.cache" +export XDG_STATE_HOME="$box/state" XDG_RUNTIME_DIR="$box/run" INSTALL_SRC="$repo" +export HC_BIN="$box/bin/hc" HC_CALLS="$evidence/hc-calls.log" +export PATH="$box/bin:$PATH" +mkdir -p "$HOME" "$XDG_RUNTIME_DIR" "$box/bin" "$XDG_STATE_HOME/bercail/desk-sessions" "$XDG_CONFIG_HOME/bercail" +: >"$HC_CALLS" +[[ -z "$real_opencode" ]] || ln -s "$real_opencode" "$box/bin/opencode" + +# The one stand-in: hc, one live desk at a bare shell. Every call is logged. +cat >"$box/bin/hc" <<'HC' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$HC_CALLS" +case "$1" in + ls) printf '{"terminals":[{"address":"t1","state":"live"}]}\n' ;; + get) printf '%s\n' '{"state":"live","reason":null,"reported":false,"pid":7,"argv":["/usr/bin/bash"],"foreground":{"name":"bash","pid":7},"labels":{"bercail.role":"agent","bercail.space":"sp1"},"records":[]}' ;; + send) exit 0 ;; + *) exit 1 ;; +esac +HC +chmod +x "$box/bin/hc" + +cat >"$XDG_CONFIG_HOME/bercail/config.toml" <<'TOML' +[agent] +command = "opencode" + +[bstack] +enabled = true +claude = "claude-agent/opus#high" +TOML + +bercail="$repo/bin/bercail" +failed=0 +# step NAME MAX_EXIT CMD...: output to NAME.out, exit code to the summary. +step() { + local name="$1" max="$2" rc=0 + shift 2 + "$@" >"$evidence/$name.out" 2>&1 >"$evidence/summary.tsv" + if ((rc > max)); then + printf 'FAIL %s: exit %s (see %s/%s.out)\n' "$name" "$rc" "$evidence" "$name" >&2 + failed=1 + fi +} +# check NAME COMMAND...: a claim about the state the run left. +check() { + local name="$1" + shift + if "$@" >/dev/null; then + printf 'ok\t%s\n' "$name" >>"$evidence/checks.tsv" + else + printf 'FAIL\t%s\n' "$name" >>"$evidence/checks.tsv" + printf 'FAIL %s\n' "$name" >&2 + failed=1 + fi +} +: >"$evidence/summary.tsv" +: >"$evidence/checks.tsv" + +oc="$XDG_CONFIG_HOME/opencode" +no_seats() { ! compgen -G "$oc/agents/bstack-council-*.md" >/dev/null; } +out_has() { grep -qF -- "$2" "$evidence/$1.out"; } +absent() { [[ -f "$1" ]] && ! grep -qE -- "$2" "$1"; } + +# --- doctor: this HOME has no desk stack, so 1 is expected; a crash is not --- +step reconfigure 0 "$bercail" reconfigure +step doctor 1 "$bercail" doctor +check "doctor reached its bstack section" out_has doctor "bstack subscription" + +# --- drive --- +record="$XDG_STATE_HOME/bercail/desk-sessions/t1.json" +# Built at run time: a session id on a command line would read as already open. +jq -n --arg s "ses_verify_$(date +%s%N)" --arg c "$repo" \ + '{address: "t1", session: $s, cwd: $c, harness: "opencode", issue: "verify-1", space: "sp1", args: []}' >"$record" +before="$(sha256sum "$record")" +step desk-restore-dry-run 0 "$bercail" desk restore --dry-run +check "dry run would resume the desk" out_has desk-restore-dry-run "would resume" +check "dry run typed nothing" absent "$HC_CALLS" '^send' +check "dry run left the record alone" test "$before" = "$(sha256sum "$record")" + +step handoff-info 0 "$repo/skills/handoff/scripts/handoff-spawn" --info +check "handoff --info is JSON" jq -e 'type == "object"' "$evidence/handoff-info.out" + +step bstack-status 0 "$bercail" bstack status +check "bstack status is on" out_has bstack-status "bstack: on" +check "ready.md deployed with no council step" absent "$oc/commands/ready.md" '[Cc]ouncil' +check "council command still deployed" test -f "$oc/commands/council.md" +check "no council seats by default" no_seats + +printf 'council = true\n' >>"$XDG_CONFIG_HOME/bercail/config.toml" +step reconfigure-council 0 "$bercail" reconfigure +check "council = true renders a seat" test -f "$oc/agents/bstack-council-claude.md" + +cp "$XDG_CONFIG_HOME/bercail/config.toml" "$evidence/config.toml" + +# --- result --- +if ((failed)); then + printf 'verify-bercail FAILED; evidence: %s\n' "$evidence" + exit 1 +fi +printf 'verify-bercail passed; evidence: %s\n' "$evidence" diff --git a/bstack/agents/bstack-council.md b/bstack/agents/bstack-council.md index cd9fb66..fb4fac0 100644 --- a/bstack/agents/bstack-council.md +++ b/bstack/agents/bstack-council.md @@ -1,6 +1,6 @@ --- # managed by bercail (bstack). `bercail update` rewrites this file; delete this line to keep your own. -description: Read-only council reviewer on @family@. Tries to break a change and reports only findings it can show. Launch through the council skill, one per provider, in parallel. +description: Read-only council reviewer on @family@. Tries to break a change and reports only findings it proves with a run. Launch through the council skill, one per provider, in parallel, only when /council is asked for. mode: subagent model: @model@ color: "#f5a524" @@ -21,13 +21,14 @@ permissions: You are one reviewer on a council of models from different providers. Your job is to find what is wrong with the change in your brief, not to praise it. -- Read the diff and the code around it. Run the tests or a small repro when a - finding depends on behavior. Do not edit files; scratch files go in /tmp. +- Read the diff and the code around it. Do not edit files; scratch files go in /tmp. - Look for: wrong behavior, missed cases, broken callers, races, security holes, - data loss, and tests that pass for the wrong reason. Skip style unless it hides - a bug. + data loss, and tests that pass for the wrong reason. +- Prove each finding with a run: a failing test, a repro, or command output. The + lead reruns your command, and a finding that does not reproduce is dropped. +- Leave out anything you could not show with a run, and all style, naming, and + YAGNI comments. - Report each finding as: severity (blocker, major, minor), `file:line`, what - breaks, the input or steps that show it, and how you checked (ran it, or read - it). Mark a finding you could not check as `unverified`. -- If you find nothing that breaks, say `no findings` and list what you checked. - Never invent a finding to have one. + breaks, the exact command, and what it printed. +- If nothing is proven, say `no findings` and list what you ran. Never invent a + finding to have one. diff --git a/bstack/agents/bstack-judge.md b/bstack/agents/bstack-judge.md index d1eb5c8..da5e5ce 100644 --- a/bstack/agents/bstack-judge.md +++ b/bstack/agents/bstack-judge.md @@ -1,6 +1,6 @@ --- # managed by bercail (bstack). `bercail update` rewrites this file; delete this line to keep your own. -description: The strongest reasoner. Use for the hardest changes, design decisions, plans, prose, and for settling a disagreement between reviewers. +description: The strongest reasoner. Use for the hardest changes, design decisions, plans, prose, and the intent check at /ready: does the diff match the issue. mode: subagent model: @judge@ color: "#a78bfa" diff --git a/bstack/agents/bstack.md b/bstack/agents/bstack.md index 545bb0c..a299099 100644 --- a/bstack/agents/bstack.md +++ b/bstack/agents/bstack.md @@ -1,6 +1,6 @@ --- # managed by bercail (bstack). `bercail update` rewrites this file; delete this line to keep your own. -description: bercail's lead agent. Plans, routes work to the builder, judge, and council by model strength, and proves the result. +description: bercail's lead agent. Plans, routes work to the builder and judge by model strength, and proves the result. mode: primary model: @lead@ color: "#d97757" diff --git a/bstack/commands/council.md b/bstack/commands/council.md index ba25a6c..3bb0933 100644 --- a/bstack/commands/council.md +++ b/bstack/commands/council.md @@ -1,6 +1,6 @@ --- # managed by bercail (bstack). `bercail update` rewrites this file; delete this line to keep your own. -description: Review the current change with one reviewer per provider (Claude, GPT, Grok) and keep only findings that hold up +description: Adversarial review on request. Reviewers must prove each finding with a run, and only findings a rerun confirms are reported --- Load the `council` skill and follow it. Review target: $ARGUMENTS diff --git a/bstack/commands/ready.md b/bstack/commands/ready.md index c6d9ca8..f8f0036 100644 --- a/bstack/commands/ready.md +++ b/bstack/commands/ready.md @@ -1,14 +1,20 @@ --- # managed by bercail (bstack). `bercail update` rewrites this file; delete this line to keep your own. -description: Finish the current work for review - prove it, run the council, stage it, and report +description: Finish the current work for review - prove it with tests, the repo's verify skill and formal checks, stage it, and report --- Get the current work ready for my review in tuicr. $ARGUMENTS !`git status --short 2>&1 | head -50` +Verify skill: !`ls "$(git rev-parse --show-toplevel 2>/dev/null)"/.agents/skills/verify-*/SKILL.md 2>/dev/null || echo none` + +Fix what fails at each step, then rerun that step. Note every command, its exit code, and where its evidence is. + 1. Re-read the job (the beads issue when there is one) and check each part of it is done. -2. Prove it on the real thing: run the tests and the command or app path the change touches. Fix what fails. -3. Run the `council` skill on the change. Fix the findings that hold up. -4. Run the `unslop` skill on the diff: debug output, dead code, narrating comments, unrelated edits. -5. Stage everything with `git add`. Do not commit or push. -6. Report in five lines or fewer: what changed, what you ran, what the council found, what is left. +2. Run the tests that cover the diff. +3. When the verify skill above is not `none`, read it and follow it from launch to cleanup. +4. Run any formal check whose coverage the diff touches: a `statechart` spec in `.bercail/statechart/` or committed in the repo. When none covers it, say so. +5. When a beads issue names the job, ask `bstack-judge` one question, giving it the issue and the diff: does the diff match the issue? It names what is missing or was never asked for, and it changes nothing. +6. Run the `unslop` skill on the diff: debug output, dead code, narrating comments, unrelated edits. +7. Stage everything with `git add`. Do not commit or push. +8. Report: what changed, each check with its exit code and evidence path, the judge's answer, what is left. In a headless handoff, also put the checks in the result file's `verification` array. diff --git a/bstack/instructions.md b/bstack/instructions.md index 963423d..318c10e 100644 --- a/bstack/instructions.md +++ b/bstack/instructions.md @@ -13,8 +13,8 @@ that is strongest at it. These rules apply to every bstack agent and subagent. - Load the `bstack` skill for anything beyond a one-line change. It holds the playbooks (bug fix, feature, refactor, investigation, review, ship, handoff). - Other bercail skills: `how` (how code works now), `why` (why it is that way, - from the record), `unslop` (clean prose and diffs), `council` (cross-provider - review), `autopilot` (drive a PR to green). + from the record), `unslop` (clean prose and diffs), `council` (adversarial + review, only when asked for), `autopilot` (drive a PR to green). ## How to work @@ -36,8 +36,8 @@ that is strongest at it. These rules apply to every bstack agent and subagent. | Role | Agent | Use it for | |------|-------|------------| | build | `bstack-builder` | Code: features, refactors, bug fixes, perf. Hand it a precise brief. | -| judge | `bstack-judge` | The hardest design calls, prose, plans, and settling disagreements. | -| council | `bstack-council-*` | Read-only review, one per connected provider. Run them through the `council` skill. | +| judge | `bstack-judge` | The hardest design calls, prose, plans, and the intent check at `/ready`. | +| council | `bstack-council-*` | Read-only adversarial review, when `[bstack] council = true`. Run them through the `council` skill, only when `/council` is asked for. | - Briefs are self-contained: the goal, the files, the constraint, what done looks like, and how to prove it. A subagent has none of your context. @@ -56,4 +56,6 @@ that is strongest at it. These rules apply to every bstack agent and subagent. No inventory of every file. When a beads issue names the job, also post that report on it (`bd comment `), so it outlives the desk's screen. - In a headless handoff, write the result file the issue comment names - (`status` ready|blocked|failed, `summary`). `blocked` with a reason beats a guess. + (`status` ready|blocked|failed, `summary`, and optionally `verification`: a list + of `{kind: test|verify-skill|statechart, command, exit_code, evidence}`). + `blocked` with a reason beats a guess. diff --git a/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index 86fd8c8..48e607a 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -28,6 +28,9 @@ effort = "high" lead = "claude" build = "grok" judge = "claude" +# Render one bstack-council- reviewer per plan for /council. /council still +# works when off: the lead reviews alone. +council = false # [graphite] # Handoff detects Graphite by the branches gt tracks. Override it per repo, keyed diff --git a/docs/bstack/01-setup.md b/docs/bstack/01-setup.md index 71fcaf3..a141b53 100644 --- a/docs/bstack/01-setup.md +++ b/docs/bstack/01-setup.md @@ -67,6 +67,6 @@ Each subagent and each council seat spends tokens from a plan. To spend less: - Save the playbooks for work that needs them. A one-line fix doesn't. - Point `build` at your cheapest plan in `[bstack]` (see [Make it yours](./08-make-it-yours.md)). -- Run `/council` once per change, at the end, not after every step. +- `/ready` does not run the council. Ask for `/council` only when a change earns it. Next: [Work with the bstack agent](./02-the-bstack-agent.md). diff --git a/docs/bstack/02-the-bstack-agent.md b/docs/bstack/02-the-bstack-agent.md index 38aab64..b5ddd4e 100644 --- a/docs/bstack/02-the-bstack-agent.md +++ b/docs/bstack/02-the-bstack-agent.md @@ -12,7 +12,7 @@ the role that fits. | X is broken | bug fix | a repro before a fix, the root cause | | add or change behavior | feature | the data shape first, the real path proven | | same behavior, better shape | refactor | behavior pinned before anything moves | -| review this | review | the council, findings checked | +| review this | review | tests and the verify skill; `/council` when you ask | | the PR is open | ship | autopilot until green | | several jobs at once | handoff | one beads issue per job | | finish this branch | takeover | read the trail before touching it | @@ -28,8 +28,8 @@ Read it once. It is short. |------|-------|------| | lead | `bstack` | your ask; plans, routes, checks, reports | | build | `bstack-builder` | code from a precise brief | -| judge | `bstack-judge` | hard design calls, prose, and disagreements | -| council | `bstack-council-` | read-only review, one per plan with a model (connected, or pinned) | +| judge | `bstack-judge` | hard design calls, prose, and the intent check at `/ready` | +| council | `bstack-council-` | read-only adversarial review, one per plan with a model, when `[bstack] council = true` | | autopilot | `bstack-autopilot` | an open PR to get green | By default the lead and judge run on Claude and the builder on Grok. A role diff --git a/docs/bstack/05-review.md b/docs/bstack/05-review.md index be68c06..b3d6ff9 100644 --- a/docs/bstack/05-review.md +++ b/docs/bstack/05-review.md @@ -1,41 +1,48 @@ # Review it -Different providers miss different bugs. bstack reviews with all of the plans -you have, then checks every finding before it counts. +bstack proves a change by running it. `/ready` runs tests, the repo's verify +skill and formal checks. Adversarial review is there when you ask for it. -## `/council` +## `/ready` ```text -/council +/ready ``` -With no target, it reviews the uncommitted change. It also takes a branch -(`/council the branch against master`) or a PR (`/council pr 42`). - -Every `bstack-council-` seat gets the same brief, in parallel, without -seeing what the others said. The lead then reads the code at each `file:line`, -runs the repro when the finding is about behavior, and keeps only what holds up. -Two providers finding the same bug raises confidence, not severity. When seats -disagree, `bstack-judge` settles it. When the job is getting the change ready -(as in `/ready`), it fixes what it kept and runs again until no blocker or major -finding is left, three rounds at most. +The finish line for any build playbook: -With one plan connected, the council is one seat, and the report says so. +1. Re-read the job and check each part is done. +2. Run the tests that cover the diff. +3. Follow the repo's `.agents/skills/verify-` skill, when it has one. +4. Run any formal check whose coverage the diff touches. +5. Ask `bstack-judge` one question: does the diff match the issue? +6. Run `/unslop` on the diff. +7. Stage everything. No commit, no push. +8. Report what changed, each check with its exit code and evidence path, the + judge's answer, and what is left. + +A headless run also records the checks in the result file's `verification` +array ([handoff](../handoff.md)). -## `/ready` +## `/council` ```text -/ready +/council ``` -The finish line for any build playbook: +With no target, it reviews the uncommitted change. It also takes a branch +(`/council the branch against master`) or a PR (`/council pr 42`). -1. Re-read the job and check each part is done. -2. Run the tests and the real path the change touches. -3. Run the council and fix what holds up. -4. Run `/unslop` on the diff. -5. Stage everything. No commit, no push. -6. Report in five lines: what changed, what ran, what the council found, what is left. +Every `bstack-council-` seat gets the same brief, in parallel, without +seeing what the others said. Each seat must prove a finding with a run: a +failing test, a repro, or command output. The lead reruns it and keeps only what +reproduces. Style, naming, YAGNI and anything unproven are dropped, not +reported. When you ask for fixes, it fixes what it kept and runs again until no +blocker or major finding is left, three rounds at most. + +The seats exist only with `council = true` in `[bstack]`, after `bercail +update`. With it off (the default), `/council` still works: the lead reviews +alone by the same rules and says the panel did not run. ## tuicr: your review @@ -47,7 +54,6 @@ You commit and open the PR. That is the line bstack does not cross without being asked. After it, autopilot takes over. **Pitfall:** accepting every finding. Reviewers, human or model, mix real -catches with noise. The council drops what is wrong or already handled, and -marks what it couldn't settle `unverified`. +catches with noise. The council reports only what a rerun confirms. Next: [Ship with autopilot](./06-autopilot.md). diff --git a/docs/bstack/08-make-it-yours.md b/docs/bstack/08-make-it-yours.md index df49981..0651ce3 100644 --- a/docs/bstack/08-make-it-yours.md +++ b/docs/bstack/08-make-it-yours.md @@ -23,8 +23,8 @@ judge = "claude" Then `bercail bstack setup` or `bercail update`. `bercail bstack status` shows each model as `auto` or `pinned`. A pin stays until you clear it, gets a council -seat even when its plan isn't connected, and skips every new release; auto -picks again on every update. +seat (with `council = true`) even when its plan isn't connected, and skips +every new release; auto picks again on every update. Roles fall back to the next connected plan, so you can set them once and connect plans later ([the order](./02-the-bstack-agent.md#the-roles)). Higher diff --git a/docs/cli.md b/docs/cli.md index 9a174ae..7bf56b5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -45,7 +45,7 @@ Call by name. Skills install to `~/.agents/skills`; bercail adds a link where th | `handoff` | Run a beads issue on a stage, headless or as a `--branch` desk (`--base` stacks it on a parent branch). See [handoff](handoff.md) | | `review` | Wait for **human** tuicr notes; publish to GitHub only if asked. See [review](review.md) | | `bstack` | Playbooks for rigorous work, routed by role. See [bstack](bstack.md) | -| `council` | One reviewer per connected provider; keep only findings that hold up | +| `council` | Adversarial review on request; only findings a rerun confirms are reported | | `how` / `why` | How code works now; why it is that way, from git, PRs, and beads. Read-only | | `unslop` | Clean machine-writing tells out of prose and a diff | | `autopilot` | Drive an open PR to green: CI, review threads, conflicts. Pushes; never merges | diff --git a/docs/handoff.md b/docs/handoff.md index 53369f2..14ab91c 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -30,7 +30,7 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) **Context.** Before it starts the agent, bercail adds a `bd comment` to the issue with the worktree and, headless, the result file and session id. A worktree's `.beads/redirect` points `bd` at the main checkout's database, so every comment lands in one store. -**Result.** A headless run leaves its CLI log in `/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`. A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`). +**Result.** A headless run leaves its CLI log in `/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`, plus an optional `verification` array of `{kind: test|verify-skill|statechart, command, exit_code, evidence}` from `/ready` (a malformed array is dropped; the rest of the result stands). A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`). **Blocked, not switched.** Headless, if the stage's binary, `bd`, or the issue is missing, the result is `blocked` and nothing starts. With `--branch`, `handoff-spawn` exits 1 before it makes a worktree. Bercail never falls through to another harness. `--binary claude|codex|cursor-agent|opencode` runs a headless stage on another CLI only when you ask (with `opencode`, `start` stays on Claude and `codex` on GPT, through your opencode plans; stage `autopilot` takes only `opencode`). diff --git a/lib/bstack.sh b/lib/bstack.sh index ebeafb3..c12c609 100644 --- a/lib/bstack.sh +++ b/lib/bstack.sh @@ -72,6 +72,10 @@ bstack_enabled() { [[ "$(_bstack_toml_get bstack enabled)" == true ]] } +bstack_council_enabled() { + [[ "$(_bstack_toml_get bstack council)" == true ]] +} + _bstack_role_default() { case "$1" in lead|judge) printf 'claude' ;; @@ -323,9 +327,12 @@ deploy_bstack() { _bstack_render "$work/src/$rel" "$work/$(basename "$rel")" _bstack_place "$work/$(basename "$rel")" "$OPENCODE_CONFIG_DIR/$rel" || true done - # One read-only reviewer per family with a model: the council's panel. + # With [bstack] council = true, one read-only reviewer per family with a model. for family in "${BSTACK_FAMILIES[@]}"; do - model="$(bstack_resolve_model "$family")" + model="" + if bstack_council_enabled; then + model="$(bstack_resolve_model "$family")" + fi if [[ -n "$model" ]]; then _bstack_render "$work/src/agents/bstack-council.md" "$work/council-$family.md" "$family" "$model" _bstack_place "$work/council-$family.md" "$OPENCODE_CONFIG_DIR/agents/bstack-council-$family.md" || true diff --git a/lib/help.sh b/lib/help.sh index fedd231..53a2628 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -80,7 +80,7 @@ bstack (opencode, bring your own Claude / GPT / Grok subscription): bercail bstack setup install opencode, connect plans (models are picked for you) bercail bstack status what is connected, each model (auto or pinned), each role Roles: lead (the bstack agent), build (bstack-builder), judge (bstack-judge); - council: one read-only reviewer per connected provider. In opencode: /council, + council ([bstack] council = true): one read-only reviewer per provider. In opencode: /council, /ready, /handoff, /bstack-setup, /how, /why, /unslop, /autopilot (a PR to green; pushes, never merges), /pr-review (bercail review-pr's read-only decisions pass). Settings: [bstack] in config.toml. diff --git a/skills/bstack/SKILL.md b/skills/bstack/SKILL.md index 232aee7..329a8a0 100644 --- a/skills/bstack/SKILL.md +++ b/skills/bstack/SKILL.md @@ -3,8 +3,8 @@ name: bstack description: >- bercail's playbooks for rigorous work on opencode with your own Claude, GPT, and Grok subscriptions: investigation, bug fix, feature, refactor, review, - ship, handoff, and takeover. Routes code to the builder, hard calls to the judge, - and review to the council. Use at the start of any non-trivial task, or when + ship, handoff, and takeover. Routes code to the builder and hard calls to the + judge; the council runs only on request. Use at the start of any non-trivial task, or when asked which bstack playbook or role fits. --- @@ -73,8 +73,10 @@ each step ending in something you can check. ## review 1. Get the target: the uncommitted diff, `git diff ...HEAD`, or `gh pr diff `. -2. Run the `council` skill on it. -3. Report only findings that hold up, worst first, with `file:line`. +2. Prove it: run the tests that cover the diff, then the repo's `verify-` + skill when `.agents/skills/` has one. Run the `council` skill only when the + person asks for `/council`. +3. Report only findings a run shows, worst first, with `file:line` and the command. 4. Post to GitHub only when asked (the `review` skill's `publish-github.sh`). ## ship diff --git a/skills/council/SKILL.md b/skills/council/SKILL.md index b5ad0e2..49e51e3 100644 --- a/skills/council/SKILL.md +++ b/skills/council/SKILL.md @@ -1,16 +1,16 @@ --- name: council description: >- - Reviews a change with one read-only reviewer per connected provider (Claude, - GPT, Grok) in parallel, checks every finding, and keeps only the ones that - hold up. Loops until no blocker is left. Use to review a diff, branch, or PR, - before calling work ready, or when asked for "council" or a second opinion. + Adversarial review on request: one read-only reviewer per provider tries to + break a change, each finding must come with a run, and only findings a rerun + confirms are reported. Use only when someone asks for /council or "council"; + /ready and ordinary review do not run it. --- # council -Different providers miss different bugs. The council puts each connected one on -the same change, then you check each finding before you believe it. +Different providers miss different bugs. The council puts each one on the same +change. A finding counts only when a run shows it and your rerun shows it again. ## 1. The target @@ -22,34 +22,46 @@ the same change, then you check each finding before you believe it. ## 2. Convene -Launch every `bstack-council-*` subagent that exists (one per connected provider), -in parallel, with the same brief. Do not tell them what the others said. +Launch every `bstack-council-*` subagent that exists (one per provider), in +parallel, with the same brief and the reviewer rules below. Do not tell them +what the others said. -With only one provider connected, the council is one reviewer: say so in the report. -Outside opencode there are no council agents: review it yourself, with the -reviewer rules below, and say the council was skipped. +With one seat, say so in the report. With none (`[bstack] council` is off, or +you are outside opencode), review it yourself by the same rules and say the +panel did not run. `[bstack] council = true` and `bercail update` turn it on. -## 3. Check every finding +## Reviewer rules -A finding is a claim until you check it. +- Try to break the change: wrong behavior, missed cases, broken callers, races, + security holes, data loss, tests that pass for the wrong reason. +- Prove each finding with a run: a failing test, a repro, or command output. + Give the exact command and what it printed. Scratch files go in /tmp. +- A finding you cannot show with a run is not a finding. Leave it out. +- Leave out style, naming, and YAGNI. +- Report each one as: severity (blocker, major, minor), `file:line`, what breaks, + the command, its output. With nothing proven, say `no findings` and list what + you ran. -- Read the code at the `file:line` it names. Run the repro or the test when it - depends on behavior. -- Keep: shown by a run, or plain from the code. Drop: wrong, already handled, or - style only. Mark `unverified` what you could not settle either way. +## 3. Rerun every finding + +A finding is a claim until you rerun it. Run its command yourself, in this +checkout, and compare the output. + +- Keep what the rerun confirms. +- Drop everything else, and do not report it: a finding that does not reproduce, + has no run, or is style, naming, or YAGNI. There is no `unverified` list. - Merge duplicates. Two providers finding the same bug raises confidence, not severity. -- When two reviewers disagree on a finding that matters, ask `bstack-judge` to - settle it with the evidence from both. +- Reviewers disagreeing is settled by the rerun, not by another opinion. ## 4. Fix and loop -- When the task is to get the change ready, fix the findings you kept, then run - the council again on the new diff. -- Stop when a round has no kept blocker or major finding, or after three rounds. - Report what is still open after the third. +- When the ask includes fixing, fix the findings you kept, then run the council + again on the new diff. +- Stop when a round has no confirmed blocker or major finding, or after three + rounds. Report what is still open after the third. ## 5. Report -Worst first, one line each: severity, `file:line`, what breaks, how it was shown, -and which providers found it. Then one line: the providers on the council and -the number of rounds. Change nothing outside the fixes you were asked to make. +Worst first, one line each: severity, `file:line`, what breaks, the command you +reran, and which providers found it. Then one line: the providers on the council +and the number of rounds. Change nothing outside the fixes you were asked to make. diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index f36b04c..b23096e 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -80,7 +80,9 @@ If the requested harness, bd, or the issue is missing, the stage writes `/.bercail/handoff-result.json` with status `blocked`, starts nothing, and exits nonzero. It does not fall through to another harness. The agent writes the same result file (`status`, `summary`, and bercail fills -`session_id`). When the run stops on a plan usage limit, bercail adds `reason`: +`session_id`), with an optional `verification` array of +`{kind: test|verify-skill|statechart, command, exit_code, evidence}`; bercail +drops a malformed array and keeps the rest of the result. When the run stops on a plan usage limit, bercail adds `reason`: `usage-limit`. It does not rerun or switch plans. Read that file. Do not scrape terminal output. For Codex, `session_id` is the JSONL `thread.started` thread id. For cursor-agent, it is diff --git a/skills/handoff/scripts/handoff-stages.sh b/skills/handoff/scripts/handoff-stages.sh index 96ad3d5..bd3a1de 100644 --- a/skills/handoff/scripts/handoff-stages.sh +++ b/skills/handoff/scripts/handoff-stages.sh @@ -144,11 +144,24 @@ handoff_write_result() { + if $reason == "" then {} else {reason:$reason} end' >"$dest" } +# verification is optional: [{kind, command, exit_code, evidence}]. A statechart +# entry may carry spec and chart in place of command and evidence (bercail-86a). +HANDOFF_VERIFICATION_DEF=' + def verification_ok: + .verification | . == null or (type == "array" and all(.[]; + type == "object" + and (.kind == "test" or .kind == "verify-skill" or .kind == "statechart") + and (.exit_code | type == "number") + and (((.command | type == "string") and (.evidence | type == "string")) + or (.kind == "statechart" and (.spec | type == "string") and (.chart | type == "string"))))); +' + handoff_result_ok() { - jq -e ' + jq -e "$HANDOFF_VERIFICATION_DEF"' (.status == "ready" or .status == "blocked" or .status == "failed") and (.summary | type == "string" and length > 0) and (.session_id | type == "string" and length > 0) + and verification_ok ' "$1" >/dev/null } @@ -672,7 +685,7 @@ handoff_run_headless() { # made-up id would look resumable. Say so in the summary instead. if [[ -f "$result" ]] && jq -e 'type == "object"' "$result" >/dev/null 2>&1; then local filled - filled="$(jq --arg session_id "$session" --arg none "$HANDOFF_NO_SESSION" --arg reason "$reason" ' + filled="$(jq --arg session_id "$session" --arg none "$HANDOFF_NO_SESSION" --arg reason "$reason" "$HANDOFF_VERIFICATION_DEF"' (.session_id | type == "string" and test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) as $own | .status = (if (.status == "ready" or .status == "blocked" or .status == "failed") then .status else "failed" end) | .summary = (if (.summary | type == "string" and length > 0) then .summary else "headless stage finished without a usable summary" end) @@ -681,6 +694,7 @@ handoff_run_headless() { else .session_id = $none | .summary += " (bercail found no session id in the CLI log, so this run cannot be resumed.)" end | if $reason != "" then .reason = $reason else del(.reason) end + | if verification_ok then . else del(.verification) | .summary += " (bercail dropped a malformed verification list.)" end ' "$result")" printf '%s\n' "$filled" >"$result" fi diff --git a/tests/bstack_test.sh b/tests/bstack_test.sh index aa22108..886d306 100644 --- a/tests/bstack_test.sh +++ b/tests/bstack_test.sh @@ -148,6 +148,7 @@ grok = "" lead = "claude" build = "grok" judge = "gpt" +council = true EOF deploy_bstack >/dev/null 2>&1 || fail "deploy_bstack failed" local oc="$OPENCODE_CONFIG_DIR" rel @@ -196,6 +197,7 @@ test_off_removes_only_ours() { [bstack] enabled = true claude = "claude-agent/opus" +council = true EOF deploy_bstack >/dev/null 2>&1 printf '{}\n' >"$OPENCODE_CONFIG_DIR/opencode.json" @@ -213,6 +215,70 @@ EOF printf 'PASS: bstack off removes only what bstack manages\n' } +test_council_seats_need_the_setting() { + reset_home + local oc="$OPENCODE_CONFIG_DIR" config='[bstack] +enabled = true +claude = "claude-agent/opus#high" +gpt = "openai/gpt-6.1-sol"' + write_config <<<"$config" + deploy_bstack >/dev/null 2>&1 || fail "deploy without council" + [[ ! -e "$oc/agents/bstack-council-claude.md" && ! -e "$oc/agents/bstack-council-gpt.md" ]] \ + || fail "seats rendered with council unset" + [[ -f "$oc/commands/council.md" ]] || fail "/council must stay callable with the seats off" + + write_config <<<"$config"$'\ncouncil = true' + deploy_bstack >/dev/null 2>&1 || fail "deploy with council" + [[ -f "$oc/agents/bstack-council-claude.md" && -f "$oc/agents/bstack-council-gpt.md" ]] || fail "council = true renders seats" + + # Turning it off takes back bstack's seats and leaves one the person owns. + printf 'my own reviewer\n' >"$oc/agents/bstack-council-gpt.md" + write_config <<<"$config"$'\ncouncil = false' + deploy_bstack >/dev/null 2>&1 || fail "deploy with council = false" + [[ ! -e "$oc/agents/bstack-council-claude.md" ]] || fail "council = false removes bstack's seat" + grep -qx 'my own reviewer' "$oc/agents/bstack-council-gpt.md" || fail "their seat changed" + printf 'PASS: council seats render only with [bstack] council = true; /council stays\n' +} + +test_ready_verifies_without_the_council() { + local ready="$ROOT/bstack/commands/ready.md" prev=0 n step + if grep -qi council "$ready"; then fail "ready.md must not run the council"; fi + for step in 'tests that cover the diff' 'verify skill above' 'Run any formal check' 'bstack-judge' '`unslop`' 'git add' 'exit code and evidence path'; do + n="$(grep -nF -- "$step" "$ready" | head -1 | cut -d: -f1)" + ((${n:-0} > prev)) || fail "ready.md: '$step' is missing or out of order" + prev="$n" + done + grep -qF 'does the diff match the issue?' "$ready" || fail "the judge gets the intent question" + if grep -q 'settl' "$ROOT/bstack/agents/bstack-judge.md"; then fail "the judge no longer settles disputes"; fi + if grep -q 'bstack-judge' "$ROOT/skills/council/SKILL.md"; then fail "the council must not call the judge"; fi + grep -q 'verify-' "$ROOT/skills/bstack/SKILL.md" || fail "the review playbook proves with the verify skill" + printf 'PASS: /ready runs tests, verify, formal checks, the judge, unslop, stage, report; no council\n' +} + +test_council_keeps_only_rerun_findings() { + local skill="$ROOT/skills/council/SKILL.md" agent="$ROOT/bstack/agents/bstack-council.md" + grep -qF 'Prove each finding with a run' "$skill" || fail "skill: a finding needs a run" + grep -qF 'Prove each finding with a run' "$agent" || fail "agent: a finding needs a run" + grep -qF 'Run its command yourself' "$skill" || fail "skill: the lead reruns" + grep -q 'style, naming, or YAGNI' "$skill" || fail "skill: style findings are dropped" + if grep -qF '`unverified`' "$agent"; then fail "agent: nothing is marked unverified"; fi + grep -qF 'There is no `unverified` list' "$skill" || fail "skill: unverified findings are dropped" + grep -qF '[bstack] council' "$skill" || fail "skill: says what to do with no seats" + printf 'PASS: the council reports only findings a run proved and a rerun confirmed\n' +} + +test_verify_skill_shape() { + local dir="$ROOT/.agents/skills/verify-bercail" run step + head -2 "$dir/SKILL.md" | grep -qx 'name: verify-bercail' || fail "verify skill frontmatter" + run="$dir/run.sh" + [[ -x "$run" ]] || fail "run.sh is executable" + bash -n "$run" || fail "run.sh parses" + for step in 'export HOME=' 'bercail" doctor' 'desk restore --dry-run' 'handoff-spawn" --info' 'bstack status' '.bercail/verify' "trap 'rm -rf"; do + grep -qF -- "$step" "$run" || fail "run.sh is missing: $step" + done + printf 'PASS: verify-bercail covers temp HOME, doctor, restore, info, status, evidence, cleanup\n' +} + test_on_switches_stock_desk_agent() { reset_home write_config <<'EOF' @@ -236,6 +302,9 @@ test_setup_auto_models() { write_config <<'EOF' [agent] command = "cursor-agent" + +[bstack] +council = true EOF FAKE_CONNECTED="claude-agent xai" bstack_setup >"$TMP_DIR/setup.log" 2>&1 || fail "setup: $(cat "$TMP_DIR/setup.log")" grep -q 'model' "$TMP_DIR/setup.log" && grep -q 'Pick' "$TMP_DIR/setup.log" && fail "setup must not ask about models" @@ -398,6 +467,10 @@ test_roles_resolve test_deploy_renders_agents test_deploy_keeps_their_files test_off_removes_only_ours +test_council_seats_need_the_setting +test_ready_verifies_without_the_council +test_council_keeps_only_rerun_findings +test_verify_skill_shape test_on_switches_stock_desk_agent test_setup_auto_models test_handoff_bstack_stage diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index f2e420e..945efd7 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -580,6 +580,66 @@ test_model_resolves_opus_55_not_max_slug test_start_stage_dry_run test_start_stage_resume_and_fixture_result +test_result_ok_verification() { + local f="$TMP_DIR/verification.json" + local base='{"status":"ready","summary":"done","session_id":"s1"}' good bad + ok_with() { jq -c --argjson v "$1" '. + {verification: $v}' <<<"$base" >"$f"; handoff_result_ok "$f"; } + printf '%s\n' "$base" >"$f" + handoff_result_ok "$f" || fail "a result without verification still passes" + for good in '[]' \ + '[{"kind":"test","command":"bash t.sh","exit_code":0,"evidence":"t.log"}]' \ + '[{"kind":"verify-skill","command":"run.sh","exit_code":1,"evidence":".bercail/verify/x"},{"kind":"statechart","command":"statechart a.tla","exit_code":0,"evidence":"tlc.out"}]' \ + '[{"kind":"statechart","spec":"a.tla","exit_code":0,"rules_broken":[],"chart":"chart.mmd","replayed":false}]'; do + ok_with "$good" || fail "should pass: $good" + done + for bad in '"passed"' 'false' '[1]' \ + '[{"kind":"vibes","command":"x","exit_code":0,"evidence":"e"}]' \ + '[{"kind":"test","command":"x","exit_code":"0","evidence":"e"}]' \ + '[{"kind":"test","exit_code":0,"evidence":"e"}]' \ + '[{"kind":"test","command":"x","exit_code":0}]' \ + '[{"kind":"statechart","exit_code":0}]'; do + if ok_with "$bad"; then fail "should fail: $bad"; fi + done + jq -nc '{status:"ready", summary:"done"}' >"$f" + if handoff_result_ok "$f"; then fail "session_id stays required"; fi + printf 'PASS: handoff_result_ok takes an optional, well-formed verification array\n' +} + +# A headless run whose agent writes $1 as its result. +_run_with_agent_result() { + local wt="$1" result="$2" stub="$TMP_DIR/bin/claude" + mkdir -p "$TMP_DIR/bin" + _start_fixture "$wt" + cat >"$stub" <"\$BERCAIL_RESULT_PATH" +STUB + chmod +x "$stub" + HANDOFF_CLAUDE_BIN="$stub" "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" >/dev/null +} + +test_runner_keeps_verification() { + local wt="$TMP_DIR/verify-keep" got + _run_with_agent_result "$wt" '{"status":"ready","summary":"proved","verification":[{"kind":"test","command":"bash t.sh","exit_code":0,"evidence":"t.log"}]}' \ + || fail "run with verification" + got="$wt/.bercail/handoff-result.json" + jq -e '.status == "ready" and .summary == "proved" and (.session_id | length > 0) + and .verification == [{kind:"test",command:"bash t.sh",exit_code:0,evidence:"t.log"}]' "$got" >/dev/null \ + || fail "verification must survive the run: $(cat "$got")" + + wt="$TMP_DIR/verify-drop" + _run_with_agent_result "$wt" '{"status":"ready","summary":"proved","verification":"all good"}' \ + || fail "a malformed verification list must not fail the run" + got="$wt/.bercail/handoff-result.json" + jq -e '.status == "ready" and (.summary | startswith("proved")) and (.summary | contains("malformed verification")) + and (has("verification") | not)' "$got" >/dev/null \ + || fail "a malformed list is dropped and the result stands: $(cat "$got")" + printf 'PASS: the runner keeps a good verification list and drops only a malformed one\n' +} + +test_result_ok_verification +test_runner_keeps_verification + test_codex_headless_argv_shape() { local sid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" handoff_build_headless_argv codex /stub/codex "" "$sid" 0 "do the job"