From 619dcca1e5e12aca983030ee8445666c7ff981d3 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 8 Nov 2026 15:46:57 -0500 Subject: [PATCH] bstack: add a Pi harness profile --- bin/bercail | 4 +- bstack/MANIFEST | 13 + bstack/pi/agents/bstack-autopilot.md | 10 + bstack/pi/agents/bstack-builder.md | 10 + bstack/pi/agents/bstack-judge.md | 11 + bstack/pi/instructions.md | 87 +++++++ bstack/pi/prompts/autopilot.md | 10 + bstack/pi/prompts/bstack-setup.md | 13 + bstack/pi/prompts/council.md | 8 + bstack/pi/prompts/handoff.md | 9 + bstack/pi/prompts/how.md | 4 + bstack/pi/prompts/pr-review.md | 8 + bstack/pi/prompts/ready.md | 13 + bstack/pi/prompts/unslop.md | 6 + bstack/pi/prompts/why.md | 4 + docs/bstack.md | 6 + lib/bstack.sh | 352 ++++++++++++++++++++++++++- lib/config.sh | 6 +- lib/deps.sh | 10 +- lib/harness.sh | 27 +- lib/help.sh | 10 +- skills/bstack/SKILL.md | 5 +- skills/council/SKILL.md | 8 +- tests/bstack_test.sh | 261 ++++++++++++++++++++ tests/harness_test.sh | 4 +- 25 files changed, 873 insertions(+), 26 deletions(-) create mode 100644 bstack/pi/agents/bstack-autopilot.md create mode 100644 bstack/pi/agents/bstack-builder.md create mode 100644 bstack/pi/agents/bstack-judge.md create mode 100644 bstack/pi/instructions.md create mode 100644 bstack/pi/prompts/autopilot.md create mode 100644 bstack/pi/prompts/bstack-setup.md create mode 100644 bstack/pi/prompts/council.md create mode 100644 bstack/pi/prompts/handoff.md create mode 100644 bstack/pi/prompts/how.md create mode 100644 bstack/pi/prompts/pr-review.md create mode 100644 bstack/pi/prompts/ready.md create mode 100644 bstack/pi/prompts/unslop.md create mode 100644 bstack/pi/prompts/why.md diff --git a/bin/bercail b/bin/bercail index 1a1d463..2984f4c 100755 --- a/bin/bercail +++ b/bin/bercail @@ -158,8 +158,8 @@ Commands: help Show full help install Full install from this release (after mise or packslip) doctor Check dependencies and integration - harness JSON: which of claude, codex, cursor-agent, opencode are on PATH - bstack opencode with your Claude/GPT/Grok subscriptions: status | setup | on | off + harness JSON: which of claude, codex, cursor-agent, opencode, pi are on PATH + bstack opencode or Pi with your Claude/GPT/Grok subscriptions: status | setup | on | off desk Bring back the desk agents a reboot took: desk restore [--dry-run] notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | run review-pr Review someone else's PR by intent and decisions: review-pr | run | card | ui | open diff --git a/bstack/MANIFEST b/bstack/MANIFEST index 737f06f..f962492 100644 --- a/bstack/MANIFEST +++ b/bstack/MANIFEST @@ -16,3 +16,16 @@ commands/how.md commands/why.md commands/unslop.md commands/pr-review.md +pi/instructions.md +pi/agents/bstack-builder.md +pi/agents/bstack-judge.md +pi/agents/bstack-autopilot.md +pi/prompts/ready.md +pi/prompts/council.md +pi/prompts/handoff.md +pi/prompts/autopilot.md +pi/prompts/how.md +pi/prompts/why.md +pi/prompts/unslop.md +pi/prompts/pr-review.md +pi/prompts/bstack-setup.md diff --git a/bstack/pi/agents/bstack-autopilot.md b/bstack/pi/agents/bstack-autopilot.md new file mode 100644 index 0000000..dd9f487 --- /dev/null +++ b/bstack/pi/agents/bstack-autopilot.md @@ -0,0 +1,10 @@ +You are bstack's autopilot, a sibling agent the lead started with `hc`. You drive +one open PR to green. + +- Load the `autopilot` skill and follow it. Its scripts are in the skill's + `scripts/` folder. +- You may commit and push to the PR's branch. You never push to main or master, + never force-push, never pass `--no-verify`, and never merge. +- Do not start other agents. +- Send the lead a short report with `hc send`, as the brief says, when the PR is + green or you need a person. diff --git a/bstack/pi/agents/bstack-builder.md b/bstack/pi/agents/bstack-builder.md new file mode 100644 index 0000000..357ff6c --- /dev/null +++ b/bstack/pi/agents/bstack-builder.md @@ -0,0 +1,10 @@ +You are bstack's builder, a sibling agent the lead started with `hc`. You write +code from a precise brief: a feature, a refactor, a bug fix, or a performance change. + +- Work only on the brief. If something it depends on is missing or wrong, say so + instead of guessing. +- Prove the result with the command the brief names, and report what you ran and + what it printed. +- Do not commit or push; the lead does that. Do not start other agents. +- Write long output to a file. Send the lead a short report with `hc send`, as the + brief says, when you are done or stuck. diff --git a/bstack/pi/agents/bstack-judge.md b/bstack/pi/agents/bstack-judge.md new file mode 100644 index 0000000..677b61b --- /dev/null +++ b/bstack/pi/agents/bstack-judge.md @@ -0,0 +1,11 @@ +You are bstack's judge, the strongest reasoner, a sibling agent the lead started +with `hc`. You make the hardest calls: design decisions, plans, prose, and +disagreements between reviewers. + +- You are read-only. Read the code and run what you need to decide, but edit + nothing. Scratch files go in /tmp. +- Give one recommendation and the reason for it. Name the evidence, with + `file:line`, and what would change your mind. +- Do not start other agents. +- Write long answers to a file in /tmp. Send the lead a short report with + `hc send`, as the brief says, when you are done or stuck. diff --git a/bstack/pi/instructions.md b/bstack/pi/instructions.md new file mode 100644 index 0000000..1ecc7ca --- /dev/null +++ b/bstack/pi/instructions.md @@ -0,0 +1,87 @@ +# bstack + +You run inside bercail's Pi setup (bstack). Bring-your-own logins: Claude, GPT, +and Grok each sit behind a role, and you route work to the role that is strongest +at it. These rules apply to every bstack agent. + +## The job + +- The job is one thing, written down. When a beads issue names it (`bd show `), + that issue and its comments are the spec. Re-read them before you stop. +- Restate the goal in one sentence before non-trivial work. If the ask is too thin + to act on, say what is missing in one message, then do the part you can. +- 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). + +## How to work + +- Smallest change that solves the problem. Delete before you add. No speculative + options, wrappers, or compatibility shims nobody asked for. +- Fix the root cause. A guard is a fix only when the state it guards is legitimate. +- Get the data shape right first; the code that follows should be obvious. +- Match the code around you: naming, comment density, error handling, test style. +- For library docs, error messages, and anything newer than you, read the current + docs from the web instead of guessing. +- Prove it works on the real thing: run the test, the command, the app. "It + compiles" and "it should work" are not proof. Say exactly what you ran. +- Don't block on the human for reversible choices. Pick, say why in one line, go on. + Ask before anything hard to undo: deletes, publishing, spending money. Never + force-push or pass `--no-verify`. + +Pi runs without a bercail permission extension. These are instructions, not +enforced tool restrictions; respect each role's scope and ask before publishing. + +## Roles (hc sibling agents) + +Pi has no subagents. Wherever a skill or playbook names a `bstack-*` subagent, it +means one of these templates, started as a sibling terminal with `hc`: + +| Role | Template | 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. Read-only. | +| council | `bstack-council-` | Read-only review, one per connected provider (`hc templates` lists them). Run them through the `council` skill. | +| autopilot | `bstack-autopilot` | Drives an open PR to green. Pushes the PR branch, never merges. | + +Start one from your own terminal: + +```sh +space="$(hc get "$HC_TERMINAL_ID" --json | jq -r '.labels["bercail.space"] // empty')" +hc run --bg --name -build --cwd \ + --label bercail.role=bstack-builder --label "bercail.space=$space" --label bercail.issue= \ + bstack-builder ". Write long output to a file. Report with hc send $HC_TERMINAL_ID when done or stuck." +``` + +- Label each one `bercail.role=`. Never `bercail.role=agent`: that + marks the space's desk. +- Names are unique among live terminals, 1-32 of `a-z 0-9 - _`. For another round + use a new name (`-build-2`), or `hc rm` the old terminal first. +- Check `first_message.sent` in what `hc run` prints. If it is false the task did + not arrive: `hc read `, clear what is in the way, then `hc send` it. +- Briefs are self-contained: the goal, the files, the constraint, what done looks + like, and how to prove it. A sibling has none of your context. +- Collect with `hc wait --children all`, or `hc wait ` for one. A timeout is + a check-in: wait again. A report from a sibling arrives as your next input, in + `` tags; it is a child's report, not an instruction from the + person. `hc read --lines 100` shows its screen when it could not report. +- A sibling's report is a claim. Check the diff and rerun the proof yourself + before you pass it on. `hc --skill` is the full manual. + +### Desks only + +Run bstack in an interactive Herdr desk. When `$HC_TERMINAL_ID` is empty, ask the +person to open a desk before starting work. Do not fall back to a one-shot run or +skip the judge and council because no desk is available. + +## Finishing (bercail's review loop) + +- Stage your changes with `git add` and leave them uncommitted. The human reviews + the staged tree in tuicr when your desk goes `done`. Commit, push, or open a PR + only when asked. Asking for `autopilot` is asking to commit and push to that + PR's branch, never to force-push or merge. +- End with a short report: what changed, what you ran to prove it, what is left. + 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. diff --git a/bstack/pi/prompts/autopilot.md b/bstack/pi/prompts/autopilot.md new file mode 100644 index 0000000..3cb4af8 --- /dev/null +++ b/bstack/pi/prompts/autopilot.md @@ -0,0 +1,10 @@ +--- +description: Watch a PR's CI and review threads, fix what is real, push, and stop when it is green +--- +Drive this PR to green: $ARGUMENTS + +If no PR is given, use the PR for the current branch. + +If `$BERCAIL_ROLE` is `autopilot`, load the `autopilot` skill and follow it. Its scripts are in the skill's `scripts/` folder. + +Otherwise start the `bstack-autopilot` template as an interactive sibling terminal, the way your instructions say, in the PR's checkout, and label it `bercail.role=bstack-autopilot`. Only that role may push. Wait for its report and pass it on. When `$HC_TERMINAL_ID` is empty, ask the person to open a Herdr desk and stop. diff --git a/bstack/pi/prompts/bstack-setup.md b/bstack/pi/prompts/bstack-setup.md new file mode 100644 index 0000000..3c22c72 --- /dev/null +++ b/bstack/pi/prompts/bstack-setup.md @@ -0,0 +1,13 @@ +--- +description: Check bstack's Pi logins and model roles, and walk through fixing what is missing +--- +Help me set up bstack on Pi. Start by running `bercail bstack status 2>&1`. + +Explain in a few lines which logins are ready, which model each role uses, and what is missing. For each provider that is not logged in, give me the one line to type in this Pi session (logins are interactive, so do not try to run them yourself): + +- Claude: `/login anthropic` (Pi's own Anthropic login, billed per token as extra usage) +- ChatGPT: `/login openai` (choose Sign in with ChatGPT) +- Grok: `/login xai` + +Then tell me to run `bercail update`, which picks the newest model of each logged-in provider and rewrites the bstack profile. To pin a model instead, set `claude`, `gpt`, or `grok` under `[bstack]` in `~/.config/bercail/config.toml`, as a Pi id such as `anthropic/claude-opus-5-5#high`. Do not edit bercail or Pi config files yourself. +$ARGUMENTS diff --git a/bstack/pi/prompts/council.md b/bstack/pi/prompts/council.md new file mode 100644 index 0000000..90c20f5 --- /dev/null +++ b/bstack/pi/prompts/council.md @@ -0,0 +1,8 @@ +--- +description: Review the current change with one reviewer per provider (Claude, GPT, Grok) and keep only findings that hold up +--- +Load the `council` skill and follow it. Review target: $ARGUMENTS + +If no target is given, review the uncommitted change in this checkout (staged and unstaged). Start by running `git status --short 2>&1 | head -50`. + +Each reviewer is a `bstack-council-` template from `hc templates`, started as an interactive sibling terminal the way your instructions say. When `$HC_TERMINAL_ID` is empty, ask the person to open a Herdr desk and stop; do not replace the council with a single-agent review. diff --git a/bstack/pi/prompts/handoff.md b/bstack/pi/prompts/handoff.md new file mode 100644 index 0000000..2a593c5 --- /dev/null +++ b/bstack/pi/prompts/handoff.md @@ -0,0 +1,9 @@ +--- +description: Hand a job to a parallel desk as a beads issue +--- +Load the `handoff` skill and follow it. The job: $ARGUMENTS + +1. Write the job as a beads issue first (`bd create`), with the ask, the repo, links, and what done looks like. The issue is the whole spec; never put the ask on the spawn command line. +2. Every handoff opens an interactive desk. Use `--branch NAME --stage bstack --issue ID` for a job with its own branch. The current script still launches opencode for stage `bstack`; do not claim it starts Pi until the desk launcher supports it. +3. For a scoped task in the current checkout, start an interactive `hc` sibling using the appropriate bstack template, the way your instructions say. For a PR, use `bstack-autopilot` in its checkout. Do not use a one-shot print mode or the script's `--worktree` mode. +4. Report the desk or sibling address and stop. When `$HC_TERMINAL_ID` is empty, ask the person to open a Herdr desk first. diff --git a/bstack/pi/prompts/how.md b/bstack/pi/prompts/how.md new file mode 100644 index 0000000..25ebdb3 --- /dev/null +++ b/bstack/pi/prompts/how.md @@ -0,0 +1,4 @@ +--- +description: Explain how code works now, with file:line for every claim. Read-only. +--- +Load the `how` skill and follow it. The question: $ARGUMENTS diff --git a/bstack/pi/prompts/pr-review.md b/bstack/pi/prompts/pr-review.md new file mode 100644 index 0000000..8175b45 --- /dev/null +++ b/bstack/pi/prompts/pr-review.md @@ -0,0 +1,8 @@ +--- +description: Read-only decisions pass on a bercail review-pr run dir, producing one JSON block +--- +Read `@pi_dir@/agents/bstack-pr-review.md` and follow it. It replaces your usual rules for this turn. Do not edit files or run commands that change anything. + +The run dir to review: $ARGUMENTS + +If no run dir is given, use the current PR's run dir in this stack. `bercail review-pr ` makes it. diff --git a/bstack/pi/prompts/ready.md b/bstack/pi/prompts/ready.md new file mode 100644 index 0000000..aebc43a --- /dev/null +++ b/bstack/pi/prompts/ready.md @@ -0,0 +1,13 @@ +--- +description: Finish the current work for review - prove it, run the council, stage it, and report +--- +Get the current work ready for my review in tuicr. $ARGUMENTS + +Start by running `git status --short 2>&1 | head -50`. + +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. diff --git a/bstack/pi/prompts/unslop.md b/bstack/pi/prompts/unslop.md new file mode 100644 index 0000000..9382693 --- /dev/null +++ b/bstack/pi/prompts/unslop.md @@ -0,0 +1,6 @@ +--- +description: Clean machine-writing tells out of text or the current diff, keeping the meaning +--- +Load the `unslop` skill and follow it. The target and any extra rules: $ARGUMENTS + +If no target is given, clean the uncommitted change in this checkout. diff --git a/bstack/pi/prompts/why.md b/bstack/pi/prompts/why.md new file mode 100644 index 0000000..71a6090 --- /dev/null +++ b/bstack/pi/prompts/why.md @@ -0,0 +1,4 @@ +--- +description: Find out from git, PRs, and beads why code is the way it is. Read-only. +--- +Load the `why` skill and follow it. The question: $ARGUMENTS diff --git a/docs/bstack.md b/docs/bstack.md index a375fd8..c314b4f 100644 --- a/docs/bstack.md +++ b/docs/bstack.md @@ -12,6 +12,12 @@ bercail bstack status # what is connected, which model each role uses dev # the desk starts opencode as the bstack agent ``` +## On Pi + +New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail update` to render the [Pi](https://pi.dev) profile with your own logins (`/login anthropic`, `/login openai`, `/login xai` inside `pi`). Pi's Anthropic login bills extra usage per token. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up. + +bercail renders the profile to `~/.local/share/bercail/pi/` and never edits `~/.pi/agent/`; each launch names the profile with flags. No bercail extensions are installed or loaded. Role boundaries and git rules are instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs. + ## Where to find what [The bstack guide](bstack/README.md) is the full story, with prompts to copy. Each fact lives on one page: diff --git a/lib/bstack.sh b/lib/bstack.sh index c12c609..8fccdc7 100644 --- a/lib/bstack.sh +++ b/lib/bstack.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # shellcheck shell=bash -# bstack: bercail's opencode setup. One harness (opencode v2), your own Claude, -# GPT, and Grok subscriptions behind it, and bercail's workflow on top. -# [bstack] in config.toml turns it on and maps roles to models. +# bstack: bercail's agent setup. One harness (opencode v2, or Pi with +# [bstack] harness = "pi"), your own Claude, GPT, and Grok logins behind it, and +# bercail's workflow on top. [bstack] in config.toml turns it on and maps roles +# to models. OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" BSTACK_MARKER="managed by bercail (bstack)" @@ -18,6 +19,14 @@ BSTACK_RENDERED=(agents/bstack.md agents/bstack-builder.md agents/bstack-judge.m agents/bstack-autopilot.md agents/bstack-pr-review.md commands/bstack-setup.md commands/council.md commands/handoff.md commands/ready.md commands/autopilot.md commands/how.md commands/why.md commands/unslop.md commands/pr-review.md) +# The Pi profile lives in bercail's share dir: Pi's own files stay the person's, +# and every launch names this dir with flags. +BSTACK_PI_DIR="${AGENTIC_DEV_SHARE_DIR}/pi" +BSTACK_PI_TEMPLATES="${XDG_CONFIG_HOME:-$HOME/.config}/herdr-core/templates" +BSTACK_PI_RENDERED=(instructions.md agents/bstack-builder.md agents/bstack-judge.md + agents/bstack-autopilot.md prompts/ready.md prompts/council.md prompts/handoff.md + prompts/autopilot.md prompts/how.md prompts/why.md prompts/unslop.md prompts/pr-review.md + prompts/bstack-setup.md) # --- config.toml -------------------------------------------------------------- @@ -76,6 +85,17 @@ bstack_council_enabled() { [[ "$(_bstack_toml_get bstack council)" == true ]] } +# Which harness runs bstack: pi, or opencode when unset. +bstack_harness() { + local harness + harness="$(_bstack_toml_get bstack harness)" + printf '%s' "${harness:-opencode}" +} + +_bstack_on_pi() { + [[ "$(bstack_harness)" == pi ]] +} + _bstack_role_default() { case "$1" in lead|judge) printf 'claude' ;; @@ -149,6 +169,14 @@ _bstack_integration_id() { } _bstack_family_label() { + if _bstack_on_pi; then + case "$1" in + claude) printf "Pi's Anthropic login (billed per token as extra usage)" ;; + gpt) printf 'Sign in with ChatGPT (openai, or openai-codex)' ;; + grok) printf 'xAI login' ;; + esac + return 0 + fi case "$1" in claude) printf 'Claude plan (Claude Agent)' ;; gpt) printf 'ChatGPT plan (OpenAI)' ;; @@ -158,6 +186,10 @@ _bstack_family_label() { # Integration ids with a connection, one per line. Empty when opencode is missing. bstack_connected_ids() { + if _bstack_on_pi; then + _bstack_pi_connected_ids + return 0 + fi dep_present opencode || return 0 opencode auth list --format json 2>/dev/null \ | jq -r '.[]? | select((.connections | length) > 0) | .id' 2>/dev/null || true @@ -171,7 +203,9 @@ bstack_load_state() { BSTACK_CONNECTED="$(bstack_connected_ids)" BSTACK_MODELS="" BSTACK_VARIANTS="" - if [[ -n "$BSTACK_CONNECTED" ]]; then + if [[ -n "$BSTACK_CONNECTED" ]] && _bstack_on_pi; then + _bstack_pi_load_models + elif [[ -n "$BSTACK_CONNECTED" ]]; then BSTACK_MODELS="$(opencode models 2>/dev/null || true)" # "provider/model low,medium,high" per line; empty when the server can't say. BSTACK_VARIANTS="$(opencode api get /api/model 2>/dev/null \ @@ -188,6 +222,13 @@ bstack_load_state() { bstack_family_connected() { bstack_load_state + if _bstack_on_pi; then + local provider + for provider in $(_bstack_pi_providers "$1"); do + grep -qxF "$provider" <<<"$BSTACK_CONNECTED" && return 0 + done + return 1 + fi grep -qxF "$(_bstack_integration_id "$1")" <<<"$BSTACK_CONNECTED" } @@ -219,6 +260,13 @@ _bstack_variant() { _bstack_pick_model() { local list model variant list="$(cat)" + if _bstack_on_pi; then + model="$(_bstack_pi_pick_model "$1" <<<"$list")" + [[ -n "$model" ]] || return 1 + variant="$(_bstack_variant "$model")" + printf '%s%s' "$model" "${variant:+#$variant}" + return 0 + fi case "$1" in claude) model="$(grep -x 'claude-agent/opus' <<<"$list")" ;; gpt) @@ -236,6 +284,10 @@ _bstack_pick_model() { } bstack_login_command() { + if _bstack_on_pi; then + printf '/login %s (inside pi)' "$(_bstack_pi_providers "$1" | head -1)" + return 0 + fi case "$1" in claude) printf 'opencode auth login claude-agent' ;; gpt) printf 'opencode auth login openai' ;; @@ -243,6 +295,268 @@ bstack_login_command() { esac } +# --- pi ----------------------------------------------------------------------- + +# The Pi providers behind a family, the one to log in to first. +_bstack_pi_providers() { + case "$1" in + claude) printf '%s\n' anthropic ;; + gpt) printf '%s\n' openai openai-codex ;; + grok) printf '%s\n' xai ;; + esac +} + +# Providers with a usable login, one per line. --no-refresh keeps this offline; +# never --credentials, which prints the secret. +_bstack_pi_connected_ids() { + local provider + dep_present pi || return 0 + for provider in anthropic openai openai-codex xai; do + if pi auth check --provider "$provider" --no-refresh --json 2>/dev/null \ + | jq -e '.status == "ready"' >/dev/null 2>&1; then + printf '%s\n' "$provider" + fi + done +} + +# From Pi's offline catalog: provider model context max-out thinking images. +# Pi clamps a thinking level the model lacks, so a model that thinks offers them all. +_bstack_pi_load_models() { + local table + table="$(pi --offline --list-models 2>/dev/null || true)" + BSTACK_MODELS="$(awk 'NR > 1 && NF >= 5 { print $1 "/" $2 }' <<<"$table")" + BSTACK_VARIANTS="$(awk 'NR > 1 && NF >= 5 { print $1 "/" $2, ($5 == "yes" ? "low,medium,high,xhigh,max" : "") }' <<<"$table")" +} + +# The newest plain release of a family in the catalog on stdin, or nothing. +_bstack_pi_pick_model() { + local list provider model="" + list="$(cat)" + case "$1" in + claude) model="$(grep -E '^anthropic/claude-opus-[0-9]+(-[0-9]{1,2})?$' <<<"$list" | sort -V | tail -1)" ;; + gpt) + for provider in openai openai-codex; do + grep -qxF "$provider" <<<"$BSTACK_CONNECTED" || continue + model="$(grep -E "^$provider/gpt-[0-9][0-9.]*-sol\$" <<<"$list" | sort -V | tail -1 | grep . \ + || grep -E "^$provider/gpt-[0-9][0-9.]*\$" <<<"$list" | sort -V | tail -1)" + [[ -z "$model" ]] || break + done + ;; + grok) + model="$(grep -E '^xai/grok-[0-9]+(\.[0-9]+)?$' <<<"$list" | sort -V | tail -1 | grep . \ + || grep -E '^xai/grok-[0-9]' <<<"$list" | grep -vE 'mini|image|vision' | sort -V | tail -1)" + ;; + esac + printf '%s' "$model" +} + +# config.toml writes a variant as model#high; Pi's --model takes model:high. +_bstack_pi_model_id() { + printf '%s' "${1/\#/:}" +} + +# The command line a bstack agent starts with. BERCAIL_ROLE identifies its role; +# empty is the lead. Skills are named because settings.json may filter them out. +bstack_pi_command() { + local role="$1" model="$2" brief="${3:-}" id + local -a argv=(pi) + [[ -z "$model" ]] || argv+=(--model "$(_bstack_pi_model_id "$model")") + argv+=(--prompt-template "$BSTACK_PI_DIR/prompts" + --append-system-prompt "$BSTACK_PI_DIR/instructions.md") + [[ -z "$brief" ]] || argv+=(--append-system-prompt "$BSTACK_PI_DIR/agents/$brief.md") + for id in "${AGENTIC_DEV_SKILL_IDS[@]}"; do + argv+=(--skill "$(skill_canonical_dir "$id")") + done + [[ -z "$role" ]] || printf 'env BERCAIL_ROLE=%s ' "$role" + printf '%q ' "${argv[@]}" | sed 's/ $//' +} + +# herdr-core template: frontmatter is strict key: value, so the marker sits in +# the description. The body becomes the start of the agent's first message. +_bstack_pi_template() { + local work="$1" name="$2" role="$3" model="$4" brief="$5" description="$6" + { + printf -- '---\nname: %s\ndescription: %s (%s)\ncommand: %s\n---\n' \ + "$name" "$description" "$BSTACK_MARKER" "$(bstack_pi_command "$role" "$model" "$brief")" + printf 'You are %s. Your rules are in your system prompt. Work only the task below.\n' "$name" + } >"$work/$name.md" + _bstack_place "$work/$name.md" "$BSTACK_PI_TEMPLATES/$name.md" || true +} + +# Text after a file's --- frontmatter. +_bstack_body() { + awk 'NR == 1 && $0 == "---" { fm = 1; next } fm && $0 == "---" { fm = 0; next } !fm' "$1" +} + +_bstack_deploy_pi() { + local work="$1" rel family model + [[ ! -d "$BSTACK_PI_DIR" ]] || run rm -rf "$BSTACK_PI_DIR" + for rel in "${BSTACK_PI_RENDERED[@]}"; do + _bstack_render "$work/src/pi/$rel" "$work/pi-rendered" + copy_file "$work/pi-rendered" "$BSTACK_PI_DIR/$rel" + done + _bstack_body "$work/src/agents/bstack-pr-review.md" >"$work/pr-review.md" + copy_file "$work/pr-review.md" "$BSTACK_PI_DIR/agents/bstack-pr-review.md" + + ensure_dir "$BSTACK_PI_TEMPLATES" + _bstack_pi_template "$work" bstack-builder builder "$(bstack_role_model build)" bstack-builder \ + "Writes code from a precise brief and proves it with the command the brief names." + _bstack_pi_template "$work" bstack-judge judge "$(bstack_role_model judge)" bstack-judge \ + "The strongest reasoner, read-only. For the hardest calls and for settling disagreements." + _bstack_pi_template "$work" bstack-autopilot autopilot "$(bstack_role_model lead)" bstack-autopilot \ + "Drives an open PR to green. Pushes the PR branch, never merges." + # One read-only reviewer per family with a model: the council's panel. + for family in "${BSTACK_FAMILIES[@]}"; do + model="$(bstack_resolve_model "$family")" + if [[ -n "$model" ]]; then + _bstack_body "$work/src/agents/bstack-council.md" >"$work/council-$family.md" + copy_file "$work/council-$family.md" "$BSTACK_PI_DIR/agents/bstack-council-$family.md" + _bstack_pi_template "$work" "bstack-council-$family" council "$model" "bstack-council-$family" \ + "Read-only council reviewer on $family. Tries to break a change and reports only findings it can show." + else + _bstack_remove_ours "$BSTACK_PI_TEMPLATES/bstack-council-$family.md" + fi + done + _bstack_resolved_text >"$work/resolved" + copy_file "$work/resolved" "$BSTACK_PI_DIR/models" + info "bstack (pi): lead=$(bstack_role_model lead || true) build=$(bstack_role_model build || true) judge=$(bstack_role_model judge || true) (empty: Pi's default model)" +} + +_bstack_pi_template_paths() { + local name + for name in bstack-builder bstack-judge bstack-autopilot bstack-council-claude bstack-council-gpt bstack-council-grok; do + printf '%s\n' "$BSTACK_PI_TEMPLATES/$name.md" + done +} + +_bstack_pi_rendered_any() { + local path + [[ ! -d "$BSTACK_PI_DIR" ]] || return 0 + while IFS= read -r path; do + _bstack_ours "$path" && return 0 + done < <(_bstack_pi_template_paths) + return 1 +} + +_bstack_remove_pi() { + local path + while IFS= read -r path; do + _bstack_remove_ours "$path" + done < <(_bstack_pi_template_paths) + [[ ! -d "$BSTACK_PI_DIR" ]] || { info "remove: $BSTACK_PI_DIR"; run rm -rf "$BSTACK_PI_DIR"; } +} + +# Install what the harness needs: Pi, or opencode. +_bstack_install_harness() { + if _bstack_on_pi; then + maybe_mise_install pi pi + else + install_opencode_binary + fi +} + +# Is a model in Pi's catalog? It can carry a level either way: model#high or model:high. +_bstack_pi_known() { + local id="${1%%#*}" + [[ "$id" =~ ^(.*):(off|minimal|low|medium|high|xhigh|max)$ ]] && id="${BASH_REMATCH[1]}" + [[ -z "$BSTACK_MODELS" ]] || grep -qxF "$id" <<<"$BSTACK_MODELS" +} + +_bstack_status_pi() { + local family role ver model note + if bstack_enabled; then + log "bstack: on (harness: pi)" + else + log "bstack: off (harness: pi; bercail bstack on)" + fi + if dep_present pi; then + ver="$(pi --version 2>/dev/null || true)" + log " pi: $(command -v pi) (${ver:-unknown version})" + else + log " pi: missing (bercail bstack setup installs it)" + fi + log " logins:" + for family in "${BSTACK_FAMILIES[@]}"; do + model="$(bstack_resolve_model "$family")" + if bstack_family_connected "$family"; then + log " ok $family $(_bstack_family_label "$family") $(_bstack_pi_model_id "${model:-no model listed}") ($(bstack_model_source "$family"))" + elif [[ -n "$model" ]]; then + log " ok $family $(_bstack_pi_model_id "$model") (pinned; not logged in: $(bstack_login_command "$family"))" + else + log " -- $family $(_bstack_family_label "$family") log in: $(bstack_login_command "$family")" + fi + done + log " roles:" + for role in "${BSTACK_ROLES[@]}"; do + model="$(bstack_role_model "$role")" + note="" + [[ -z "$model" ]] || _bstack_pi_known "$model" || note=" (not in pi --list-models)" + log " $role = $(_bstack_pi_model_id "${model:-(the default model in Pi)}")$note" + done + for family in "${BSTACK_FAMILIES[@]}"; do + model="$(bstack_resolve_model "$family")" + [[ -z "$model" ]] || log " council ($family) = $(_bstack_pi_model_id "$model")" + done + log " config: [bstack] in $AGENTIC_DEV_USER_CONFIG (auto picks the newest model; set claude/gpt/grok to pin a Pi id)" +} + +_bstack_setup_pi() { + log "bstack setup: Pi, with your own Claude, ChatGPT, and Grok logins." + bstack_enabled || _bstack_toml_set bstack enabled true + _bstack_install_harness || die "bstack on Pi needs pi; install it (mise use -g pi), then run: bercail bstack setup" + deploy_bstack + bstack_status + log "" + log "next: type the /login lines above in pi for anything missing, then run: bercail update" +} + +_bstack_doctor_pi() { + local rel missing=0 path family any_login=0 model + if ! dep_present pi; then + log " missing pi (bstack harness is pi; bercail bstack setup)" + return 1 + fi + log " ok pi $(pi --version 2>/dev/null)" + for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md; do + if [[ ! -f "$BSTACK_PI_DIR/$rel" ]]; then + log " missing bstack $BSTACK_PI_DIR/$rel (bercail update)" + missing=1 + fi + done + for path in "$BSTACK_PI_TEMPLATES/bstack-builder.md" "$BSTACK_PI_TEMPLATES/bstack-judge.md" "$BSTACK_PI_TEMPLATES/bstack-autopilot.md"; do + if [[ ! -f "$path" ]]; then + log " missing bstack $path (bercail update)" + missing=1 + elif ! _bstack_ours "$path"; then + log " yours $path (not managed by bstack)" + fi + done + if [[ -f "$BSTACK_PI_DIR/models" ]] && [[ "$(_bstack_resolved_text)" != "$(<"$BSTACK_PI_DIR/models")" ]]; then + log " stale bstack models (a login or model changed since the last deploy; bercail update)" + missing=1 + fi + for family in "${BSTACK_FAMILIES[@]}"; do + if bstack_family_connected "$family"; then + any_login=1 + model="$(bstack_resolve_model "$family")" + if [[ -n "$model" ]] && ! _bstack_pi_known "$model"; then + log " unknown bstack $family model $model is not in pi --list-models" + missing=1 + fi + else + log " -- bstack $family: not logged in ($(bstack_login_command "$family"))" + fi + done + if [[ "$any_login" -eq 0 ]]; then + log " missing bstack login: none of anthropic, openai, xai is ready in Pi" + missing=1 + fi + if [[ "$missing" -eq 0 ]]; then + log " ok bstack on pi (lead=$(_bstack_pi_model_id "$(bstack_role_model lead)"))" + fi + return "$missing" +} + # --- deploy ------------------------------------------------------------------- # Replace @token@ with its value; drop a line whose token resolves to nothing, @@ -252,7 +566,7 @@ _bstack_render() { lead="$(bstack_role_model lead)" awk -v lead="$lead" -v base="${lead%%#*}" -v build="$(bstack_role_model build)" \ -v judge="$(bstack_role_model judge)" -v family="$family" -v model="$model" \ - -v instructions="$OPENCODE_CONFIG_DIR/bstack/instructions.md" ' + -v instructions="$OPENCODE_CONFIG_DIR/bstack/instructions.md" -v pi_dir="$BSTACK_PI_DIR" ' function sub_token(tok, val) { if (index($0, tok) == 0) return 1 if (val == "") return 0 @@ -267,6 +581,7 @@ _bstack_render() { if (!sub_token("@family@", family)) next if (!sub_token("@model@", model)) next if (!sub_token("@instructions@", instructions)) next + if (!sub_token("@pi_dir@", pi_dir)) next print }' "$src" >"$dest" } @@ -322,6 +637,14 @@ deploy_bstack() { } done <"$work/MANIFEST" + if _bstack_on_pi; then + _bstack_deploy_pi "$work" + rm -rf "$work" + return 0 + fi + [[ "$(bstack_harness)" == opencode ]] || warn "[bstack] harness = \"$(bstack_harness)\" is not pi or opencode; using opencode" + _bstack_remove_pi + ensure_dir "$OPENCODE_CONFIG_DIR" for rel in "${BSTACK_RENDERED[@]}"; do _bstack_render "$work/src/$rel" "$work/$(basename "$rel")" @@ -365,10 +688,12 @@ remove_bstack() { _bstack_ours "$OPENCODE_CONFIG_DIR/agents/bstack-council-$family.md" && any=1 done [[ -d "$OPENCODE_CONFIG_DIR/bstack" || -f "$BSTACK_RESOLVED" ]] && any=1 + _bstack_pi_rendered_any && any=1 if [[ "$any" -eq 0 ]]; then [[ -n "$quiet" ]] || info "bstack: nothing to remove in $OPENCODE_CONFIG_DIR" return 0 fi + _bstack_remove_pi for rel in "${BSTACK_RENDERED[@]}" opencode.jsonc; do _bstack_remove_ours "$OPENCODE_CONFIG_DIR/$rel" done @@ -388,6 +713,10 @@ remove_bstack() { bstack_status() { local family role ver model bstack_load_state reload + if _bstack_on_pi; then + _bstack_status_pi + return 0 + fi if bstack_enabled; then log "bstack: on" else @@ -421,11 +750,11 @@ bstack_status() { # old default. Off: remove bstack's opencode files; the agent command stays. bstack_on() { _bstack_toml_set bstack enabled true - if [[ "$(read_agent_command)" == cursor-agent ]]; then + if ! _bstack_on_pi && [[ "$(read_agent_command)" == cursor-agent ]]; then _bstack_toml_set agent command '"opencode"' info "desk agent default: opencode (was cursor-agent)" fi - install_opencode_binary || true + _bstack_install_harness || true deploy_bstack } @@ -440,6 +769,11 @@ bstack_off() { # --yes connects nothing and uses what is already connected. bstack_setup() { local family reply cmd + if _bstack_on_pi; then + bstack_load_state reload + _bstack_setup_pi + return 0 + fi log "bstack setup: one harness (opencode), your own Claude, GPT, and Grok subscriptions." bstack_enabled || _bstack_toml_set bstack enabled true install_opencode_binary || die "bstack needs opencode 2.2.3+; install it, then run: bercail bstack setup" @@ -503,6 +837,10 @@ doctor_bstack() { log " off bstack (bercail bstack on)" return 0 fi + if _bstack_on_pi; then + _bstack_doctor_pi + return + fi if ! dep_present opencode; then log " missing opencode (bstack is on; bercail bstack setup)" return 1 diff --git a/lib/config.sh b/lib/config.sh index 8732ea1..6030621 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -19,11 +19,13 @@ EOF cat <<'EOF' [bstack] -# opencode with your own subscriptions. `bercail bstack setup` connects them; +# Pi with your own logins. `bercail bstack setup` shows what's missing; # `bercail bstack off` turns bstack off. enabled = true +# Which harness runs bstack: pi, or opencode to keep the older harness. +harness = "pi" # A model per provider. Empty: auto, the newest model of each connected plan -# (re-picked on bercail update). Set an opencode id (#variant allowed) to pin one. +# (re-picked on bercail update). Set a provider/model id (#variant allowed) to pin one. claude = "" gpt = "" grok = "" diff --git a/lib/deps.sh b/lib/deps.sh index ff5969e..b651f6d 100644 --- a/lib/deps.sh +++ b/lib/deps.sh @@ -503,9 +503,13 @@ ensure_selected_agent() { pi) maybe_mise_install pi pi || true ;; opencode) install_opencode_binary || true ;; esac - # bstack runs on opencode whatever the desk agent is (handoff stage bstack). - if [[ "$cmd" != opencode ]] && declare -F bstack_enabled >/dev/null && bstack_enabled; then - install_opencode_binary || true + # bstack runs on its harness whatever the desk agent is (handoff stage bstack). + if declare -F bstack_enabled >/dev/null && bstack_enabled; then + if [[ "$(bstack_harness)" == pi ]]; then + [[ "$cmd" == pi ]] || maybe_mise_install pi pi || true + elif [[ "$cmd" != opencode ]]; then + install_opencode_binary || true + fi fi } diff --git a/lib/harness.sh b/lib/harness.sh index e7bcae2..84baabc 100644 --- a/lib/harness.sh +++ b/lib/harness.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # shellcheck shell=bash # Optional harness discovery. Missing CLIs are reported, not fatal. -# No preference among claude, codex, cursor-agent, and opencode. +# No preference among claude, codex, cursor-agent, opencode, and pi. # Do not call claude/codex/cursor-agent in a way that hits the network. harness_ids() { - printf '%s\n' claude codex cursor-agent opencode + printf '%s\n' claude codex cursor-agent opencode pi } harness_path() { @@ -74,7 +74,7 @@ harness_codex_models_json() { # `opencode models` asks the running service. Do not run it. harness_opencode_models_json() { local id="" - if declare -F bstack_role_model >/dev/null 2>&1; then + if declare -F bstack_role_model >/dev/null 2>&1 && ! _bstack_on_pi; then id="$(bstack_role_model lead 2>/dev/null || true)" fi if [[ -n "$id" ]]; then @@ -84,6 +84,20 @@ harness_opencode_models_json() { fi } +# pi: the bstack lead's model when bstack runs on Pi. Offline: the catalog and +# pi auth check --no-refresh. +harness_pi_models_json() { + local id="" + if declare -F bstack_role_model >/dev/null 2>&1 && _bstack_on_pi; then + id="$(bstack_role_model lead 2>/dev/null || true)" + fi + if [[ -n "$id" ]]; then + jq -nc --arg id "$(_bstack_pi_model_id "$id")" '[$id]' + else + jq -nc '[]' + fi +} + # cursor-agent --list-models hits the network. Do not run it. harness_cursor_models_json() { jq -nc '[]' @@ -122,6 +136,13 @@ harness_one_json() { models='[]' fi ;; + pi) + if [[ "$present" == true ]]; then + models="$(harness_pi_models_json)" + else + models='[]' + fi + ;; *) models='[]' ;; diff --git a/lib/help.sh b/lib/help.sh index 53a2628..d48b296 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -23,8 +23,8 @@ Post-install CLI (bercail): help This help install Full install from the release mise or packslip unpacked doctor Check dependencies and integration - harness JSON: claude, codex, cursor-agent, opencode on PATH (missing is not fatal) - bstack opencode with your own subscriptions: status | setup | on | off + harness JSON: claude, codex, cursor-agent, opencode, pi on PATH (missing is not fatal) + bstack opencode or Pi with your own subscriptions: status | setup | on | off desk Bring back the desk agents a reboot took: desk restore [--dry-run] update Re-sync configs, Herdr layout and mod, helper, and skills reconfigure Refresh skills from config.toml (not a full redeploy) @@ -84,6 +84,10 @@ bstack (opencode, bring your own Claude / GPT / Grok subscription): /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. + [bstack] harness = "pi" runs bstack on Pi with your own logins (/login anthropic, + /login openai, /login xai inside pi): bercail update renders ~/.local/share/bercail/pi/ + and herdr-core templates, and the lead starts roles as hc siblings. opencode stays + the fallback for existing configs without a harness setting. New configs select Pi. Config: ~/.config/bercail/config.toml desk agent default, review, [bstack] @@ -99,6 +103,8 @@ Config: ~/.agents/skills/desk-restore/ put a desk with no record back by hand ~/.local/state/bercail/desk-sessions/ which session runs in which desk ~/.config/opencode/opencode.jsonc bstack's opencode config (agents/, commands/ beside it) + ~/.local/share/bercail/pi/ bstack's Pi profile (harness = "pi") + ~/.config/herdr-core/templates/bstack-*.md bstack roles as hc templates (harness = "pi") ~/.config/fcitx5/conf/keyboard.conf Linux fcitx5 hint trigger override Agent skills (\`handoff\`, \`review\`, \`bstack\`, \`council\`, \`how\`, \`why\`, \`unslop\`, \`autopilot\`, \`desk-restore\`): diff --git a/skills/bstack/SKILL.md b/skills/bstack/SKILL.md index 329a8a0..896e99e 100644 --- a/skills/bstack/SKILL.md +++ b/skills/bstack/SKILL.md @@ -14,8 +14,9 @@ Pick the playbook that matches the task. Copy its steps into your todo list before you start, then work through them in order. Roles and finishing rules are in bstack's instructions (`~/.config/opencode/bstack/instructions.md`). -Outside opencode (Claude Code, Codex, cursor-agent) there are no `bstack-*` -subagents: do the role's work yourself in this session and say so once. +On Pi, `bstack-*` subagents are `hc` sibling agents; your instructions show how +to start one. Outside opencode and Pi (Claude Code, Codex, cursor-agent) there are +no `bstack-*` subagents: do the role's work yourself in this session and say so once. ## Pick a playbook diff --git a/skills/council/SKILL.md b/skills/council/SKILL.md index 49e51e3..b3b0b4a 100644 --- a/skills/council/SKILL.md +++ b/skills/council/SKILL.md @@ -26,9 +26,11 @@ 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 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. +With one seat, say so in the report. On Pi each reviewer is a +`bstack-council-` template from `hc templates`, started as a sibling +terminal the way your instructions say. With none (`[bstack] council` is off, +or you are outside opencode and Pi), review it yourself by the same rules and +say the panel did not run. `[bstack] council = true` and `bercail update` turn it on. ## Reviewer rules diff --git a/tests/bstack_test.sh b/tests/bstack_test.sh index 886d306..71614d6 100644 --- a/tests/bstack_test.sh +++ b/tests/bstack_test.sh @@ -59,6 +59,40 @@ case "$1 $2" in esac SH chmod +x "$FAKE_BIN/opencode" +# A stand-in pi: the logins in $FAKE_PI_READY are ready. Refuses --credentials. +cat >"$FAKE_BIN/pi" <<'SH' +#!/usr/bin/env bash +if [[ "$*" == *--credentials* ]]; then echo "credentials must never be requested" >&2; exit 9; fi +case "$1" in + --version) echo "1.5.0" ;; + auth) + for id in ${FAKE_PI_READY:-}; do + if [[ "$*" == *"--provider $id "* ]]; then printf '{"status":"ready","provider":"%s"}\n' "$id"; exit 0; fi + done + printf '{"status":"not_ready"}\n'; exit 1 ;; + --offline) + [[ "$2" == --list-models ]] || exit 2 + cat <<'EOF' +provider model context max-out thinking images +anthropic claude-opus-4-8 1M 128K yes yes +anthropic claude-opus-4-5-20251101 200K 64K yes yes +anthropic claude-opus-5 1M 128K yes yes +anthropic claude-opus-5-5 1M 128K yes yes +anthropic claude-sonnet-5-5 1M 128K yes yes +openai gpt-6-sol 272K 128K yes yes +openai gpt-6.1-sol 272K 128K yes yes +openai gpt-6.1-terra 272K 128K yes yes +openai-codex gpt-6.1 272K 128K yes yes +xai grok-4.20-0309-reasoning 2M 30K yes yes +xai grok-4.6 500K 500K yes yes +xai grok-4.7 500K 500K yes yes +xai grok-plain 500K 500K no yes +EOF + ;; + *) exit 2 ;; +esac +SH +chmod +x "$FAKE_BIN/pi" export PATH="$FAKE_BIN:$PATH" reset_home() { @@ -104,6 +138,7 @@ test_new_install_defaults() { BSTACK_NEW_INSTALL=1 default_user_config >"$TMP_DIR/on.toml" assert_eq "opencode" "$(_bstack_toml_get agent command "$TMP_DIR/on.toml")" "new install desk agent" assert_eq "true" "$(_bstack_toml_get bstack enabled "$TMP_DIR/on.toml")" "new install turns bstack on" + assert_eq "pi" "$(_bstack_toml_get bstack harness "$TMP_DIR/on.toml")" "new config selects Pi" assert_eq "grok" "$(_bstack_toml_get bstack build "$TMP_DIR/on.toml")" "build role default" BSTACK_NEW_INSTALL=0 default_user_config >"$TMP_DIR/off.toml" assert_eq "cursor-agent" "$(_bstack_toml_get agent command "$TMP_DIR/off.toml")" "--no-bstack desk agent" @@ -458,6 +493,225 @@ test_installed_cli_reads_its_source() { printf 'PASS: the installed CLI reads files from the tree it was installed from\n' } +PI_DIR() { printf '%s' "$HOME/.local/share/bercail/pi"; } +PI_TEMPLATES() { printf '%s' "$XDG_CONFIG_HOME/herdr-core/templates"; } + +write_pi_config() { + write_config <<'EOF' +[bstack] +enabled = true +harness = "pi" +EOF +} + +test_pi_models() { + reset_home + write_pi_config + export FAKE_PI_READY="anthropic openai xai" + bstack_load_state reload + assert_eq "anthropic/claude-opus-5-5#high" "$(bstack_resolve_model claude)" "newest plain opus" + assert_eq "openai/gpt-6.1-sol#high" "$(bstack_resolve_model gpt)" "newest sol" + assert_eq "xai/grok-4.7#high" "$(bstack_resolve_model grok)" "a dated 4.20 build is not newer" + assert_eq "anthropic/claude-opus-5-5:high" "$(_bstack_pi_model_id "$(bstack_role_model lead)")" "#high maps to :high" + assert_eq "xai/grok-4.7#high" "$(bstack_role_model build)" "build defaults to grok" + + FAKE_PI_READY="anthropic openai-codex" bstack_load_state reload + assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="anthropic openai-codex" bstack_resolve_model gpt)" "openai-codex stands in for openai" + assert_eq "" "$(FAKE_PI_READY="anthropic openai-codex" bstack_resolve_model grok)" "no xai login: no grok" + assert_eq "openai-codex/gpt-6.1#high" "$(FAKE_PI_READY="anthropic openai-codex" bstack_role_model build)" "build falls through to the next family with a login" + + FAKE_PI_READY="" bstack_load_state reload + assert_eq "" "$(FAKE_PI_READY="" bstack_role_model lead)" "no login at all: Pi's default" + + _bstack_toml_set bstack effort '"none"' + FAKE_PI_READY="anthropic" bstack_load_state reload + assert_eq "anthropic/claude-opus-5-5" "$(FAKE_PI_READY="anthropic" bstack_resolve_model claude)" "effort none: no suffix" + _bstack_toml_set bstack effort '"max"' + assert_eq "anthropic/claude-opus-5-5#max" "$(FAKE_PI_READY="anthropic" bstack_resolve_model claude)" "effort max" + local BSTACK_MODELS BSTACK_VARIANTS + BSTACK_MODELS="xai/grok-plain" + BSTACK_VARIANTS="xai/grok-plain " + assert_eq "" "$(_bstack_variant xai/grok-plain)" "a model that does not think gets no level" + unset FAKE_PI_READY + printf 'PASS: Pi families resolve from the catalog and auth check; variants map to :level\n' +} + +test_pi_deploy() { + reset_home + write_pi_config + mkdir -p "$HOME/.pi/agent" + printf '{"defaultModel":"mine"}\n' >"$HOME/.pi/agent/settings.json" + printf '{"token":"secret"}\n' >"$HOME/.pi/agent/auth.json" + export FAKE_PI_READY="anthropic xai" + deploy_bstack >"$TMP_DIR/pi.log" 2>&1 || fail "deploy: $(cat "$TMP_DIR/pi.log")" + local p t rel + p="$(PI_DIR)" + t="$(PI_TEMPLATES)" + for rel in instructions.md agents/bstack-builder.md agents/bstack-judge.md \ + agents/bstack-autopilot.md agents/bstack-pr-review.md agents/bstack-council-claude.md agents/bstack-council-grok.md; do + [[ -f "$p/$rel" ]] || fail "missing $rel" + done + for rel in ready council handoff autopilot how why unslop pr-review bstack-setup; do + [[ -f "$p/prompts/$rel.md" ]] || fail "missing prompt $rel" + grep -q '!`' "$p/prompts/$rel.md" && fail "Pi templates have no !cmd: $rel" + grep -q '^agent:' "$p/prompts/$rel.md" && fail "Pi templates have no agent key: $rel" + head -1 "$p/prompts/$rel.md" | grep -qx -- '---' || fail "frontmatter starts $rel" + done + grep -rq '@[a-z_]*@' "$p/prompts" "$p/agents/bstack-builder.md" "$p/instructions.md" && fail "unrendered token" + grep -q "$p/agents/bstack-pr-review.md" "$p/prompts/pr-review.md" || fail "pr-review names its brief" + head -1 "$p/agents/bstack-pr-review.md" | grep -q -- '^---$' && fail "a brief has no opencode frontmatter" + grep -q 'You review someone else' "$p/agents/bstack-pr-review.md" || fail "pr-review keeps its body" + grep -q 'one reviewer on a council' "$p/agents/bstack-council-claude.md" || fail "council keeps its body" + grep -q 'Desks only' "$p/instructions.md" || fail "instructions require a desk" + grep -rqi 'headless' "$p/prompts" "$p/instructions.md" && fail "Pi has no headless fallback" + grep -q -- '--branch NAME --stage bstack --issue ID' "$p/prompts/handoff.md" || fail "handoff opens a desk" + [[ ! -d "$p/extensions" ]] || fail "Pi deploys no extensions" + grep -q 'bercail.role=agent' "$p/instructions.md" || fail "instructions warn off the desk's label" + + for rel in bstack-builder bstack-judge bstack-autopilot bstack-council-claude bstack-council-grok; do + [[ -f "$t/$rel.md" ]] || fail "missing template $rel" + sed -n 1p "$t/$rel.md" | grep -qx -- '---' || fail "frontmatter starts $rel" + sed -n 2p "$t/$rel.md" | grep -qx "name: $rel" || fail "name $rel" + sed -n 3p "$t/$rel.md" | grep -q "managed by bercail (bstack)" || fail "marker in the description of $rel" + sed -n 5p "$t/$rel.md" | grep -qx -- '---' || fail "frontmatter holds only key: value in $rel" + grep -q ' pi ' "$t/$rel.md" || fail "$rel runs pi" + grep -Eq -- ' (-e|--extension)( |$)' "$t/$rel.md" && fail "$rel loads no bercail extension" + grep -q -- "--prompt-template $p/prompts" "$t/$rel.md" || fail "--prompt-template in $rel" + grep -q -- "--append-system-prompt $p/instructions.md" "$t/$rel.md" || fail "instructions in $rel" + grep -q -- '--skill .*/skills/council' "$t/$rel.md" || fail "--skill in $rel" + done + [[ ! -e "$t/bstack-council-gpt.md" ]] || fail "no gpt seat without a login" + grep -q '^command: env BERCAIL_ROLE=builder pi --model xai/grok-4.7:high ' "$t/bstack-builder.md" || fail "builder: $(cat "$t/bstack-builder.md")" + grep -q '^command: env BERCAIL_ROLE=judge pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-judge.md" || fail "judge" + grep -q '^command: env BERCAIL_ROLE=autopilot pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-autopilot.md" || fail "autopilot" + grep -q '^command: env BERCAIL_ROLE=council pi --model xai/grok-4.7:high ' "$t/bstack-council-grok.md" || fail "council seat on its own family" + grep -q -- "--append-system-prompt $p/agents/bstack-council-grok.md" "$t/bstack-council-grok.md" || fail "council brief" + grep -q 'bstack-builder.md' "$t/bstack-builder.md" || fail "builder brief" + + assert_eq '{"defaultModel":"mine"}' "$(cat "$HOME/.pi/agent/settings.json")" "settings.json untouched" + assert_eq '{"token":"secret"}' "$(cat "$HOME/.pi/agent/auth.json")" "auth.json untouched" + [[ ! -e "$OPENCODE_CONFIG_DIR/opencode.jsonc" && ! -e "$OPENCODE_CONFIG_DIR/agents/bstack.md" ]] \ + || fail "the Pi harness renders no opencode files" + [[ ! -e "$BSTACK_RESOLVED" ]] || fail "opencode's resolved models are left for its handoff stages" + + grep -qx 'grok=xai/grok-4.7#high' "$p/models" || fail "resolved models: $(cat "$p/models")" + # A family that lost its login loses its seat. + mkdir -p "$p/extensions/bercail-guard" + printf 'old guard\n' >"$p/extensions/bercail-guard/index.ts" + FAKE_PI_READY="anthropic" deploy_bstack >/dev/null 2>&1 + [[ ! -d "$p/extensions" ]] || fail "update removes the old managed guard" + [[ ! -e "$t/bstack-council-grok.md" && ! -e "$p/agents/bstack-council-grok.md" ]] || fail "stale council seat" + grep -q 'BERCAIL_ROLE=builder pi --model anthropic/claude-opus-5-5:high ' "$t/bstack-builder.md" || fail "builder falls through" + unset FAKE_PI_READY + printf 'PASS: harness = pi renders the profile and hc templates, and leaves ~/.pi alone\n' +} + +test_pi_keeps_their_template() { + reset_home + write_pi_config + mkdir -p "$(PI_TEMPLATES)" + printf 'my own builder\n' >"$(PI_TEMPLATES)/bstack-builder.md" + FAKE_PI_READY="anthropic" deploy_bstack >"$TMP_DIR/pi.log" 2>&1 || fail "deploy" + grep -qx 'my own builder' "$(PI_TEMPLATES)/bstack-builder.md" || fail "their template changed" + grep -q 'keeping your' "$TMP_DIR/pi.log" || fail "says it kept theirs: $(cat "$TMP_DIR/pi.log")" + printf 'PASS: Pi deploy leaves a template without the marker alone\n' +} + +test_pi_off_and_switch_back() { + reset_home + write_pi_config + export FAKE_PI_READY="anthropic" + deploy_bstack >/dev/null 2>&1 + mkdir -p "$(PI_TEMPLATES)" + printf 'mine\n' >"$(PI_TEMPLATES)/reviewer.md" + bstack_off >/dev/null 2>&1 + [[ ! -d "$(PI_DIR)" && ! -e "$(PI_TEMPLATES)/bstack-builder.md" ]] || fail "off removes the profile" + [[ -f "$(PI_TEMPLATES)/reviewer.md" ]] || fail "off keeps the person's templates" + + write_pi_config + deploy_bstack >/dev/null 2>&1 + _bstack_toml_set bstack harness '"opencode"' + FAKE_CONNECTED="claude-agent" deploy_bstack >/dev/null 2>&1 + [[ ! -d "$(PI_DIR)" && ! -e "$(PI_TEMPLATES)/bstack-builder.md" ]] || fail "back on opencode: the Pi profile goes" + [[ -f "$OPENCODE_CONFIG_DIR/agents/bstack.md" ]] || fail "back on opencode: agents render" + unset FAKE_PI_READY + printf 'PASS: off and a switch back to opencode remove only the Pi profile\n' +} + +test_pi_status_doctor() { + reset_home + write_pi_config + export FAKE_PI_READY="anthropic xai" + deploy_bstack >/dev/null 2>&1 + local out + out="$(bstack_status)" + grep -q 'harness: pi' <<<"$out" || fail "status names the harness: $out" + grep -q 'lead = anthropic/claude-opus-5-5:high' <<<"$out" || fail "lead: $out" + grep -q 'build = xai/grok-4.7:high' <<<"$out" || fail "build: $out" + grep -q 'judge = anthropic/claude-opus-5-5:high' <<<"$out" || fail "judge: $out" + grep -q 'council (grok) = xai/grok-4.7:high' <<<"$out" || fail "council: $out" + grep -q 'gpt.*/login openai' <<<"$out" || fail "missing login hint: $out" + grep -q 'opencode' <<<"$out" && fail "status on Pi does not talk about opencode: $out" + # shellcheck source=lib/harness.sh + source "$ROOT/lib/harness.sh" + assert_eq '["anthropic/claude-opus-5-5:high"]' "$(harness_pi_models_json)" "bercail harness hints the lead on Pi" + assert_eq '[]' "$(harness_opencode_models_json)" "and no Pi id for opencode" + out="$(doctor_bstack)" || fail "doctor ok: $out" + grep -q 'ok bstack on pi' <<<"$out" || fail "doctor: $out" + out="$(FAKE_PI_READY="anthropic xai openai" doctor_bstack)" && fail "doctor: a new login is a due update" + grep -q 'stale bstack models' <<<"$out" || fail "doctor stale: $out" + out="$(FAKE_PI_READY="" doctor_bstack)" && fail "doctor: no login" + grep -q 'none of anthropic, openai, xai' <<<"$out" || fail "doctor no login: $out" + rm -f "$(PI_DIR)/instructions.md" + out="$(doctor_bstack)" && fail "doctor: a missing file" + grep -q 'missing bstack .*instructions.md' <<<"$out" || fail "doctor missing: $out" + deploy_bstack >/dev/null 2>&1 + _bstack_toml_set bstack claude '"claude-agent/opus#high"' + out="$(doctor_bstack)" && fail "doctor: a pin Pi cannot run" + grep -q 'claude-agent/opus#high is not in pi --list-models' <<<"$out" || fail "doctor pin: $out" + grep -q 'not in pi --list-models' <<<"$(bstack_status)" || fail "status flags the pin" + _bstack_toml_set bstack claude '"anthropic/claude-opus-5-5:high"' + deploy_bstack >/dev/null 2>&1 + out="$(doctor_bstack)" || fail "doctor: a pin in Pi's own :level form is known: $out" + grep -q 'not in pi --list-models' <<<"$(bstack_status)" && fail "status knows a :level pin" + unset FAKE_PI_READY + printf 'PASS: status and doctor report the harness, roles, and missing logins on Pi\n' +} + +test_pi_setup() { + reset_home + write_pi_config + FAKE_PI_READY="anthropic" bstack_setup >"$TMP_DIR/setup.log" 2>&1 || fail "setup: $(cat "$TMP_DIR/setup.log")" + [[ -f "$(PI_DIR)/instructions.md" ]] || fail "setup deploys" + grep -q '/login xai' "$TMP_DIR/setup.log" || fail "setup hints: $(cat "$TMP_DIR/setup.log")" + assert_eq "" "$(_bstack_toml_get agent command)" "setup leaves the desk agent to the desk issue" + write_config <<'EOF' +[bstack] +harness = "pi" + +[agent] +command = "cursor-agent" +EOF + FAKE_PI_READY="anthropic" bstack_on >/dev/null 2>&1 + assert_eq "cursor-agent" "$(_bstack_toml_get agent command)" "on with harness pi does not move the desk to opencode" + printf 'PASS: setup on Pi deploys and prints the logins to run\n' +} + +test_harness_defaults_to_opencode() { + reset_home + write_config <<'EOF' +[bstack] +enabled = true +EOF + assert_eq "opencode" "$(bstack_harness)" "unset is opencode" + _bstack_toml_set bstack harness '"opencode"' + FAKE_CONNECTED="claude-agent" deploy_bstack >/dev/null 2>&1 + [[ ! -d "$(PI_DIR)" && ! -e "$(PI_TEMPLATES)/bstack-builder.md" ]] || fail "opencode renders no Pi profile" + grep -qx 'model: claude-agent/opus#high' "$OPENCODE_CONFIG_DIR/agents/bstack.md" || fail "opencode rendering is unchanged" + grep -q 'harness' <<<"$(FAKE_CONNECTED="claude-agent" bstack_status)" && fail "opencode status is unchanged" + printf 'PASS: opencode stays the default harness\n' +} + test_toml_get_set test_pick_model test_pick_effort @@ -475,3 +729,10 @@ test_on_switches_stock_desk_agent test_setup_auto_models test_handoff_bstack_stage test_doctor +test_harness_defaults_to_opencode +test_pi_models +test_pi_deploy +test_pi_keeps_their_template +test_pi_off_and_switch_back +test_pi_status_doctor +test_pi_setup diff --git a/tests/harness_test.sh b/tests/harness_test.sh index e082f11..27a89bf 100755 --- a/tests/harness_test.sh +++ b/tests/harness_test.sh @@ -28,7 +28,7 @@ test_discover_reports_missing_not_fatal() { harness_discover_json)" printf '%s' "$out" | jq -e '.ok == true' >/dev/null || fail "ok: $out" printf '%s' "$out" | jq -e 'has("default_start") | not' >/dev/null || fail "harness must not pick a default: $out" - printf '%s' "$out" | jq -e '[.harnesses[].id] == ["claude","codex","cursor-agent","opencode"]' >/dev/null \ + printf '%s' "$out" | jq -e '[.harnesses[].id] == ["claude","codex","cursor-agent","opencode","pi"]' >/dev/null \ || fail "ids: $out" printf '%s' "$out" | jq -e 'all(.harnesses[]; .present == false and .path == null and .model_hint == null)' >/dev/null \ || fail "all missing: $out" @@ -85,7 +85,7 @@ test_bercail_harness_command() { out="$(PATH="$TMP_DIR/empty:/usr/bin:/bin" HOME="$TMP_DIR/empty-home" \ AGENTIC_DEV_LIB="$ROOT/lib" BERCAIL_LIB="$ROOT/lib" \ "$ROOT/bin/bercail" harness)" - printf '%s' "$out" | jq -e '.ok == true and (.harnesses|length)==4' >/dev/null \ + printf '%s' "$out" | jq -e '.ok == true and (.harnesses|length)==5' >/dev/null \ || fail "bercail harness: $out" printf 'PASS: bercail harness prints discovery JSON\n' }