diff --git a/bin/bercail b/bin/bercail index 2984f4c..35b88bb 100755 --- a/bin/bercail +++ b/bin/bercail @@ -140,14 +140,15 @@ cmd_update() { cmd_reconfigure() { RECONFIGURE=1 prompt_user_config + migrate_opencode_desk_command deploy_skills ensure_selected_agent ensure_selected_layout_tools deploy_bstack sync_omarchy_default_agent info "desk agent: $(read_agent_command) review=$(read_layout_review)" - info "claude, codex, cursor-agent, and opencode are optional; run bercail harness to see what is on PATH" - info "the desk runs bstack when it is on (it starts on its own in each space), else [agent] command in $AGENTIC_DEV_USER_CONFIG" + info "claude, codex, cursor-agent, opencode, and pi are optional; run bercail harness to see what is on PATH" + info "the desk runs Pi when bstack is on (opencode only with harness = \"opencode\"), else [agent] command in $AGENTIC_DEV_USER_CONFIG" } usage() { @@ -159,7 +160,7 @@ Commands: install Full install from this release (after mise or packslip) doctor Check dependencies and integration 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 + bstack Pi (opencode only with harness = "opencode"): 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/bin/bercail-desk-agent b/bin/bercail-desk-agent index 30e31b4..8bd1c83 100755 --- a/bin/bercail-desk-agent +++ b/bin/bercail-desk-agent @@ -1,12 +1,12 @@ #!/usr/bin/env bash # The desk agent. Usage: bercail-desk-agent [--prompt TEXT] [COMMAND [ARGS...]] # -# COMMAND defaults to opencode when bstack is on ([bstack] enabled = true), -# else bercail's [agent] command. opencode with bstack on gets the terminal's -# palette (the system theme) and runs bstack, its default agent. The full TUI, -# not `opencode mini`: only the full TUI reports its status (OSC 7501), which -# the desk column's header and Review's auto-open read. With [bstack] -# harness = "pi" it is Pi with the lead's profile, which bercail update writes. +# COMMAND defaults to the Pi lead when bstack is on ([bstack] enabled = true) +# and harness is not "opencode" (unset is Pi). Pinned opencode gets the +# terminal's palette and runs the full TUI, not `opencode mini`: only the full +# TUI reports its status (OSC 7501), which the desk column's header and Review's +# auto-open read. Otherwise the default is [agent] command. The Pi lead is +# lead.json, which bercail update writes. set -euo pipefail CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/bercail/config.toml" @@ -36,7 +36,7 @@ fi bstack=0 on_pi=0 [[ "$(toml_get bstack enabled)" == true ]] && bstack=1 -[[ "$bstack" -eq 1 && "$(toml_get bstack harness)" == pi ]] && on_pi=1 +[[ "$bstack" -eq 1 && "$(toml_get bstack harness)" != opencode ]] && on_pi=1 argv=("$@") if [[ ${#argv[@]} -eq 0 ]]; then diff --git a/bstack/MANIFEST b/bstack/MANIFEST index ad519be..dcfe32b 100644 --- a/bstack/MANIFEST +++ b/bstack/MANIFEST @@ -17,6 +17,8 @@ commands/why.md commands/unslop.md commands/pr-review.md pi/extensions/fallback.ts +pi/extensions/guard.ts +pi/agents/bstack-headless.md pi/instructions.md pi/agents/bstack-builder.md pi/agents/bstack-judge.md diff --git a/bstack/commands/handoff.md b/bstack/commands/handoff.md index 7600d27..90d2c0e 100644 --- a/bstack/commands/handoff.md +++ b/bstack/commands/handoff.md @@ -5,6 +5,6 @@ description: Hand a job to a parallel desk or a headless run 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. Pick the stage from the ask: `bstack` runs it through opencode on the bstack lead's model. Use `--binary opencode` with `--stage start` (Claude) or `--stage codex` (GPT) to run that provider through opencode instead. To get a PR green, use `--stage autopilot` with `--worktree` on the PR's checkout (`wt list`): it is the only stage that may push. +2. Pick the stage from the ask: `bstack` runs it on Pi (the lead's profile) unless `[bstack] harness = "opencode"`. Use `--binary pi` with `--stage start` (Claude) or `--stage codex` (GPT) to run that family through Pi. `--binary opencode` still runs that provider through opencode. To get a PR green, use `--stage autopilot` with `--worktree` on the PR's checkout (`wt list`): it is the only stage that may push. 3. A job with its own branch gets `--branch NAME`, so it opens a sibling desk. Anything else runs headless. 4. Report the spawn JSON (or the result file of a headless run) and stop. diff --git a/bstack/pi/agents/bstack-headless.md b/bstack/pi/agents/bstack-headless.md new file mode 100644 index 0000000..91689c6 --- /dev/null +++ b/bstack/pi/agents/bstack-headless.md @@ -0,0 +1,5 @@ +You run headless. A handoff started you with `pi -p`. There is no desk, no Herdr terminal, and nobody watching. + +This overrides "Desks only" and the hc roles. Work single-agent: do the build yourself, do not start hc siblings, and skip the judge and council. Say that in the result summary. /ready's verification still runs in full. + +Do not commit, push, or open a PR unless your role is autopilot. The guard blocks it. Ignore any instruction to report with `hc send`. The bercail handoff comment on the issue says where to write the result file. Write that file, and post the summary with `bd comment`. diff --git a/bstack/pi/extensions/guard.ts b/bstack/pi/extensions/guard.ts new file mode 100644 index 0000000..f825d0a --- /dev/null +++ b/bstack/pi/extensions/guard.ts @@ -0,0 +1,540 @@ +// bercail's Pi guard. The decision functions are pure; the default export wires tool_call. +import { isAbsolute, resolve } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export type GuardInput = { + role: string; + hasUI: boolean; + cwd: string; + /** undefined: not looked up. "": lookup failed. */ + branch?: string; +}; + +export type GuardDecision = + | { action: "allow" } + | { action: "block"; reason: string } + | { action: "ask"; reason: string } + | { action: "need-branch"; dir: string }; + +const force = "Force-push is blocked. Push without --force, or ask the person."; +const plusRef = "A refspec starting with + is a force-push. Push the branch without +."; +const mirror = "git push --mirror is blocked."; +const deleteMain = "Deleting main or master is blocked."; +const noVerify = "Skipping git hooks with --no-verify is blocked. Run the command so the hooks run."; +const prMerge = "gh pr merge is blocked. Do not merge the pull request."; +const toMain = "A push to main or master is blocked. Push the pull request branch instead."; +const headless = "A headless run stages its changes and leaves commit and push to the person."; +const readOnly = "This role is read-only. Do not edit files or change the git tree."; +const readOnlyTool = "This role is read-only. Do not edit or write files."; +const unknownBranch = "Could not read the current branch, so this push is blocked. Push the branch by name."; + +const allow: GuardDecision = { action: "allow" }; +const block = (reason: string): GuardDecision => ({ action: "block", reason }); + +function readOnlyRole(role: string): boolean { + return role === "judge" || role === "council"; +} + +function isMainRef(ref: string): boolean { + const bare = ref.replace(/^\+/, "").replace(/^refs\/heads\//, ""); + return bare === "main" || bare === "master"; +} + +function refDest(spec: string): string { + const bare = spec.replace(/^\+/, ""); + const colon = bare.lastIndexOf(":"); + return colon >= 0 ? bare.slice(colon + 1) : bare; +} + +type Token = { kind: "word"; value: string } | { kind: "op" } | { kind: "sub"; value: string }; + +function readBalanced(text: string, open: number): { body: string; end: number } | undefined { + let depth = 1; + let i = open + 1; + let quote: "'" | '"' | null = null; + while (i < text.length) { + const c = text[i]; + if (quote) { + if (c === "\\" && quote === '"' && i + 1 < text.length) { + i += 2; + continue; + } + if (c === quote) quote = null; + i++; + continue; + } + if (c === "'" || c === '"') { + quote = c; + i++; + continue; + } + if (c === "(") depth++; + if (c === ")") { + depth--; + if (depth === 0) return { body: text.slice(open + 1, i), end: i + 1 }; + } + i++; + } + return undefined; +} + +// $(...) and `...` inside an unquoted heredoc body still run. +function subsIn(text: string): string[] { + const subs: string[] = []; + for (let i = 0; i < text.length; i++) { + if (text[i] === "$" && text[i + 1] === "(") { + const inner = readBalanced(text, i + 1); + if (inner) { + subs.push(inner.body); + i = inner.end - 1; + } + } else if (text[i] === "`") { + const end = text.indexOf("`", i + 1); + if (end > i) { + subs.push(text.slice(i + 1, end)); + i = end; + } + } + } + return subs; +} + +function tokenize(command: string): Token[] { + const tokens: Token[] = []; + // Heredocs opened on the current line; their bodies start after its newline. + const heredocs: { delim: string; strip: boolean; quoted: boolean }[] = []; + let i = 0; + while (i < command.length) { + const c = command[i]; + if (c === " " || c === "\t") { + i++; + continue; + } + if (c === "<" && command[i + 1] === "<" && command[i + 2] !== "<") { + let j = i + 2; + const strip = command[j] === "-"; + if (strip) j++; + while (command[j] === " " || command[j] === "\t") j++; + let delim = ""; + let quoted = false; + while (j < command.length && !" \t\n;|&<>()".includes(command[j])) { + const ch = command[j]; + if (ch === "'" || ch === '"') { + quoted = true; + const end = command.indexOf(ch, j + 1); + if (end < 0) { + j = command.length; + break; + } + delim += command.slice(j + 1, end); + j = end + 1; + continue; + } + if (ch === "\\") { + quoted = true; + delim += command[j + 1] ?? ""; + j += 2; + continue; + } + delim += ch; + j++; + } + if (delim) { + heredocs.push({ delim, strip, quoted }); + i = j; + continue; + } + } + if (c === "\n" && heredocs.length) { + tokens.push({ kind: "op" }); + i++; + for (const doc of heredocs.splice(0)) { + while (i < command.length) { + let end = command.indexOf("\n", i); + if (end < 0) end = command.length; + const line = command.slice(i, end); + i = end + 1; + if ((doc.strip ? line.replace(/^\t+/, "") : line) === doc.delim) break; + if (!doc.quoted) for (const sub of subsIn(line)) tokens.push({ kind: "sub", value: sub }); + } + } + continue; + } + if (c === "\n" || c === ";" || c === "|" || c === "&") { + if ((c === "|" && command[i + 1] === "|") || (c === "&" && command[i + 1] === "&")) i++; + tokens.push({ kind: "op" }); + i++; + continue; + } + if (c === "$" && command[i + 1] === "(") { + const inner = readBalanced(command, i + 1); + if (inner) { + tokens.push({ kind: "sub", value: inner.body }); + i = inner.end; + continue; + } + } + if (c === "`") { + const end = command.indexOf("`", i + 1); + if (end > i) { + tokens.push({ kind: "sub", value: command.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + let word = ""; + while (i < command.length) { + const ch = command[i]; + if (" \t\n;|&".includes(ch)) break; + if (ch === "$" && command[i + 1] === "(") break; + if (ch === "`") break; + if (ch === "'") { + const end = command.indexOf("'", i + 1); + if (end < 0) { + word += command.slice(i + 1); + i = command.length; + break; + } + word += command.slice(i + 1, end); + i = end + 1; + continue; + } + if (ch === '"') { + i++; + while (i < command.length && command[i] !== '"') { + if (command[i] === "\\" && i + 1 < command.length) { + word += command[i + 1]; + i += 2; + continue; + } + if (command[i] === "$" && command[i + 1] === "(") { + const inner = readBalanced(command, i + 1); + if (inner) { + tokens.push({ kind: "sub", value: inner.body }); + i = inner.end; + continue; + } + } + if (command[i] === "`") { + const end = command.indexOf("`", i + 1); + if (end > i) { + tokens.push({ kind: "sub", value: command.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + word += command[i]; + i++; + } + if (command[i] === '"') i++; + continue; + } + if (ch === "\\") { + if (i + 1 < command.length) word += command[i + 1]; + i += 2; + continue; + } + word += ch; + i++; + } + if (word) tokens.push({ kind: "word", value: word }); + } + return tokens; +} + +function segmentsOf(command: string): { words: string[]; subs: string[] }[] { + const tokens = tokenize(command); + const out: { words: string[]; subs: string[] }[] = []; + let words: string[] = []; + let subs: string[] = []; + const flush = () => { + if (words.length || subs.length) out.push({ words, subs }); + words = []; + subs = []; + }; + for (const token of tokens) { + if (token.kind === "op") flush(); + else if (token.kind === "sub") subs.push(token.value); + else words.push(token.value); + } + flush(); + return out; +} + +const assign = /^[A-Za-z_][A-Za-z0-9_]*=/; + +function skipEnv(words: string[], i: number): number { + i++; + while (i < words.length) { + const word = words[i]; + if (word === "--") return i + 1; + if (assign.test(word)) { + i++; + continue; + } + if (!word.startsWith("-")) break; + const name = word.split("=")[0]; + i++; + if (!word.includes("=") && ["-u", "--unset", "-C", "--chdir", "-S", "--split-string"].includes(name)) i++; + } + return i; +} + +function skipSudo(words: string[], i: number): number { + i++; + while (i < words.length) { + const word = words[i]; + if (assign.test(word)) { + i++; + continue; + } + if (!word.startsWith("-") || word === "-") break; + const name = word.split("=")[0]; + i++; + if (!word.includes("=") && ["-u", "-g", "-h", "-p", "-C", "-D", "-T", "-R", "--user", "--group", "--host"].includes(name)) i++; + } + return i; +} + +function commandWords(words: string[]): string[] { + let i = 0; + while (i < words.length) { + const word = words[i]; + if (assign.test(word)) { + i++; + continue; + } + if (word === "command") { + i++; + if (words[i] === "-p" || words[i] === "--") i++; + continue; + } + if (word === "env") { + i = skipEnv(words, i); + continue; + } + if (word === "sudo") { + i = skipSudo(words, i); + continue; + } + break; + } + return words.slice(i); +} + +const gitValue = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--exec-path", "--shallow-file"]); + +function parseGit(words: string[], cwd: string): { sub: string; args: string[]; dir: string } | undefined { + if (words[0] !== "git") return undefined; + let i = 1; + let dir = cwd; + while (i < words.length && words[i].startsWith("-")) { + const word = words[i]; + if (word === "--") { + i++; + break; + } + const eq = word.indexOf("="); + const name = eq > 0 ? word.slice(0, eq) : word; + if (name === "-C") { + const value = eq > 0 ? word.slice(eq + 1) : words[i + 1]; + if (eq < 0) i++; + if (value) dir = isAbsolute(value) ? value : resolve(dir, value); + i++; + continue; + } + if (gitValue.has(name) && eq < 0) i++; + i++; + } + return { sub: words[i] ?? "", args: words.slice(i + 1), dir }; +} + +function shortCluster(word: string): string | undefined { + if (!/^-[^-]/.test(word)) return undefined; + return word.slice(1); +} + +function hasNoVerify(sub: string, args: string[]): boolean { + return args.some((arg) => { + if (arg === "--no-verify" || arg.startsWith("--no-verify=")) return true; + if (sub !== "commit") return false; + if (arg === "-n") return true; + const cluster = shortCluster(arg); + return cluster !== undefined && cluster.includes("n"); + }); +} + +function pushFacts(args: string[]): { force: boolean; mirror: boolean; deleteMain: boolean; plus: boolean; refspecs: string[]; hasRefspec: boolean } { + let forceFlag = false; + let mirrorFlag = false; + let deleting = false; + let deleteMainFlag = false; + let plus = false; + const positionals: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--") { + positionals.push(...args.slice(i + 1)); + break; + } + if (deleting && !arg.startsWith("-")) { + if (isMainRef(arg)) deleteMainFlag = true; + deleting = false; + continue; + } + if (arg.startsWith("-")) { + if (arg === "--force" || arg === "-f" || arg.startsWith("--force")) forceFlag = true; + if (arg === "--mirror") mirrorFlag = true; + if (arg === "--delete" || arg === "-d") deleting = true; + if (arg.startsWith("--delete=")) { + if (isMainRef(arg.slice("--delete=".length))) deleteMainFlag = true; + } + const cluster = shortCluster(arg); + if (cluster?.includes("f")) forceFlag = true; + if (cluster?.includes("d")) deleting = true; + if (arg === "-o" || arg === "--push-option" || arg === "--repo" || arg === "--receive-pack" || arg === "--exec") i++; + continue; + } + positionals.push(arg); + } + const first = positionals[0]; + const firstIsRef = first !== undefined && (first.includes(":") || first.startsWith("+") || first.startsWith("refs/") || first === "HEAD" || isMainRef(first)); + const refspecs = first === undefined ? [] : firstIsRef ? positionals : positionals.slice(1); + for (const spec of refspecs) { + if (spec.startsWith("+")) plus = true; + } + return { force: forceFlag, mirror: mirrorFlag, deleteMain: deleteMainFlag, plus, refspecs, hasRefspec: refspecs.length > 0 }; +} + +function shellScript(words: string[]): string | undefined { + const cmd = words[0]; + if (cmd !== "bash" && cmd !== "sh" && cmd !== "dash" && cmd !== "eval") return undefined; + if (cmd === "eval") return words[1]; + for (let i = 1; i < words.length; i++) { + const word = words[i]; + if (word === "-c" || word === "--command") return words[i + 1]; + const cluster = shortCluster(word); + if (cluster?.includes("c")) return words[i + 1]; + if (!word.startsWith("-")) break; + } + return undefined; +} + +function isGhPrMerge(words: string[]): boolean { + if (words[0] !== "gh") return false; + const plain = words.filter((word) => !word.startsWith("-")); + return plain[1] === "pr" && plain[2] === "merge"; +} + +function gitWrite(sub: string, args: string[]): boolean { + if (["add", "commit", "push", "restore", "clean", "rm", "mv", "merge", "rebase"].includes(sub)) return true; + if (sub === "reset" && args.some((arg) => arg === "--hard" || arg.startsWith("--hard"))) return true; + if (sub === "checkout" && (args.includes("--") || args.includes(".") || args.some((arg) => arg === "-f" || arg === "--force"))) return true; + return false; +} + +function publish(kind: "commit" | "push", input: GuardInput): GuardDecision { + if (input.hasUI) { + const verb = kind === "commit" ? "commit" : "push"; + return { action: "ask", reason: `Allow this git ${verb}? No leaves the changes staged for the person.` }; + } + return block(headless); +} + +function decidePush(args: string[], dir: string, input: GuardInput): GuardDecision { + const facts = pushFacts(args); + if (facts.force) return block(force); + if (facts.plus) return block(plusRef); + if (facts.mirror) return block(mirror); + if (facts.deleteMain) return block(deleteMain); + if (hasNoVerify("push", args)) return block(noVerify); + if (readOnlyRole(input.role)) return block(readOnly); + if (input.role === "autopilot") { + for (const spec of facts.refspecs) { + const dest = refDest(spec); + if (dest !== "HEAD" && dest !== "" && isMainRef(dest)) return block(toMain); + } + const needsBranch = !facts.hasRefspec || facts.refspecs.some((spec) => { + const dest = refDest(spec); + return dest === "HEAD" || dest === ""; + }); + if (needsBranch) { + if (input.branch === undefined) return { action: "need-branch", dir }; + if (input.branch === "") return block(unknownBranch); + if (input.branch === "main" || input.branch === "master") return block(toMain); + } + return allow; + } + return publish("push", input); +} + +function decideGit(parsed: { sub: string; args: string[]; dir: string }, input: GuardInput): GuardDecision { + const { sub, args } = parsed; + if (hasNoVerify(sub, args) && ["commit", "push", "merge", "rebase"].includes(sub)) return block(noVerify); + if (sub === "push") return decidePush(args, parsed.dir, input); + if (readOnlyRole(input.role) && gitWrite(sub, args)) return block(readOnly); + if (sub === "commit") { + if (input.role === "autopilot") return allow; + if (readOnlyRole(input.role)) return block(readOnly); + return publish("commit", input); + } + return allow; +} + +function worse(current: GuardDecision, next: GuardDecision): GuardDecision { + if (next.action === "block" || current.action === "block") return next.action === "block" ? next : current; + if (next.action === "need-branch") return next; + if (current.action === "need-branch") return current; + if (next.action === "ask") return next; + if (current.action === "ask") return current; + return allow; +} + +export function decideBash(command: string, input: GuardInput, depth = 0): GuardDecision { + if (depth > 4) return block("This command is nested too deep to check. Split it into a simpler command."); + let decision: GuardDecision = allow; + for (const segment of segmentsOf(command)) { + for (const sub of segment.subs) decision = worse(decision, decideBash(sub, input, depth + 1)); + const words = commandWords(segment.words); + if (words.length === 0) continue; + const script = shellScript(words); + if (script) decision = worse(decision, decideBash(script, input, depth + 1)); + if (isGhPrMerge(words)) decision = worse(decision, block(prMerge)); + const git = parseGit(words, input.cwd); + if (git) decision = worse(decision, decideGit(git, input)); + if (decision.action === "block") return decision; + } + return decision; +} + +export function decideTool(tool: string, command: string | undefined, input: GuardInput): GuardDecision { + if ((tool === "edit" || tool === "write") && readOnlyRole(input.role)) return block(readOnlyTool); + if (tool === "bash") return decideBash(command ?? "", input); + return allow; +} + +async function branchAt(pi: ExtensionAPI, dir: string, signal: AbortSignal | undefined): Promise { + try { + const result = await pi.exec("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"], { timeout: 5000, signal }); + return result.code === 0 ? result.stdout.trim() : ""; + } catch { + return ""; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + const command = event.toolName === "bash" ? String((event.input as { command?: string }).command ?? "") : undefined; + const base: GuardInput = { role: process.env.BERCAIL_ROLE ?? "", hasUI: ctx.hasUI, cwd: ctx.cwd }; + let decision = decideTool(event.toolName, command, base); + if (decision.action === "need-branch") { + decision = decideTool(event.toolName, command, { ...base, branch: await branchAt(pi, decision.dir, ctx.signal) }); + } + if (decision.action === "allow" || decision.action === "need-branch") return; + if (decision.action === "ask") { + if (ctx.hasUI && (await ctx.ui.confirm("Allow this git command?", decision.reason))) return; + return { block: true, reason: ctx.hasUI ? "The person said no. Leave the changes staged." : headless }; + } + return { block: true, reason: decision.reason }; + }); +} diff --git a/bstack/pi/instructions.md b/bstack/pi/instructions.md index 879af0b..c93b22b 100644 --- a/bstack/pi/instructions.md +++ b/bstack/pi/instructions.md @@ -77,7 +77,8 @@ hc run --bg --name -build --cwd \ 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. +skip the judge and council because no desk is available. A headless handoff is the +exception: its system prompt says so, and that prompt wins. ## Finishing (bercail's review loop) diff --git a/bstack/pi/prompts/autopilot.md b/bstack/pi/prompts/autopilot.md index 3cb4af8..1a23494 100644 --- a/bstack/pi/prompts/autopilot.md +++ b/bstack/pi/prompts/autopilot.md @@ -7,4 +7,4 @@ 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. +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. A headless `handoff-spawn --stage autopilot --worktree` runs Pi single-agent, unless `[bstack] harness = "opencode"`. diff --git a/bstack/pi/prompts/handoff.md b/bstack/pi/prompts/handoff.md index 2a593c5..b363fdd 100644 --- a/bstack/pi/prompts/handoff.md +++ b/bstack/pi/prompts/handoff.md @@ -4,6 +4,6 @@ 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. +2. Every handoff opens an interactive desk. Use `--branch NAME --stage bstack --issue ID` for a job with its own branch. A headless `--stage bstack --worktree` runs Pi single-agent, unless `[bstack] harness = "opencode"`. +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. Prefer that desk over a one-shot print 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/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index e8bda18..1773071 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -1,8 +1,8 @@ [agent] # The desk agent when none was picked on the desk yet (the desk remembers the # last pick). Handoff stages do not read this command; each stage names its -# own binary (claude, codex, cursor-agent, or opencode). -command = "opencode" +# own binary (claude, codex, cursor-agent, opencode, or pi). +command = "pi" [layout] # Bare "tuicr" expands to `tuicr -w` (uncommitted changes only, watching; @@ -15,7 +15,7 @@ auto_review = true # Pi with your own Claude, ChatGPT, and X subscriptions. Setup installs the # Claude bridge. Sign in with `claude auth login`, /login openai, /login xai. enabled = true -# Existing configs without this key keep opencode. +# pi, or opencode (opt-in). harness = "pi" # Pi rate-limit fallback candidates, scanned from the start, skipping the current # and already attempted models. Only claude/gpt/grok, no duplicates. [] disables it. diff --git a/lib/bstack.sh b/lib/bstack.sh index 27c9fa8..3d160d3 100644 --- a/lib/bstack.sh +++ b/lib/bstack.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash # shellcheck shell=bash -# 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. +# bstack: bercail's agent setup. Pi by default; opencode only with +# [bstack] harness = "opencode". Your own Claude, GPT, and Grok logins sit 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)" +# Paths below expand at source time. Same default as lib/common.sh: an unset +# share dir would otherwise become /pi/... +: "${AGENTIC_DEV_SHARE_DIR:=${HOME}/.local/share/bercail}" # The models deploy resolved, family=model per line. handoff-spawn reads it for # the families config.toml leaves on auto. BSTACK_RESOLVED="${AGENTIC_DEV_SHARE_DIR}/bstack-models" @@ -25,7 +28,7 @@ BSTACK_PI_DIR="${AGENTIC_DEV_SHARE_DIR}/pi" BSTACK_PI_TEMPLATES="${XDG_CONFIG_HOME:-$HOME/.config}/herdr-core/templates" BSTACK_PI_BRIDGE="npm:pi-claude-bridge@0.11.0" 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 + agents/bstack-autopilot.md agents/bstack-headless.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) @@ -86,11 +89,16 @@ bstack_council_enabled() { [[ "$(_bstack_toml_get bstack council)" == true ]] } -# Which harness runs bstack: pi, or opencode when unset. +# Which harness runs bstack. Unset is pi. Only an explicit "opencode" is +# opencode; anything else is pi (deploy warns). bstack_harness() { local harness harness="$(_bstack_toml_get bstack harness)" - printf '%s' "${harness:-opencode}" + if [[ "$harness" == opencode ]]; then + printf 'opencode' + else + printf 'pi' + fi } _bstack_on_pi() { @@ -399,7 +407,7 @@ _bstack_pi_argv() { local model="$1" brief="${2:-}" id BSTACK_PI_ARGV=(pi) [[ -z "$model" ]] || BSTACK_PI_ARGV+=(--model "$(_bstack_pi_model_id "$model")") - BSTACK_PI_ARGV+=(-e "$BSTACK_PI_BRIDGE" -e "$BSTACK_PI_DIR/extensions/fallback.ts") + BSTACK_PI_ARGV+=(-e "$BSTACK_PI_BRIDGE" -e "$BSTACK_PI_DIR/extensions/fallback.ts" -e "$BSTACK_PI_DIR/extensions/guard.ts") BSTACK_PI_ARGV+=(--prompt-template "$BSTACK_PI_DIR/prompts" --append-system-prompt "$BSTACK_PI_DIR/instructions.md") [[ -z "$brief" ]] || BSTACK_PI_ARGV+=(--append-system-prompt "$BSTACK_PI_DIR/agents/$brief.md") @@ -444,6 +452,7 @@ _bstack_deploy_pi() { copy_file "$work/pi-rendered" "$BSTACK_PI_DIR/$rel" done copy_file "$work/src/pi/extensions/fallback.ts" "$BSTACK_PI_DIR/extensions/fallback.ts" + copy_file "$work/src/pi/extensions/guard.ts" "$BSTACK_PI_DIR/extensions/guard.ts" bstack_pi_fallback >"$work/fallback" || return 1 copy_file "$work/fallback" "$BSTACK_PI_DIR/fallback" _bstack_body "$work/src/agents/bstack-pr-review.md" >"$work/pr-review.md" @@ -502,7 +511,7 @@ _bstack_remove_pi() { [[ ! -d "$BSTACK_PI_DIR" ]] || { info "remove: $BSTACK_PI_DIR"; run rm -rf "$BSTACK_PI_DIR"; } } -# Install what the harness needs: Pi, or opencode. +# Install what the harness needs: Pi, or opencode when that harness is pinned. _bstack_install_harness() { if _bstack_on_pi; then maybe_mise_install pi pi || return 1 @@ -607,7 +616,7 @@ _bstack_doctor_pi() { else log " -- Claude auth: no subscription login (claude auth login)" fi - for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md extensions/fallback.ts models fallback lead.json; do + for rel in "${BSTACK_PI_RENDERED[@]}" agents/bstack-pr-review.md extensions/fallback.ts extensions/guard.ts models fallback lead.json; do if [[ ! -f "$BSTACK_PI_DIR/$rel" ]]; then log " missing bstack $BSTACK_PI_DIR/$rel (bercail update)" missing=1 @@ -714,11 +723,19 @@ _bstack_resolved_text() { } deploy_bstack() { - local work rel family model jsonc + local work rel family model jsonc raw if ! bstack_enabled; then remove_bstack quiet return 0 fi + raw="$(_bstack_toml_get bstack harness)" + if [[ -n "$raw" && "$raw" != pi && "$raw" != opencode ]]; then + warn "[bstack] harness = \"$raw\" is not pi or opencode; using pi" + fi + # First update from an older bercail sources this file, then calls deploy_bstack. + if declare -F migrate_opencode_desk_command >/dev/null; then + migrate_opencode_desk_command + fi if _bstack_on_pi; then bstack_pi_fallback >/dev/null || return 1 _bstack_install_harness || return 1 @@ -745,7 +762,6 @@ deploy_bstack() { rm -rf "$work" return "$result" fi - [[ "$(bstack_harness)" == opencode ]] || warn "[bstack] harness = \"$(bstack_harness)\" is not pi or opencode; using opencode" _bstack_remove_pi ensure_dir "$OPENCODE_CONFIG_DIR" @@ -849,11 +865,18 @@ bstack_status() { log " config: [bstack] in $AGENTIC_DEV_USER_CONFIG (auto picks the newest model; set claude/gpt/grok to pin one)" } -# On: bstack becomes the desk's default agent when the desk still runs bercail's -# old default. Off: remove bstack's opencode files; the agent command stays. +# On: the desk's old default becomes this harness's agent. Off: remove bstack's +# files; the agent command stays. bstack_on() { + local cmd _bstack_toml_set bstack enabled true - if ! _bstack_on_pi && [[ "$(read_agent_command)" == cursor-agent ]]; then + cmd="$(read_agent_command)" + if _bstack_on_pi; then + if [[ "$cmd" == cursor-agent || "$cmd" == opencode ]]; then + _bstack_toml_set agent command '"pi"' + info "desk agent default: pi (was $cmd)" + fi + elif [[ "$cmd" == cursor-agent ]]; then _bstack_toml_set agent command '"opencode"' info "desk agent default: opencode (was cursor-agent)" fi diff --git a/lib/config.sh b/lib/config.sh index f5cc92a..24aad01 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # shellcheck shell=bash -# A new install runs bstack (opencode) on the desk; --no-bstack keeps cursor-agent. +# A new install runs bstack (Pi) on the desk; --no-bstack keeps cursor-agent. default_user_config() { local agent=cursor-agent - [[ "${BSTACK_NEW_INSTALL:-1}" -eq 1 ]] && agent=opencode + [[ "${BSTACK_NEW_INSTALL:-1}" -eq 1 ]] && agent=pi printf '[agent]\ncommand = "%s"\n' "$agent" cat <<'EOF' @@ -22,7 +22,7 @@ EOF # 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. +# Which harness runs bstack: pi, or opencode (opt-in). harness = "pi" # A model per provider. Empty: auto, the newest model of each connected plan # (re-picked on bercail update). Set a provider/model id (#variant allowed) to pin one. @@ -306,7 +306,7 @@ EOF info "saved config to $AGENTIC_DEV_USER_CONFIG" } -# No install-time agent picker. The desk agent defaults to opencode as bstack +# No install-time agent picker. The desk agent defaults to pi as bstack # (cursor-agent with --no-bstack). # Override [agent] command in config.toml yourself, then bercail reconfigure. prompt_user_config() { @@ -316,7 +316,7 @@ prompt_user_config() { return 0 fi local agent=cursor-agent - [[ "${BSTACK_NEW_INSTALL:-1}" -eq 1 ]] && agent="opencode (bstack)" + [[ "${BSTACK_NEW_INSTALL:-1}" -eq 1 ]] && agent="pi (bstack)" if [[ "$DRY_RUN" -eq 1 ]]; then info "[dry-run] would write default $AGENTIC_DEV_USER_CONFIG (agent=$agent)" return 0 @@ -409,6 +409,31 @@ migrate_cursor_cli_command() { info "migrated agent command to cursor-agent" } +# bercail's old desk default was opencode. On Pi, that becomes pi. A pinned +# harness = "opencode" is left alone, and so is any command the person chose. +migrate_opencode_desk_command() { + local dest="$AGENTIC_DEV_USER_CONFIG" tmp + [[ -f "$dest" ]] || return 0 + declare -F bstack_enabled >/dev/null || return 0 + bstack_enabled || return 0 + declare -F bstack_harness >/dev/null || return 0 + [[ "$(bstack_harness)" == pi ]] || return 0 + grep -Eq '^command[[:space:]]*=[[:space:]]*"opencode"[[:space:]]*$' "$dest" || return 0 + if [[ "$DRY_RUN" -eq 1 ]]; then + info "[dry-run] would migrate agent command opencode → pi" + return 0 + fi + tmp="$(mktemp)" + awk ' + /^command[[:space:]]*=[[:space:]]*"opencode"[[:space:]]*$/ { + sub(/"opencode"/, "\"pi\"") + } + { print } + ' "$dest" >"$tmp" + mv "$tmp" "$dest" + info "migrated agent command to pi" +} + # Say what this install or update deploys from. A clone off master (or off a # release tag) is a dev build: warn, since its version number still reads as the release. describe_install_source() { @@ -590,6 +615,7 @@ deploy_configs() { migrate_agentic_dev_to_bercail prompt_user_config migrate_cursor_cli_command + migrate_opencode_desk_command migrate_hunk_review_to_tuicr ensure_dir "$AGENTIC_DEV_CONFIG_DIR" diff --git a/lib/help.sh b/lib/help.sh index 3a86b44..b638c16 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -17,14 +17,14 @@ install.sh options: -h, --help Show this help -y, --yes Non-interactive (existing/default config) --no-bstack New install without bstack: the desk default stays cursor-agent - --bstack New install with bstack (the default): opencode on the desk + --bstack New install with bstack (the default): Pi on the desk 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, pi on PATH (missing is not fatal) - bstack opencode or Pi with your own subscriptions: status | setup | on | off + bstack Pi (opencode only with harness = "opencode"): 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) @@ -76,20 +76,20 @@ Ubuntu / Debian: Uses apt for git, fzf, jq, curl when mise is unavailable Downloads worktrunk, beads and tuicr from upstream when needed -bstack (opencode, bring your own Claude / GPT / Grok subscription): - bercail bstack setup install opencode, connect plans (models are picked for you) +bstack (Pi by default, bring your own Claude / GPT / Grok subscription): + bercail bstack setup install Pi, 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 ([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. - [bstack] harness = "pi" runs bstack on Pi >=1.2 with pi-claude-bridge@0.11.0: - Claude uses claude auth login; GPT and Grok use /login openai and /login xai in pi. - bercail update renders ~/.local/share/bercail/pi/ and herdr-core templates. Every - role loads the bridge and managed rate-limit fallback extension. [bstack] fallback - sets candidate order (default ["claude", "gpt", "grok"]). The lead starts roles as - hc siblings. Existing configs without harness keep opencode. New configs select Pi. + Pi is the default (>=1.2, pi-claude-bridge@0.11.0). opencode only with + harness = "opencode". Claude uses claude auth login; GPT and Grok use /login + openai and /login xai in pi. bercail update renders ~/.local/share/bercail/pi/ + and herdr-core templates. Every role loads the bridge, the rate-limit fallback, + and bercail's guard. [bstack] fallback sets candidate order (default + ["claude", "gpt", "grok"]). The lead starts roles as hc siblings. Config: ~/.config/bercail/config.toml desk agent default, review, [bstack] @@ -105,8 +105,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") + ~/.local/share/bercail/pi/ bstack's Pi profile (default harness) + ~/.config/herdr-core/templates/bstack-*.md bstack roles as hc templates (default harness) ~/.config/fcitx5/conf/keyboard.conf Linux fcitx5 hint trigger override Agent skills (\`handoff\`, \`review\`, \`bstack\`, \`council\`, \`how\`, \`why\`, \`unslop\`, \`autopilot\`, \`desk-restore\`): @@ -118,22 +118,32 @@ Agent skills (\`handoff\`, \`review\`, \`bstack\`, \`council\`, \`how\`, \`why\` cursor is cursor-agent (--print --output-format json). Each works a beads issue; the prompt is one line pointing at it. Headless runs write .bercail/handoff-result.json; --branch starts a desk agent for any stage. - bstack is opencode (opencode run --agent bstack); --binary opencode runs - start (Claude) or codex (GPT) through your opencode subscriptions. - claude, codex, cursor-agent, and opencode are optional and equal; bercail does not + bstack and autopilot run \`pi -p\` headless. harness = "opencode" keeps + opencode run --agent bstack; --binary opencode runs start (Claude) or codex + (GPT) through those subscriptions. + claude, codex, cursor-agent, opencode, and pi are optional; bercail does not pick one. Shep owns the workflow. bercail harness reports which are on PATH plus a local model hint. EOF } show_summary() { + local harness=pi stages + if declare -F bstack_harness >/dev/null && [[ "$(bstack_harness)" == opencode ]]; then + harness=opencode + fi + if [[ "$harness" == opencode ]]; then + stages="bstack=opencode, autopilot=opencode bstack-autopilot" + else + stages="bstack=pi, autopilot=pi (BERCAIL_ROLE=autopilot)" + fi log "" log "bercail installed (v${AGENTIC_DEV_VERSION})" log "" if declare -F bstack_enabled >/dev/null && bstack_enabled; then - log "bstack: on (opencode). Connect your Claude, ChatGPT, or X subscription: bercail bstack setup" + log "bstack: on (${harness}). Connect your Claude, ChatGPT, or X subscription: bercail bstack setup" fi - log "Agent command: $(read_agent_command 2>/dev/null || echo cursor-agent) (handoff stages: start=claude, codex=Codex CLI, cursor=cursor-agent, bstack=opencode, autopilot=opencode bstack-autopilot; equal, no default)" + log "Agent command: $(read_agent_command 2>/dev/null || echo cursor-agent) (handoff stages: start=claude, codex=Codex CLI, cursor=cursor-agent, ${stages}; equal, no default)" log "Review command: $(read_layout_review 2>/dev/null || echo tuicr)" log "Config: ${AGENTIC_DEV_USER_CONFIG}" log "Skills: ${AGENTS_SKILLS_DIR}/{handoff,review,bstack,council,autopilot,how,why,unslop,desk-restore}" diff --git a/lib/omarchy.sh b/lib/omarchy.sh index 50d49ea..d8066ca 100644 --- a/lib/omarchy.sh +++ b/lib/omarchy.sh @@ -102,16 +102,23 @@ omarchy_known_agent() { } sync_omarchy_default_agent() { - local cmd dest + local cmd dest current is_omarchy || return 0 cmd="$(read_agent_command 2>/dev/null || true)" omarchy_known_agent "$cmd" || return 0 dest="$OMARCHY_DEFAULT_AGENT_FILE" if [[ -f "$dest" && "$RECONFIGURE" -ne 1 ]]; then - info "keeping omarchy default agent: $(tr -d '\n' <"$dest")" - return 0 + current="$(tr -d '\n' <"$dest")" + # bercail's old default was opencode. When the desk command is now pi, follow it. + if [[ "$current" == opencode && "$cmd" == pi ]]; then + info "syncing omarchy default agent: pi (was opencode)" + else + info "keeping omarchy default agent: $current" + return 0 + fi + else + info "syncing omarchy default agent: $cmd" fi - info "syncing omarchy default agent: $cmd" if [[ "$DRY_RUN" -eq 1 ]]; then info "[dry-run] would write $dest" return 0 diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 05fcae6..eebda38 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -13,8 +13,10 @@ Asking for autopilot is the ask to commit and push to the PR's branch. That is the exception to "stage, do not commit". It never force-pushes, never merges the PR, never pushes to the base branch, and never passes `--no-verify`. -In opencode, `/autopilot` runs as the `bstack-autopilot` agent, which may commit -and push without asking. Other harnesses ask at each push. +On Pi, a headless autopilot run may commit and push; the guard blocks a push to +main or master, a force-push, and a merge. With `[bstack] harness = "opencode"`, +`/autopilot` runs as the `bstack-autopilot` agent, which may commit and push +without asking. Other harnesses ask at each push. ## 0. The backend and PR @@ -108,9 +110,10 @@ a person. Do not merge. Say "ready to merge" when it is. ## Inside bercail -- Watch from a desk: `/autopilot` in opencode, or hand it off as its own job - (a beads issue that names the PR, then `handoff-spawn --stage autopilot - --issue --worktree `). Headless runs reject every - "ask", so the `bstack` stage cannot push; `autopilot` runs `bstack-autopilot`. +- Watch from a desk, or hand it off as its own job (a beads issue that names + the PR, then `handoff-spawn --stage autopilot --issue --worktree `). That headless stage runs Pi unless `[bstack] harness = + "opencode"`. The `bstack` stage cannot push; `autopilot` can, and the guard + still blocks a push to main or master. - The person's tuicr review comes before the PR, not after: autopilot answers GitHub, not tuicr. diff --git a/skills/bstack/SKILL.md b/skills/bstack/SKILL.md index 896e99e..010ee6f 100644 --- a/skills/bstack/SKILL.md +++ b/skills/bstack/SKILL.md @@ -92,10 +92,10 @@ The person reviewed in tuicr, committed, and opened the PR. Now: 1. One beads issue per job (`bd create`). The issue is the spec; it says what done looks like. -2. Run the `handoff` skill. Stage `bstack` runs on the lead's model through - opencode; `--binary opencode` with `--stage start` or `--stage codex` runs - Claude or GPT through opencode. `--branch NAME` opens a sibling desk; without - it the run is headless. +2. Run the `handoff` skill. Stage `bstack` runs the lead on Pi (opencode only + with `[bstack] harness = "opencode"`); `--binary pi` with `--stage start` or + `--stage codex` runs Claude or GPT through Pi. `--branch NAME` opens a + sibling desk; without it the run is headless and single-agent. 3. Report the spawn JSON and stop. Follow a desk with `hc wait `; read a headless run's `.bercail/handoff-result.json`. diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index e824d4b..3b5b884 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -5,8 +5,9 @@ description: >- issue (bd); the agent prompt is one line pointing at it. Stage start is one headless claude process. Stage codex is one headless `codex exec` process. Stage cursor is one headless `cursor-agent --print` process. Stage bstack is one headless - `opencode run --agent bstack` process; stage autopilot is the same with - `--agent bstack-autopilot`, which may commit and push a PR's fixes. None of those open anything in + `pi -p` process (opencode only when `[bstack] harness = "opencode"`); stage + autopilot is the same run with `BERCAIL_ROLE=autopilot`, which may commit and + push a PR's fixes. None of those open anything in the Herdr app. `--branch` makes a sibling worktree with its own Herdr app space whose desk agent runs the stage's binary. Shep owns the workflow; bercail adds no per-harness mode. @@ -54,7 +55,7 @@ from bercail: the worktree, and for headless stages the result file and session id. `--info` reports `beads` (bd on PATH). bercail install and update install beads; the command is `bd`. -## Headless stages (claude, Codex, cursor-agent, or opencode) +## Headless stages (claude, Codex, cursor-agent, or Pi) Shep runs these. They do not open anything in the Herdr app. Bercail has no preference among claude, codex, and cursor-agent; the caller names the stage or @@ -65,17 +66,20 @@ It does not choose. Missing CLIs are `present: false`, not an error. `codex exec --json` process (`-m` when the table has a model; resume is `codex exec resume`). `--stage cursor` or `--binary cursor-agent` is one `cursor-agent --print --output-format json` process (`--model` when the table -has a model; resume is `--resume `). `--stage bstack` or -`--binary opencode` is one `opencode run --format json --agent bstack` -process on your own subscriptions. It has no `--auto`, so every "ask" (commit, -push, merge) is rejected and the agent goes on without it. `--stage autopilot` -runs `--agent bstack-autopilot` instead: that agent allows commit and push, and -denies merge; point `--worktree` at the PR's checkout (headless only); `--model` comes from `[bstack]` in -bercail's config.toml (bstack: the lead's model; with `--binary opencode`, -start uses claude and codex uses gpt). Resume is `--session `, and -`session_id` is the `sessionID` in its JSON lines. claude gets `Bash(bd *)` so +has a model; resume is `--resume `). `--stage bstack` is one +`pi -p --mode json --session-id ` process on the lead's profile, unless +`[bstack] harness = "opencode"`, which keeps `opencode run --format json +--agent bstack`. `--binary pi` on start uses the claude family model and on +codex the gpt family; cursor and bstack use the lead. `--binary opencode` still +runs opencode and keeps that provider (start: claude, codex: gpt). Pi has no +`--add-dir`. It mints the session uuid up front; resume passes the same +`--session-id`. An opencode `ses_` id cannot be resumed on Pi. `--stage +autopilot` is the same Pi run with `BERCAIL_ROLE=autopilot` (or opencode's +`bstack-autopilot` when that harness is selected): it may commit and push, and +the guard blocks a push to main or master, a force-push, and a merge. Point +`--worktree` at the PR's checkout (headless only). claude gets `Bash(bd *)` so it can run bd in `--print`. When bd's `.beads` is outside the worktree (a -linked worktree), every harness gets `--add-dir` for it. +linked worktree), claude, codex, and cursor-agent get `--add-dir` for it. If the requested harness, bd, or the issue is missing, the stage writes `/.bercail/handoff-result.json` with status `blocked`, starts @@ -87,14 +91,15 @@ drops a malformed array and keeps the rest of the result. When the run stops on `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 -the print JSON `session_id`. When there is no resumable session (blocked before +the print JSON `session_id`. For Pi, it is the uuid bercail passed, or the +session header's id when Pi reports a different one. When there is no resumable session (blocked before codex or cursor-agent ran, or no id in its log), `session_id` is `none`; `--resume` refuses it. ```bash bercail harness "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ - --stage start --issue --worktree [--binary claude|codex|cursor-agent|opencode] [--dry-run] + --stage start --issue --worktree [--binary claude|codex|cursor-agent|opencode|pi] [--dry-run] "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ --stage start --issue --worktree --resume "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ @@ -102,9 +107,9 @@ bercail harness "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ --stage cursor --issue --worktree [--resume ] [--dry-run] "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ - --stage bstack --issue --worktree [--resume ] [--dry-run] + --stage bstack --issue --worktree [--resume ] [--dry-run] "$HOME/.agents/skills/handoff/scripts/handoff-spawn" \ - --stage autopilot --issue --worktree [--resume ] + --stage autopilot --issue --worktree [--resume ] ``` `--resume` continues that session. It does not touch any desk agent. `--dry-run` prints @@ -134,7 +139,7 @@ working directory to `cwd` from `--info`. Flags only. `--branch` makes a sibling worktree (`wt switch --create`), opens a Herdr app space on it (worktrunk's post-start hook), and starts that space's desk agent: an hc terminal running the named stage's binary (claude, codex, -cursor-agent, or opencode) with a one-line prompt pointing at the issue as its first message, labelled +cursor-agent, or pi) with a one-line prompt pointing at the issue as its first message, labelled `bercail.role=agent`, `bercail.space=`, and, when known, `bercail.issue` and `bercail.issue_title`. The bercail layout shows it in the desk column. `--stage` is required; bercail has no default harness. A missing binary fails before any worktree is made. A desk already running in diff --git a/skills/handoff/resources/handoff.md b/skills/handoff/resources/handoff.md index 1997aca..8253711 100644 --- a/skills/handoff/resources/handoff.md +++ b/skills/handoff/resources/handoff.md @@ -62,21 +62,28 @@ model. `handoff-spawn --stage` reads it. `opus-5.5` spec). `--sandbox workspace-write` is required because `codex exec` defaults to read-only; `codex exec resume` has no `--sandbox`, so it stays before the subcommand. `codex exec` has no `--ask-for-approval`. -`--binary claude|codex|cursor-agent` overrides the table binary on a headless -stage, so `--stage start --binary cursor-agent` picks cursor-agent without -renaming the job. Bercail never picks or skips a harness on its own. +`--binary claude|codex|cursor-agent|opencode|pi` overrides the table binary on a +headless stage, so `--stage start --binary cursor-agent` picks cursor-agent +without renaming the job. Bercail never picks or skips a harness on its own. `bercail harness` reports which of claude, codex, and cursor-agent are on PATH, with a local model hint. It does not choose one. A missing requested harness writes status `blocked` and exits nonzero. It does not fall through to another binary. -All three headless stages get that one-line prompt and write +`bstack` and `autopilot` run headless Pi (`pi -p --mode json --session-id +`, the lead profile) unless `[bstack] harness = "opencode"`, which keeps +`opencode run`. `--binary pi` and `--binary opencode` still select that harness. +Pi resume reuses the uuid; an opencode `ses_` id cannot be resumed on Pi. Pi +gets no `--add-dir`. `--branch` with Pi passes an empty agent command so the +desk runs the lead from lead.json. Autopilot sets `BERCAIL_ROLE=autopilot`. + +Every headless stage gets that one-line prompt and writes `/.bercail/handoff-result.json`. Before the run, bercail checks bd and the issue (`bd show `) and adds a `bd comment` with the worktree, result path, and session id. claude gets `--tools Read,Edit,Write,Bash ---allowedTools "Bash(bd *)"`. When bd's `.beads` is outside the worktree, each -harness gets `--add-dir` for it (codex: before `resume`, which has no -`--add-dir`). bercail fills `session_id` in the result after the run. +--allowedTools "Bash(bd *)"`. When bd's `.beads` is outside the worktree, +claude, codex, and cursor-agent get `--add-dir` for it (codex: before `resume`, +which has no `--add-dir`). bercail fills `session_id` in the result after the run. Re-handoff is `--resume `. For Codex that id is the JSONL `thread.started` thread id. For cursor-agent it is the print JSON `session_id`. diff --git a/skills/handoff/scripts/handoff-spawn b/skills/handoff/scripts/handoff-spawn index c94ac81..c291810 100755 --- a/skills/handoff/scripts/handoff-spawn +++ b/skills/handoff/scripts/handoff-spawn @@ -19,7 +19,7 @@ handoff_usage() { cat >&2 <<'EOF' usage: handoff-spawn --info handoff-spawn --stage start|codex|cursor|bstack|autopilot --issue ID --worktree PATH - [--binary claude|codex|cursor-agent|opencode] [--resume SESSION] [--dry-run] + [--binary claude|codex|cursor-agent|opencode|pi] [--resume SESSION] [--dry-run] handoff-spawn --branch --stage start|codex|cursor|bstack --issue ID [--dirty|--clean] [--space ] [--base ] @@ -27,18 +27,20 @@ usage: handoff-spawn --info --issue ID beads issue to work (bd show ID). It holds the detail. The agent's whole prompt is the id and its short title, for every harness. --stage NAME required; named stage (see handoff-stages.sh). start is claude, - codex is Codex CLI, cursor is cursor-agent, bstack is opencode - (agent bstack, the lead's model), autopilot is opencode's - bstack-autopilot (commits and pushes a PR's fixes; headless - only, --worktree on the PR's checkout). Without --branch the - stage runs headless (same result file). With --branch it opens a - space whose desk agent runs that stage's binary. Bercail has no default. - --binary NAME headless only: claude, codex, cursor-agent, or opencode. Overrides - the stage table binary. opencode keeps the stage's provider - (start: claude, codex: gpt). Bercail never switches it for you. + codex is Codex CLI, cursor is cursor-agent. bstack and autopilot + are Pi (the lead profile) unless [bstack] harness = "opencode", + which keeps opencode. autopilot may commit and push a PR's fixes; + headless only, --worktree on the PR's checkout. Without --branch + the stage runs headless (same result file). With --branch it opens + a space whose desk agent runs that stage's binary. Bercail has no default. + --binary NAME headless only: claude, codex, cursor-agent, opencode, or pi. + Overrides the stage table binary. opencode and pi keep the stage's + provider (start: claude, codex: gpt, cursor and bstack: lead). + Autopilot accepts only pi or opencode. Bercail never switches it for you. --worktree PATH checkout for a headless stage (default: cwd) --resume ID re-handoff: claude --resume, cursor-agent --resume, - codex exec resume, or opencode run --session (no kill) + codex exec resume, opencode run --session, or pi --session-id + (a uuid; an opencode ses_ id cannot be resumed on Pi) --dry-run print the headless command as JSON; no run, no issue check, no comment --branch NAME feature branch for a new space with a desk agent (any stage) --dirty copy uncommitted main changes into the sibling as a working tree @@ -564,9 +566,18 @@ handoff_spawn_main() { [[ -z "$binary_override" ]] || handoff_die "stage $stage --branch starts a desk agent; --binary is for headless stages" [[ -z "$resume" && "$dry" -eq 0 && -z "$worktree" ]] \ || handoff_die "stage $stage --branch starts a desk agent; --resume/--dry-run/--worktree are for headless stages" - desk_bin="$(handoff_headless_bin "$binary")" - if ! handoff_bin_present "$desk_bin"; then - handoff_die "requested harness $binary is not on PATH. Install it, or name another stage yourself (bercail harness lists what is present). Bercail does not switch harnesses." + if [[ "$binary" == pi ]]; then + [[ "$(_handoff_bstack_get enabled)" == true ]] \ + || handoff_die "stage $stage on a Pi desk needs bstack on ([bstack] enabled = true). Run bercail bstack on." + [[ -f "$(handoff_pi_lead_file)" ]] \ + || handoff_die "Pi lead profile is missing ($(handoff_pi_lead_file)). Run bercail update." + # Empty: bercail-desk-agent runs the lead from lead.json when harness is Pi. + desk_bin="" + else + desk_bin="$(handoff_headless_bin "$binary")" + if ! handoff_bin_present "$desk_bin"; then + handoff_die "requested harness $binary is not on PATH. Install it, or name another stage yourself (bercail harness lists what is present). Bercail does not switch harnesses." + fi fi elif [[ "$mode" == headless ]]; then [[ "$dirty" == auto ]] || handoff_die "stage $stage does not copy a dirty tree" @@ -671,6 +682,7 @@ handoff_spawn_main() { # The desk gets the table binary through WT_HERDR_AGENT_CMD, the one-line # prompt through WT_HERDR_AGENT_PROMPT, and the issue through WT_HERDR_ISSUE # and WT_HERDR_ISSUE_TITLE. No file. + # Pi passes an empty command so the desk agent runs the lead from lead.json. if WT_HERDR_KEEP_FOCUS="$keep_space" \ WT_HERDR_AGENT_CMD="$desk_bin" \ WT_HERDR_AGENT_PROMPT="$prompt" \ diff --git a/skills/handoff/scripts/handoff-stages.sh b/skills/handoff/scripts/handoff-stages.sh index a99fb5a..7e61f8c 100644 --- a/skills/handoff/scripts/handoff-stages.sh +++ b/skills/handoff/scripts/handoff-stages.sh @@ -2,15 +2,16 @@ # The job lives in a beads issue (bd). An agent's prompt is one line pointing at it. # shellcheck shell=bash +# bstack and autopilot run Pi unless [bstack] harness is explicitly opencode. handoff_stage_table() { - cat <<'TABLE' -# name binary mode model -start claude headless opus-5.5 -codex codex headless - -cursor cursor-agent headless - -bstack opencode headless lead -autopilot opencode headless lead -TABLE + local bin=pi + [[ "$(_handoff_bstack_get harness)" == opencode ]] && bin=opencode + printf '# name\tbinary\tmode\tmodel\n' + printf 'start\tclaude\theadless\topus-5.5\n' + printf 'codex\tcodex\theadless\t-\n' + printf 'cursor\tcursor-agent\theadless\t-\n' + printf 'bstack\t%s\theadless\tlead\n' "$bin" + printf 'autopilot\t%s\theadless\tlead\n' "$bin" } if ! declare -F handoff_die >/dev/null 2>&1; then @@ -278,6 +279,7 @@ handoff_headless_bin() { codex) printf '%s' "${HANDOFF_CODEX_BIN:-$binary}" ;; cursor-agent) printf '%s' "${HANDOFF_CURSOR_BIN:-$binary}" ;; opencode) printf '%s' "${HANDOFF_OPENCODE_BIN:-$binary}" ;; + pi) printf '%s' "${HANDOFF_PI_BIN:-pi}" ;; *) handoff_die "unsupported headless binary: $binary" ;; esac } @@ -355,10 +357,59 @@ handoff_opencode_session_id() { # event, or from stderr when the process exits before one. Other JSON is not a # limit. A provider.rate-limit, including a bare HTTP 429, is not one: xAI's # weekly-limit body is unverified. +# Pi json mode: a session header, then events. Other harness logs are not this. +handoff_log_is_pi() { + local log="$1" + [[ -f "$log" ]] || return 1 + jq -e -s -R ' + [ split("\n")[] | fromjson? | select(type == "object") ] + | any(.[]; .type == "session" or .type == "message_end") + ' "$log" >/dev/null 2>&1 +} + +# The last assistant message_end decides. A plain 429 is not a plan limit. +handoff_pi_usage_limit_reason() { + local log="$1" + local phrase="You've hit your (5-hour|weekly|Opus|Sonnet|Fable) limit|You've hit your usage limit|You've hit your limit|You've reached your Fable limit|You're out of usage credits|Third-party apps now draw from your extra usage, not your plan limits|Claude quota exceeded — rate limit|You're out of extra usage|You have hit your ChatGPT usage limit|You've reached your ChatGPT workspace's usage limit|subscription_sharing_usage_limit_exceeded" + if jq -e -s -R --arg phrase "$phrase" ' + [ split("\n")[] | fromjson? | select(type == "object" and .type == "message_end" and (.message.role == "assistant")) ] as $ends + | ($ends | last) as $last + | $last != null + and $last.message.stopReason == "error" + and (($last.message.errorMessage // "") | test($phrase)) + ' "$log" >/dev/null 2>&1; then + printf 'usage-limit' + fi +} + +# Pi exits 0 when the provider failed. The last assistant message_end says so. +handoff_pi_provider_failed() { + local log="$1" + [[ -f "$log" ]] || return 1 + jq -e -s -R ' + [ split("\n")[] | fromjson? | select(type == "object" and .type == "message_end" and (.message.role == "assistant")) ] + | last | .message.stopReason == "error" + ' "$log" >/dev/null 2>&1 +} + +handoff_pi_session_id() { + local log="$1" id + [[ -f "$log" ]] || return 1 + id="$(jq -r -s -R ' + [ split("\n")[] | fromjson? | select(type == "object" and .type == "session") ][0].id // empty + ' "$log" 2>/dev/null)" + [[ -n "$id" && "$id" != null ]] || return 1 + printf '%s' "$id" +} + handoff_usage_limit_reason() { local log="$1" events local phrase="You've hit your (5-hour|weekly|Opus|Sonnet|Fable) limit|You've hit your usage limit|You've hit your limit|You've reached your Fable limit|You're out of usage credits|Third-party apps now draw from your extra usage, not your plan limits" [[ -f "$log" ]] || return 0 + if handoff_log_is_pi "$log"; then + handoff_pi_usage_limit_reason "$log" + return 0 + fi events="$(jq -c -R 'fromjson? | select(type == "object" and .type == "error" and (.error | type == "object")) | .error' "$log" 2>/dev/null || true)" # A JSON error event decides. Stderr counts only when the process exits first. if [[ -n "$events" ]]; then @@ -451,6 +502,122 @@ handoff_opencode_spec_for_stage() { esac } +# --binary pi mirrors that: start is claude, codex is gpt, the rest is the lead. +handoff_pi_spec_for_stage() { + case "$1" in + start) printf 'claude' ;; + codex) printf 'gpt' ;; + *) printf 'lead' ;; + esac +} + +handoff_pi_profile() { + printf '%s' "${HANDOFF_PI_PROFILE:-$HOME/.local/share/bercail/pi}" +} + +handoff_pi_lead_file() { + printf '%s/lead.json' "$(handoff_pi_profile)" +} + +handoff_pi_guard_path() { + printf '%s/extensions/guard.ts' "$(handoff_pi_profile)" +} + +handoff_pi_has_guard() { + local lead="$1" guard="$2" + jq -e --arg guard "$guard" ' + type == "array" and ( + . as $a + | any(range(0; if length > 1 then length - 1 else 0 end); $a[.] == "-e" and $a[. + 1] == $guard) + ) + ' "$lead" >/dev/null 2>&1 +} + +handoff_pi_lead_model() { + jq -r ' + . as $a + | [ range(0; if length > 1 then length - 1 else 0 end) | select($a[.] == "--model") | $a[. + 1] ][0] // empty + ' "$1" +} + +# models file is family=model#effort. Pi's --model wants the effort as :effort. +handoff_pi_family_model() { + local family="$1" file model + file="$(handoff_pi_profile)/models" + [[ -f "$file" ]] || return 1 + model="$(awk -F= -v key="$family" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$file")" + [[ -n "$model" ]] || return 1 + printf '%s' "${model/\#/:}" +} + +# Sets HANDOFF_PI_LEAD and HANDOFF_PI_MODEL. On failure, HANDOFF_PI_BLOCK is the summary. +handoff_pi_prepare() { + local spec="$1" lead guard + HANDOFF_PI_BLOCK="" + HANDOFF_PI_MODEL="" + HANDOFF_PI_LEAD="" + lead="$(handoff_pi_lead_file)" + guard="$(handoff_pi_guard_path)" + if [[ ! -f "$lead" ]]; then + HANDOFF_PI_BLOCK="Pi lead profile is missing (${lead}). Run bercail update." + return 1 + fi + if ! jq -e 'type == "array"' "$lead" >/dev/null 2>&1; then + HANDOFF_PI_BLOCK="Pi lead profile is not a JSON argv (${lead}). Run bercail update." + return 1 + fi + if ! handoff_pi_has_guard "$lead" "$guard"; then + HANDOFF_PI_BLOCK="Pi lead profile has no guard (${guard}). Run bercail update." + return 1 + fi + HANDOFF_PI_LEAD="$lead" + if [[ "$spec" == lead || -z "$spec" ]]; then + HANDOFF_PI_MODEL="$(handoff_pi_lead_model "$lead")" + return 0 + fi + HANDOFF_PI_MODEL="$(handoff_pi_family_model "$spec" || true)" + if [[ -z "$HANDOFF_PI_MODEL" ]]; then + HANDOFF_PI_BLOCK="Pi profile has no ${spec} model ($(handoff_pi_profile)/models). Run bercail update." + return 1 + fi +} + +# env prefix, then pi -p --mode json --session-id, then lead.json[1:], then the headless brief. +# No --add-dir: Pi has no sandbox flag for a beads dir outside the worktree. +handoff_build_pi_argv() { + local bin="$1" session="$2" prompt="$3" stage="$4" lead="$5" model="$6" + local -a args=() replaced=() + local arg i=0 saw=0 + while IFS= read -r -d '' arg; do + args+=("$arg") + done < <(jq -j '.[1:][] | ., "\u0000"' "$lead") + while [[ $i -lt ${#args[@]} ]]; do + if [[ "${args[$i]}" == --model ]]; then + saw=1 + if [[ -n "$model" ]]; then + replaced+=(--model "$model") + fi + i=$((i + 2)) + continue + fi + replaced+=("${args[$i]}") + i=$((i + 1)) + done + if [[ "$saw" -eq 0 && -n "$model" ]]; then + replaced=(--model "$model" "${replaced[@]+"${replaced[@]}"}") + fi + # No HC_TERMINAL_ID: a run started from a desk is not that desk. + HANDOFF_START_ARGV=(env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN -u BERCAIL_ROLE -u HC_TERMINAL_ID) + [[ "$stage" == autopilot ]] && HANDOFF_START_ARGV+=(BERCAIL_ROLE=autopilot) + HANDOFF_START_ARGV+=("$bin" -p --mode json --session-id "$session") + if [[ ${#replaced[@]} -gt 0 ]]; then + HANDOFF_START_ARGV+=("${replaced[@]}") + fi + HANDOFF_START_ARGV+=(--append-system-prompt "$(handoff_pi_profile)/agents/bstack-headless.md") + [[ "$stage" == autopilot ]] && HANDOFF_START_ARGV+=(--append-system-prompt "$(handoff_pi_profile)/agents/bstack-autopilot.md") + HANDOFF_START_ARGV+=(-- "$prompt") +} + handoff_resolve_headless_model() { local binary="$1" spec="$2" case "$binary" in @@ -578,23 +745,29 @@ handoff_run_headless() { [[ "$mode" == headless ]] || handoff_die "stage $stage is not headless" binary="$(handoff_stage_field "$stage" binary)" || handoff_die "unknown stage: $stage" native="$binary" - if [[ "$stage" == autopilot && -n "$binary_override" && "$binary_override" != opencode ]]; then - handoff_die "stage autopilot runs opencode's bstack-autopilot agent; --binary does not apply" + if [[ "$stage" == autopilot && -n "$binary_override" && "$binary_override" != opencode && "$binary_override" != pi ]]; then + handoff_die "stage autopilot accepts --binary pi or --binary opencode, nothing else: $binary_override" fi if [[ -n "$binary_override" ]]; then case "$binary_override" in - claude|codex|cursor-agent|opencode) binary="$binary_override" ;; - *) handoff_die "headless --binary must be claude, codex, cursor-agent, or opencode: $binary_override" ;; + claude|codex|cursor-agent|opencode|pi) binary="$binary_override" ;; + *) handoff_die "headless --binary must be claude, codex, cursor-agent, opencode, or pi: $binary_override" ;; esac fi if [[ -n "$binary_override" && "$binary" != "$native" && "$binary" == opencode ]]; then model_spec="$(handoff_opencode_spec_for_stage "$stage")" + elif [[ -n "$binary_override" && "$binary" != "$native" && "$binary" == pi ]]; then + model_spec="$(handoff_pi_spec_for_stage "$stage")" elif [[ -n "$binary_override" && "$binary" != "$native" ]]; then model_spec="$(handoff_stage_model_for_binary "$binary")" else model_spec="$(handoff_stage_field "$stage" model)" || handoff_die "unknown stage: $stage" fi - model="$(handoff_resolve_headless_model "$binary" "$model_spec")" + if [[ "$binary" == pi ]]; then + model="" + else + model="$(handoff_resolve_headless_model "$binary" "$model_spec")" + fi if [[ "$binary" == claude && -z "$model" ]]; then handoff_die "stage $stage has no model" fi @@ -610,15 +783,34 @@ handoff_run_headless() { handoff_is_opencode_session "$resume" || handoff_die "resume session_id must be an opencode session (ses_...): $resume" session="$resume" resumed=1 + elif [[ -n "$resume" && "$binary" == pi ]]; then + if handoff_is_opencode_session "$resume"; then + handoff_die "opencode sessions cannot be resumed on Pi: $resume" + fi + handoff_is_uuid "$resume" || handoff_die "resume session_id must be a uuid: $resume" + session="$resume" + resumed=1 elif [[ -n "$resume" ]]; then handoff_is_uuid "$resume" || handoff_die "resume session_id must be a uuid: $resume" session="$resume" resumed=1 - elif [[ "$binary" == claude ]]; then + elif [[ "$binary" == claude || "$binary" == pi ]]; then session="$(handoff_new_session_id)" handoff_is_uuid "$session" || handoff_die "could not mint a session uuid" fi + if [[ "$binary" == pi ]]; then + if ! handoff_pi_prepare "$model_spec"; then + # A dry run writes nothing: a blocked result would replace a resumable one. + [[ "$dry" -ne 1 ]] || handoff_die "$HANDOFF_PI_BLOCK" + [[ -n "$session" ]] || session="$HANDOFF_NO_SESSION" + handoff_block "$result" "$HANDOFF_PI_BLOCK" "$session" || true + cat -- "$result"; printf '\n' + return 1 + fi + model="$HANDOFF_PI_MODEL" + fi + bd="$(handoff_bd_bin)" if handoff_bin_present "$bd"; then bd_present=true @@ -662,7 +854,11 @@ handoff_run_headless() { prompt="$(handoff_issue_prompt "$issue" "$issue_title")" local agent=bstack [[ "$stage" == autopilot ]] && agent=bstack-autopilot - handoff_build_headless_argv "$binary" "$bin" "$model" "$session" "$resumed" "$prompt" "$beads_dir" "$agent" + if [[ "$binary" == pi ]]; then + handoff_build_pi_argv "$bin" "$session" "$prompt" "$stage" "$HANDOFF_PI_LEAD" "$model" + else + handoff_build_headless_argv "$binary" "$bin" "$model" "$session" "$resumed" "$prompt" "$beads_dir" "$agent" + fi cmd_json="$(printf '%s\0' "${HANDOFF_START_ARGV[@]}" | jq -Rs 'split("\u0000") | .[:-1]')" if handoff_bin_present "$bin"; then @@ -711,8 +907,18 @@ handoff_run_headless() { codex) tid="$(handoff_codex_thread_id "$log" || true)" ;; cursor-agent) tid="$(handoff_cursor_session_id "$log" || true)" ;; opencode) tid="$(handoff_opencode_session_id "$log" || true)" ;; + pi) tid="$(handoff_pi_session_id "$log" || true)" ;; esac - if [[ -n "$tid" ]]; then + if [[ "$binary" == pi ]]; then + if [[ -n "$tid" && "$tid" != "$session" ]]; then + printf 'handoff: Pi session header is %s, not %s; using the header\n' "$tid" "$session" >&2 + session="$tid" + out="$(jq --arg session_id "$session" '.session_id = $session_id' <<<"$out")" + fi + if handoff_pi_provider_failed "$log"; then + rc=1 + fi + elif [[ -n "$tid" ]]; then session="$tid" out="$(jq --arg session_id "$session" '.session_id = $session_id' <<<"$out")" fi diff --git a/tests/bstack_test.sh b/tests/bstack_test.sh index 7aa2f18..616a631 100644 --- a/tests/bstack_test.sh +++ b/tests/bstack_test.sh @@ -172,7 +172,7 @@ EOF test_new_install_defaults() { reset_home 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 "pi" "$(_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" @@ -213,6 +213,7 @@ test_deploy_renders_agents() { write_config <<'EOF' [bstack] enabled = true +harness = "opencode" claude = "claude-agent/opus#high" gpt = "openai/gpt-6.1-sol" grok = "" @@ -248,6 +249,7 @@ test_deploy_keeps_their_files() { write_config <<'EOF' [bstack] enabled = true +harness = "opencode" EOF mkdir -p "$OPENCODE_CONFIG_DIR/commands" printf '{ "model": "mine/model" }\n' >"$OPENCODE_CONFIG_DIR/opencode.jsonc" @@ -267,6 +269,7 @@ test_off_removes_only_ours() { write_config <<'EOF' [bstack] enabled = true +harness = "opencode" claude = "claude-agent/opus" council = true EOF @@ -290,6 +293,7 @@ test_council_seats_need_the_setting() { reset_home local oc="$OPENCODE_CONFIG_DIR" config='[bstack] enabled = true +harness = "opencode" claude = "claude-agent/opus#high" gpt = "openai/gpt-6.1-sol"' write_config <<<"$config" @@ -355,6 +359,9 @@ test_on_switches_stock_desk_agent() { write_config <<'EOF' [agent] command = "cursor-agent" + +[bstack] +harness = "opencode" EOF bstack_on >/dev/null 2>&1 assert_eq "opencode" "$(_bstack_toml_get agent command)" "on moves the stock desk agent to opencode" @@ -362,6 +369,9 @@ EOF write_config <<'EOF' [agent] command = "claude" + +[bstack] +harness = "opencode" EOF bstack_on >/dev/null 2>&1 assert_eq "claude" "$(_bstack_toml_get agent command)" "on keeps a desk agent the person picked" @@ -375,6 +385,7 @@ test_setup_auto_models() { command = "cursor-agent" [bstack] +harness = "opencode" council = true EOF FAKE_CONNECTED="claude-agent xai" bstack_setup >"$TMP_DIR/setup.log" 2>&1 || fail "setup: $(cat "$TMP_DIR/setup.log")" @@ -416,6 +427,7 @@ test_handoff_bstack_stage() { reset_home write_config <<'EOF' [bstack] +harness = "opencode" claude = "claude-agent/opus#high" gpt = "openai/gpt-6.1-sol" lead = "gpt" @@ -465,6 +477,7 @@ EOF write_config <<'EOF' [bstack] enabled = true +harness = "opencode" claude = "claude-agent/opus" EOF FAKE_CONNECTED="" deploy_bstack >/dev/null 2>&1 @@ -477,6 +490,8 @@ EOF } test_pick_model() { + mkdir -p "$(dirname "$AGENTIC_DEV_USER_CONFIG")" + printf '[bstack]\nharness = "opencode"\n' >"$AGENTIC_DEV_USER_CONFIG" local m='xai/grok-4.20-0309-reasoning xai/grok-4.20-multi-agent-0309 xai/grok-4.3 @@ -505,11 +520,12 @@ claude-agent/opus' local BSTACK_VARIANTS='xai/grok-4.7 low,medium,high,xhigh openai/gpt-6.1-sol low,medium,high,xhigh,max claude-agent/opus low,medium,high,xhigh,max' + printf '[bstack]\nharness = "opencode"\n' >"$AGENTIC_DEV_USER_CONFIG" assert_eq "openai/gpt-6.1-sol#high" "$(_bstack_pick_model gpt <<<"$m")" "high by default" - printf '[bstack]\neffort = "max"\n' >"$AGENTIC_DEV_USER_CONFIG" + printf '[bstack]\nharness = "opencode"\neffort = "max"\n' >"$AGENTIC_DEV_USER_CONFIG" assert_eq "xai/grok-4.7#xhigh" "$(_bstack_pick_model grok <<<"$m")" "grok has no max: the next level down" assert_eq "claude-agent/opus#max" "$(_bstack_pick_model claude <<<"$m")" "claude at max" - printf '[bstack]\neffort = "none"\n' >"$AGENTIC_DEV_USER_CONFIG" + printf '[bstack]\nharness = "opencode"\neffort = "none"\n' >"$AGENTIC_DEV_USER_CONFIG" assert_eq "openai/gpt-6.1-sol" "$(_bstack_pick_model gpt <<<"$m")" "none: the provider default" printf 'PASS: auto adds the [bstack] effort variant each model offers\n' } @@ -600,12 +616,16 @@ test_pi_deploy() { 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" + # A desk never falls back to headless; only a headless handoff's own brief says so. + grep -q 'exception: its system prompt says so' "$p/instructions.md" || fail "headless is only the handoff's exception" + grep -q 'Work single-agent' "$p/agents/bstack-headless.md" || fail "the headless brief is single-agent" grep -q -- '--branch NAME --stage bstack --issue ID' "$p/prompts/handoff.md" || fail "handoff opens a desk" cmp "$ROOT/bstack/pi/extensions/fallback.ts" "$p/extensions/fallback.ts" || fail "managed fallback extension" + cmp "$ROOT/bstack/pi/extensions/guard.ts" "$p/extensions/guard.ts" || fail "managed guard extension" + [[ -f "$p/agents/bstack-headless.md" ]] || fail "headless lead brief" jq -e --arg p "$p" '.[0:3] == ["pi", "--model", "claude-bridge/claude-opus-5-5:high"] and (index(["-e", "npm:pi-claude-bridge@0.11.0"]) != null) - and (index(["-e", "\($p)/extensions/fallback.ts"]) != null) + and (index(["-e", "\($p)/extensions/fallback.ts", "-e", "\($p)/extensions/guard.ts"]) != null) and (index(["--prompt-template", "\($p)/prompts"]) != null) and (index(["--append-system-prompt", "\($p)/instructions.md"]) != null) and (index("--skill") != null) and (index("env") == null)' "$p/lead.json" >/dev/null \ @@ -620,7 +640,7 @@ test_pi_deploy() { 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 -q -- "-e $p/extensions/fallback.ts" "$t/$rel.md" || fail "$rel loads fallback" + grep -q -- "-e $p/extensions/fallback.ts -e $p/extensions/guard.ts" "$t/$rel.md" || fail "$rel loads fallback then guard" grep -q -- "-e npm:pi-claude-bridge@0.11.0" "$t/$rel.md" || fail "$rel loads bridge" grep -q -- "env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN " "$t/$rel.md" || fail "$rel unsets Anthropic env" grep -q -- "--prompt-template $p/prompts" "$t/$rel.md" || fail "--prompt-template in $rel" @@ -741,7 +761,25 @@ harness = "pi" command = "cursor-agent" EOF FAKE_PI_READY="claude-bridge" 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" + assert_eq "pi" "$(_bstack_toml_get agent command)" "on with harness pi moves the stock desk to pi" + write_config <<'EOF' +[bstack] +enabled = true + +[agent] +command = "opencode" +EOF + FAKE_PI_READY="claude-bridge" bstack_on >/dev/null 2>&1 + assert_eq "pi" "$(_bstack_toml_get agent command)" "on moves bercail's old opencode desk to pi" + write_config <<'EOF' +[bstack] +enabled = true + +[agent] +command = "claude" +EOF + bstack_on >/dev/null 2>&1 + assert_eq "claude" "$(_bstack_toml_get agent command)" "on keeps a desk agent the person picked" printf 'PASS: setup on Pi deploys and prints the logins to run\n' } test_pi_launch_contract() { @@ -756,17 +794,19 @@ test_pi_launch_contract() { cmd="$(awk '/^command: / { sub(/^command: /, ""); print }' "$template")" eval "$cmd" grep -qxF "$(PI_DIR)/extensions/fallback.ts" "$FAKE_PI_LAUNCH_LOG" || fail "fallback in $template" + grep -qxF "$(PI_DIR)/extensions/guard.ts" "$FAKE_PI_LAUNCH_LOG" || fail "guard in $template" grep -qxF 'npm:pi-claude-bridge@0.11.0' "$FAKE_PI_LAUNCH_LOG" || fail "bridge in $template" grep -q '^ANTHROPIC_' "$FAKE_PI_LAUNCH_LOG.env" && fail "Anthropic env leaked from $template" done cmd="$(bstack_pi_command '' "$(bstack_role_model lead)")" eval "$cmd" grep -qxF "$(PI_DIR)/extensions/fallback.ts" "$FAKE_PI_LAUNCH_LOG" || fail "lead fallback" + grep -qxF "$(PI_DIR)/extensions/guard.ts" "$FAKE_PI_LAUNCH_LOG" || fail "lead guard" grep -qxF 'npm:pi-claude-bridge@0.11.0' "$FAKE_PI_LAUNCH_LOG" || fail "lead bridge" grep -q '^ANTHROPIC_' "$FAKE_PI_LAUNCH_LOG.env" && fail "Anthropic env leaked from lead" _bstack_pi_claude_ready || fail "Claude auth probe also sanitizes env" unset ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN FAKE_PI_LAUNCH_LOG FAKE_PI_READY - printf 'PASS: every Pi role including GPT/Grok council seats and empty-role lead loads both extensions and unsets Anthropic env\n' + printf 'PASS: every Pi role including GPT/Grok council seats and empty-role lead loads bridge, fallback, and guard, and unsets Anthropic env\n' } test_pi_fallback_config() { @@ -796,10 +836,12 @@ test_pi_fallback_config() { _bstack_toml_set bstack fallback '[]' deploy_bstack >/dev/null 2>&1 [[ ! -s "$(PI_DIR)/fallback" ]] || fail "empty fallback disables candidates" - rm "$(PI_DIR)/fallback" "$(PI_DIR)/models" + rm "$(PI_DIR)/fallback" "$(PI_DIR)/models" "$(PI_DIR)/extensions/guard.ts" "$(PI_DIR)/agents/bstack-headless.md" out="$(doctor_bstack)" && fail "doctor missing data files" grep -q 'missing bstack .*fallback' <<<"$out" || fail "missing fallback: $out" grep -q 'missing bstack .*models' <<<"$out" || fail "missing models: $out" + grep -q 'missing bstack .*extensions/guard.ts' <<<"$out" || fail "missing guard: $out" + grep -q 'missing bstack .*bstack-headless.md' <<<"$out" || fail "missing headless brief: $out" unset FAKE_PI_READY printf 'PASS: Pi fallback order validates families and duplicates, deploys separately, and doctor catches missing/stale config\n' } @@ -843,19 +885,85 @@ test_pi_bridge_doctor() { printf 'PASS: Pi bridge install is required and idempotent; doctor verifies actual bridge, Claude Code auth and minimum Pi version\n' } -test_harness_defaults_to_opencode() { +test_harness_defaults_to_pi() { reset_home write_config <<'EOF' [bstack] enabled = true EOF - assert_eq "opencode" "$(bstack_harness)" "unset is opencode" + assert_eq "pi" "$(bstack_harness)" "unset is pi" + _bstack_toml_set bstack harness '"nope"' + assert_eq "pi" "$(bstack_harness)" "unknown falls back to pi" + local out + out="$(deploy_bstack 2>&1)" || fail "unknown harness deploys Pi: $out" + grep -q 'using pi' <<<"$out" || fail "unknown harness warns: $out" + grep -q 'using opencode' <<<"$out" && fail "unknown harness must not fall back to opencode: $out" + [[ -f "$(PI_DIR)/lead.json" ]] || fail "unknown harness deploys the Pi profile" _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" + [[ ! -d "$(PI_DIR)" && ! -e "$(PI_TEMPLATES)/bstack-builder.md" ]] || fail "pinned 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' + printf 'PASS: Pi is the default harness; opencode only when pinned\n' +} + +test_share_dir_defaults_when_unset() { + local dir + dir="$( + env -u AGENTIC_DEV_SHARE_DIR HOME="$HOME" bash --noprofile --norc -c \ + 'source "$1"; printf "%s" "$BSTACK_PI_DIR"' _ "$ROOT/lib/bstack.sh" + )" + assert_eq "$HOME/.local/share/bercail/pi" "$dir" "unset share dir is ~/.local/share/bercail/pi" + printf 'PASS: bstack paths default when AGENTIC_DEV_SHARE_DIR is unset\n' +} + +test_migrate_opencode_desk_and_summary() { + reset_home + # shellcheck source=lib/help.sh + source "$ROOT/lib/help.sh" + write_config <<'EOF' +[agent] +command = "opencode" + +[bstack] +enabled = true +EOF + migrate_opencode_desk_command + assert_eq "pi" "$(_bstack_toml_get agent command)" "update migrates the old desk default" + local out + out="$(show_summary)" + grep -q 'bstack: on (pi)' <<<"$out" || fail "summary harness: $out" + grep -q 'bstack=pi, autopilot=pi (BERCAIL_ROLE=autopilot)' <<<"$out" || fail "summary stages: $out" + grep -q 'Agent command: pi ' <<<"$out" || fail "summary agent: $out" + grep -q 'opencode' <<<"$out" && fail "summary on Pi must not say opencode: $out" + write_config <<'EOF' +[agent] +command = "opencode" + +[bstack] +enabled = true +harness = "opencode" +EOF + migrate_opencode_desk_command + assert_eq "opencode" "$(_bstack_toml_get agent command)" "pinned opencode harness keeps its desk" + out="$(show_summary)" + grep -q 'bstack: on (opencode)' <<<"$out" || fail "pinned summary: $out" + grep -q 'bstack=opencode, autopilot=opencode bstack-autopilot' <<<"$out" || fail "pinned stages: $out" + write_config <<'EOF' +[agent] +command = "claude" + +[bstack] +enabled = true +EOF + migrate_opencode_desk_command + assert_eq "claude" "$(_bstack_toml_get agent command)" "a chosen desk agent stays" + grep -q 'Existing configs without harness keep opencode' "$ROOT/lib/help.sh" \ + && fail "help still says unset harness is opencode" + grep -q 'Pi on the desk' "$ROOT/lib/help.sh" || fail "help --bstack should say Pi on the desk" + grep -q 'pi -p' "$ROOT/lib/help.sh" || fail "help should say bstack runs pi -p" + grep -q 'opencode or Pi' "$ROOT/bin/bercail" && fail "usage still leads with opencode" + printf 'PASS: update migrates the old opencode desk default; summary names the harness\n' } test_toml_get_set @@ -875,7 +983,9 @@ test_on_switches_stock_desk_agent test_setup_auto_models test_handoff_bstack_stage test_doctor -test_harness_defaults_to_opencode +test_harness_defaults_to_pi +test_share_dir_defaults_when_unset +test_migrate_opencode_desk_and_summary test_pi_models test_pi_deploy test_pi_keeps_their_template diff --git a/tests/desk_agent_test.sh b/tests/desk_agent_test.sh index adaa187..1154d26 100755 --- a/tests/desk_agent_test.sh +++ b/tests/desk_agent_test.sh @@ -35,15 +35,15 @@ CONFIG="$XDG_CONFIG_HOME/bercail/config.toml" desk() { "$ROOT/bin/bercail-desk-agent" "$@"; } expect() { [[ "$1" == "$2" ]] || fail "$3: got '$1', want '$2'"; } -printf '[agent]\ncommand = "claude --verbose" # mine\n\n[bstack]\nenabled = true\n' >"$CONFIG" +printf '[agent]\ncommand = "claude --verbose" # mine\n\n[bstack]\nenabled = true\nharness = "opencode"\n' >"$CONFIG" got="$(desk)" -[[ "$got" == "opencode cli="* ]] || fail "bstack on runs opencode with no flags: $got" +[[ "$got" == "opencode cli="* ]] || fail "pinned opencode runs opencode with no flags: $got" cli="${got#opencode cli=}" expect "$(jq -c '.theme' <<<"$cli")" '{"name":"system","mode":"system"}' "bstack's desk takes the terminal's palette" expect "$(jq -c '.highlights' <<<"$cli")" false "no release highlights on the desk" expect "$(jq -r '.tabs.mode' <<<"$cli")" off "tabs off: the TUI reports only its own session" expect "$(desk --prompt 'Do bercail-1')" "opencode [--prompt] [Do bercail-1] cli=$cli" "bstack prompt" -printf 'PASS: bstack on runs opencode (full TUI, system theme), its prompt through --prompt\n' +printf 'PASS: pinned opencode runs opencode (full TUI, system theme), its prompt through --prompt\n' expect "$(desk --prompt 'Do bercail-1' codex)" "codex [--no-daemon] [Do bercail-1] cli=none" "handoff codex" expect "$(desk codex --no-daemon)" "codex [--no-daemon] cli=none" "codex keeps one --no-daemon" @@ -81,6 +81,15 @@ expect "$(ANTHROPIC_API_KEY=mine desk pi)" "pi [-e] [$EXT] key=mine" "bstack off rm -f "$EXT" printf 'PASS: bstack on Pi runs the lead from lead.json, with the desk extension and no Anthropic env\n' +printf '[bstack]\nenabled = true\n' >"$CONFIG" +expect "$(ANTHROPIC_API_KEY=secret desk)" \ + "pi [--model] [claude-bridge/opus:high] [--append-system-prompt] [/a b/instructions.md] key=none" \ + "bstack on, harness unset, runs the Pi lead from lead.json" +printf '[bstack]\nenabled = true\nharness = "opencode"\n' >"$CONFIG" +got="$(desk)" +[[ "$got" == "opencode cli="* ]] || fail "pinned harness opencode stays opencode: $got" +printf 'PASS: unset harness is the Pi lead; pinned opencode stays opencode\n' + # --- the desk's record, for bercail desk restore --- export XDG_STATE_HOME="$TMP_DIR/state" diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index c69dfe9..ccf85e1 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -863,7 +863,7 @@ test_codex_stage_dry_run() { err="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" \ --stage start --issue "$ISSUE" --binary cursor --worktree "$wt" --dry-run 2>&1)" || rc=$? [[ "$rc" -eq 1 ]] || fail "bad --binary should exit 1, got $rc ($err)" - printf '%s' "$err" | grep -q 'claude, codex, cursor-agent, or opencode' || fail "bad --binary message: $err" + printf '%s' "$err" | grep -q 'claude, codex, cursor-agent, opencode, or pi' || fail "bad --binary message: $err" unset BERCAIL_CLAUDE_MODEL_CATALOG printf 'PASS: codex dry-run is exec --json; resume is exec resume; --binary selects it\n' } @@ -1931,3 +1931,155 @@ HC test_issue_title_normalises_and_degrades test_issue_title_on_headless_runs test_branch_carries_issue_title + +_pi_profile() { + local profile="$TMP_DIR/pi-profile" guard="$TMP_DIR/pi-profile/extensions/guard.ts" + mkdir -p "$profile/extensions" "$profile/agents" + printf '// guard\n' >"$guard" + jq -n --arg guard "$guard" --arg model "claude-bridge/lead-model:high" \ + --arg prompts "$profile/prompts" --arg instructions "$profile/instructions.md" \ + '["pi","--model",$model,"-e","npm:pi-claude-bridge@0.11.0","-e",$guard,"--prompt-template",$prompts,"--append-system-prompt",$instructions]' \ + >"$profile/lead.json" + printf 'claude=claude-bridge/family-claude#high\ngpt=openai/family-gpt#high\n' >"$profile/models" + printf '%s' "$profile" +} + +test_pi_headless_stage() { + local saved_config="${BERCAIL_CONFIG-}" saved_profile="${HANDOFF_PI_PROFILE-}" saved_models="${BERCAIL_BSTACK_MODELS-}" + local profile wt out err rc=0 sid stub + profile="$(_pi_profile)" + printf '[bstack]\nharness = "pi"\nenabled = true\nlead = "openai/from-config"\n' >"$TMP_DIR/pi.toml" + printf '[bstack]\nharness = "opencode"\nlead = "openai/from-config"\n' >"$TMP_DIR/oc.toml" + export BERCAIL_CONFIG="$TMP_DIR/pi.toml" + export HANDOFF_PI_PROFILE="$profile" + export BERCAIL_BSTACK_MODELS="$TMP_DIR/no-bstack-models" + : >"$BERCAIL_BSTACK_MODELS" + export HANDOFF_PI_BIN="$TMP_DIR/bin/pi-missing" + wt="$TMP_DIR/pi-dry" + mkdir -p "$wt" + + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e --arg bin "$HANDOFF_PI_BIN" --arg profile "$profile" --arg model "claude-bridge/lead-model:high" --arg id "$ISSUE" ' + .dry_run == true and .binary == "pi" and .model == $model and .stage == "bstack" + and .command[0:11] == ["env","-u","ANTHROPIC_API_KEY","-u","ANTHROPIC_BASE_URL","-u","ANTHROPIC_AUTH_TOKEN","-u","BERCAIL_ROLE","-u","HC_TERMINAL_ID"] + and (any(.command[]; . == "BERCAIL_ROLE=autopilot") | not) + and .command[11] == $bin + and .command[12:16] == ["-p","--mode","json","--session-id"] + and (.command[16] | test("^[0-9a-fA-F-]{36}$")) + and .session_id == .command[16] + and .command[(.command | index("--model")) + 1] == $model + and any(.command[]; . == ($profile + "/extensions/guard.ts")) + and any(.command[]; . == ($profile + "/agents/bstack-headless.md")) + and (any(.command[]; . == ($profile + "/agents/bstack-autopilot.md")) | not) + and (any(.command[]; . == "--add-dir") | not) + and .command[-2] == "--" + and (.command[-1] | startswith("Your job is beads issue " + $id)) + ' >/dev/null || fail "bstack pi argv: $out" + + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage autopilot --issue "$ISSUE" --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e --arg bin "$HANDOFF_PI_BIN" --arg profile "$profile" ' + .binary == "pi" and .stage == "autopilot" + and .command[0:11] == ["env","-u","ANTHROPIC_API_KEY","-u","ANTHROPIC_BASE_URL","-u","ANTHROPIC_AUTH_TOKEN","-u","BERCAIL_ROLE","-u","HC_TERMINAL_ID"] + and .command[11] == "BERCAIL_ROLE=autopilot" + and .command[12] == $bin + and .command[13:17] == ["-p","--mode","json","--session-id"] + and any(.command[]; . == ($profile + "/agents/bstack-headless.md")) + and any(.command[]; . == ($profile + "/agents/bstack-autopilot.md")) + and .command[-2] == "--" + ' >/dev/null || fail "autopilot pi argv: $out" + + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --binary pi --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e '.binary == "pi" and .model == "claude-bridge/family-claude:high" and .command[(.command | index("--model")) + 1] == "claude-bridge/family-claude:high"' >/dev/null \ + || fail "start --binary pi family model: $out" + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage codex --issue "$ISSUE" --binary pi --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e '.binary == "pi" and .model == "openai/family-gpt:high"' >/dev/null \ + || fail "codex --binary pi family model: $out" + + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --binary opencode --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e '.binary == "opencode" and .command[0:6] == ["opencode","run","--format","json","--agent","bstack"] and .model == "openai/from-config"' >/dev/null \ + || fail "--binary opencode still runs opencode: $out" + + export BERCAIL_CONFIG="$TMP_DIR/oc.toml" + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e '.binary == "opencode" and .command[0:6] == ["opencode","run","--format","json","--agent","bstack"] and .model == "openai/from-config"' >/dev/null \ + || fail "harness opencode keeps today argv: $out" + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage autopilot --issue "$ISSUE" --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e '.binary == "opencode" and .command[5] == "bstack-autopilot"' >/dev/null \ + || fail "harness opencode autopilot: $out" + export BERCAIL_CONFIG="$TMP_DIR/pi.toml" + + sid=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee + out="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" --resume "$sid" --dry-run)" + printf '%s' "$out" | jq -e --arg sid "$sid" '.resume == true and .session_id == $sid and .command[(.command | index("--session-id")) + 1] == $sid and (any(.command[]; . == "--resume") | not)' >/dev/null \ + || fail "pi resume reuses the uuid: $out" + rc=0 + err="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" --resume ses_abc --dry-run 2>&1)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "ses_ resume should fail" + printf '%s' "$err" | grep -q 'opencode sessions cannot be resumed on Pi' || fail "ses_ message: $err" + + export HANDOFF_PI_PROFILE="$TMP_DIR/no-pi-profile" + mkdir -p "$HANDOFF_PI_PROFILE" + rm -f "$wt/.bercail/handoff-result.json" + rc=0 + err="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" --dry-run 2>&1)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "missing lead.json should fail a dry run" + printf '%s' "$err" | grep -q 'bercail update' || fail "missing lead.json dry-run message: $err" + [[ ! -e "$wt/.bercail/handoff-result.json" ]] || fail "a dry run writes no result file" + rc=0 + err="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" 2>&1)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "missing lead.json should block" + jq -e '.status == "blocked" and (.summary | test("bercail update"))' "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "missing lead.json result: $(cat "$wt/.bercail/handoff-result.json") $err" + rm -f "$wt/.bercail/handoff-result.json" + jq -n --arg model "claude-bridge/lead-model:high" '["pi","--model",$model,"-e","npm:pi-claude-bridge@0.11.0"]' >"$HANDOFF_PI_PROFILE/lead.json" + rc=0 + err="$(cd "$wt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$wt" 2>&1)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "missing guard should block" + jq -e '.status == "blocked" and (.summary | test("guard")) and (.summary | test("bercail update"))' "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "missing guard result: $(cat "$wt/.bercail/handoff-result.json")" + export HANDOFF_PI_PROFILE="$profile" + + _bd_stub + stub="$TMP_DIR/bin/pi" + mkdir -p "$TMP_DIR/bin" + cat >"$stub" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${PI_STUB_MODE:-}" == error ]]; then + printf '%s\n' '{"type":"session","version":3,"id":"bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"}' + printf '%s\n' '{"type":"message_end","message":{"role":"assistant","stopReason":"error","errorMessage":"provider exploded"}}' + exit 0 +fi +printf '%s\n' '{"type":"session","version":3,"id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}' +printf '%s\n' '{"type":"agent_settled"}' +jq -nc '{status:"ready", summary:"wrote the fix"}' >"$BERCAIL_RESULT_PATH" +exit 0 +STUB + chmod +x "$stub" + export HANDOFF_PI_BIN="$stub" + local runwt="$TMP_DIR/pi-run" errf="$TMP_DIR/pi-header.err" + mkdir -p "$runwt" + out="$(cd "$runwt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$runwt" 2>"$errf")" + grep -q 'using the header' "$errf" || fail "header mismatch should be said: $(cat "$errf")" + jq -e '.status == "ready" and .session_id == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"' "$runwt/.bercail/handoff-result.json" >/dev/null \ + || fail "header session: $(cat "$runwt/.bercail/handoff-result.json")" + + export PI_STUB_MODE=error + local failwt="$TMP_DIR/pi-fail" + mkdir -p "$failwt" + rc=0 + out="$(cd "$failwt" && "$ROOT/skills/handoff/scripts/handoff-spawn" --stage bstack --issue "$ISSUE" --worktree "$failwt" 2>"$TMP_DIR/pi-fail.err")" || rc=$? + [[ "$rc" -ne 0 ]] || fail "pi exit 0 with an error message_end should fail" + jq -e '.status == "failed" and .session_id == "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" and (has("reason") | not)' \ + "$failwt/.bercail/handoff-result.json" >/dev/null \ + || fail "pi provider error result: $(cat "$failwt/.bercail/handoff-result.json")" + unset PI_STUB_MODE + + if [[ -n "$saved_config" ]]; then export BERCAIL_CONFIG="$saved_config"; else unset BERCAIL_CONFIG; fi + if [[ -n "$saved_profile" ]]; then export HANDOFF_PI_PROFILE="$saved_profile"; else unset HANDOFF_PI_PROFILE; fi + if [[ -n "$saved_models" ]]; then export BERCAIL_BSTACK_MODELS="$saved_models"; else unset BERCAIL_BSTACK_MODELS; fi + unset HANDOFF_PI_BIN + printf 'PASS: headless bstack and autopilot run Pi, and opencode stays opt-in\n' +} + +test_pi_headless_stage diff --git a/tests/layout_config_test.sh b/tests/layout_config_test.sh index c7e55fe..ba0ed4e 100644 --- a/tests/layout_config_test.sh +++ b/tests/layout_config_test.sh @@ -51,8 +51,8 @@ assert_eq "bash" "$(detect_shell_name)" "detect_shell_name follows bash \$SHELL" assert_eq "${HOME}/.bashrc" "$(shell_rc_for bash)" "bash rc is ~/.bashrc" SHELL="$saved_shell" -assert_contains "$(default_user_config)" 'command = "opencode"' \ - "a new install's desk runs bstack (opencode)" +assert_contains "$(default_user_config)" 'command = "pi"' \ + "a new install's desk runs bstack (pi)" assert_contains "$(BSTACK_NEW_INSTALL=0 default_user_config)" 'command = "cursor-agent"' \ "--no-bstack keeps cursor-agent" grep -q 'Choice \[1-7\]' "$ROOT/lib/config.sh" \ @@ -60,7 +60,7 @@ grep -q 'Choice \[1-7\]' "$ROOT/lib/config.sh" \ YES=1 prompt_user_config -assert_eq "opencode" "$(read_agent_command)" "prompt_user_config writes opencode (bstack) with no picker" +assert_eq "pi" "$(read_agent_command)" "prompt_user_config writes pi (bstack) with no picker" YES=0 declare -F pstack_plugin_present >/dev/null \ @@ -75,7 +75,7 @@ assert_contains "$(default_user_config)" 'auto_review = true' \ mkdir -p "$AGENTIC_DEV_CONFIG_DIR" cp "$ROOT/config/agentic-dev/config-reader.sh" "$AGENTIC_DEV_CONFIG_DIR/config-reader.sh" -assert_eq "opencode" "$(read_agent_command)" "the config reader returns the new-install default" +assert_eq "pi" "$(read_agent_command)" "the config reader returns the new-install default" assert_eq "tuicr" "$(read_layout_review)" "review defaults to tuicr" write_user_config grok diff --git a/tests/omarchy_test.sh b/tests/omarchy_test.sh index 3098b91..a521c0e 100755 --- a/tests/omarchy_test.sh +++ b/tests/omarchy_test.sh @@ -195,6 +195,27 @@ EOF rm -rf "$HOME/.local/share/omarchy" "$HOME/.config/omarchy" "$HOME/.config/agentic-dev" } +test_sync_omarchy_rewrites_old_opencode_default() { + mkdir -p "$HOME/.local/share/omarchy" "$HOME/.config/omarchy/defaults" + printf 'opencode\n' >"$HOME/.config/omarchy/defaults/agent" + read_agent_command() { printf 'pi'; } + RECONFIGURE=0 + sync_omarchy_default_agent + assert_eq "pi" "$(tr -d '\n' <"$HOME/.config/omarchy/defaults/agent")" \ + "old opencode default follows the desk command to pi" + printf 'claude\n' >"$HOME/.config/omarchy/defaults/agent" + sync_omarchy_default_agent + assert_eq "claude" "$(tr -d '\n' <"$HOME/.config/omarchy/defaults/agent")" \ + "a chosen omarchy agent stays" + printf 'opencode\n' >"$HOME/.config/omarchy/defaults/agent" + read_agent_command() { printf 'opencode'; } + sync_omarchy_default_agent + assert_eq "opencode" "$(tr -d '\n' <"$HOME/.config/omarchy/defaults/agent")" \ + "pinned opencode desk keeps the omarchy default" + unset -f read_agent_command + rm -rf "$HOME/.local/share/omarchy" "$HOME/.config/omarchy" +} + test_is_omarchy_isolated_home test_is_omarchy_user_share test_is_omarchy_omarchy_path @@ -206,6 +227,7 @@ test_lua_binding_generic test_patch_lua_bindings test_doctor_herdr_app_binding test_sync_omarchy_default_agent +test_sync_omarchy_rewrites_old_opencode_default printf '%s passed, %s failed\n' "$pass" "$fail" ((fail == 0)) diff --git a/tests/pi_guard_live.mjs b/tests/pi_guard_live.mjs new file mode 100755 index 0000000..85c243c --- /dev/null +++ b/tests/pi_guard_live.mjs @@ -0,0 +1,36 @@ +// Real Pi, offline provider: a bash tool call for git push --force must be blocked. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const pi = process.env.PI_GUARD_BIN || "pi"; +const dir = mkdtempSync(join(tmpdir(), "pi-guard-")); +try { + mkdirSync(join(dir, "agent"), { recursive: true }); + writeFileSync(join(dir, "agent/settings.json"), JSON.stringify({ retry: { enabled: false }, compaction: { enabled: false } })); + const env = { ...process.env, HOME: dir, PI_CODING_AGENT_DIR: join(dir, "agent"), PI_OFFLINE: "1" }; + delete env.BERCAIL_ROLE; + delete env.HC_TERMINAL_ID; + const args = [ + "--offline", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-mcp", + "--mode", "json", "-p", + "-e", resolve(root, "bstack/pi/extensions/guard.ts"), + "-e", resolve(root, "tests/pi_guard_provider.ts"), + "--model", "fake-guard/model", + "Push the branch.", + ]; + const result = spawnSync(pi, args, { env, cwd: dir, encoding: "utf8", timeout: 30000 }); + assert.equal(result.error, undefined, String(result.error)); + assert.doesNotMatch(result.stderr, /Failed to load extension/i, result.stderr); + assert.match(result.stdout, /Force-push is blocked/, `stdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.doesNotMatch(result.stdout, /No configured push destination|Everything up-to-date/, "the push must not run"); + console.log("PASS: real Pi blocked git push --force"); +} catch (error) { + console.error(`Artifacts: ${dir}`); + throw error; +} +rmSync(dir, { recursive: true, force: true }); diff --git a/tests/pi_guard_provider.ts b/tests/pi_guard_provider.ts new file mode 100644 index 0000000..41050ff --- /dev/null +++ b/tests/pi_guard_provider.ts @@ -0,0 +1,48 @@ +// Offline provider loaded by the real Pi binary. No network. +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + let calls = 0; + pi.registerProvider("fake-guard", { + api: "pi-guard-test", + baseUrl: "http://invalid.invalid", + apiKey: "offline-test-key", + models: [{ + id: "model", + name: "guard", + reasoning: false, + input: ["text"], + contextWindow: 200000, + maxTokens: 1000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }], + streamSimple(model) { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + calls++; + const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; + const base = { role: "assistant" as const, api: model.api, provider: model.provider, model: model.id, usage, timestamp: Date.now() }; + if (calls === 1) { + stream.push({ + type: "done", + reason: "toolUse", + message: { + ...base, + stopReason: "toolUse", + content: [{ type: "toolCall", id: "guard-call", name: "bash", arguments: { command: "git push --force" } }], + }, + }); + } else { + stream.push({ + type: "done", + reason: "stop", + message: { ...base, stopReason: "stop", content: [{ type: "text", text: "PI_GUARD_DONE" }] }, + }); + } + stream.end(); + }); + return stream; + }, + }); +} diff --git a/tests/pi_guard_test.mjs b/tests/pi_guard_test.mjs new file mode 100755 index 0000000..5d224f8 --- /dev/null +++ b/tests/pi_guard_test.mjs @@ -0,0 +1,102 @@ +// Decision table for bercail's Pi guard. No Pi process. +import assert from "node:assert/strict"; + +const { decideBash, decideTool } = await import(new URL("../bstack/pi/extensions/guard.ts", import.meta.url).href); + +const headless = { role: "", hasUI: false, cwd: "/work" }; +const tui = { role: "", hasUI: true, cwd: "/work" }; +const auto = { role: "autopilot", hasUI: false, cwd: "/work", branch: "feature" }; +const judge = { role: "judge", hasUI: true, cwd: "/work" }; +const council = { role: "council", hasUI: false, cwd: "/work" }; + +function decision(command, input) { + return decideBash(command, input); +} + +function blocked(command, input, pattern) { + const got = decision(command, input); + assert.equal(got.action, "block", `${command} → ${JSON.stringify(got)}`); + if (pattern) assert.match(got.reason, pattern, command); +} + +function allowed(command, input) { + assert.equal(decision(command, input).action, "allow", command); +} + +function asked(command, input) { + const got = decision(command, input); + assert.equal(got.action, "ask", `${command} → ${JSON.stringify(got)}`); +} + +blocked("git push --force", auto, /Force-push/); +blocked("git push -f origin feature", auto, /Force-push/); +blocked("git push --force-with-lease", auto, /Force-push/); +blocked("git push --force-if-includes origin feature", headless, /Force-push/); +blocked("git push --mirror", auto, /mirror/); +blocked("git push origin +feature", auto, /starting with \+/); +blocked("git push origin +HEAD:feature", auto, /starting with \+/); +blocked("git push --delete main", auto, /Deleting main or master/); +blocked("git push -d master", auto, /Deleting main or master/); +blocked("git push origin --delete refs/heads/main", auto, /Deleting main or master/); +blocked("git commit --no-verify -m x", auto, /--no-verify/); +blocked("git commit -n -m x", auto, /--no-verify/); +blocked("git push --no-verify origin feature", auto, /--no-verify/); +blocked("git merge --no-verify feature", headless, /--no-verify/); +blocked("git rebase --no-verify feature", tui, /--no-verify/); +blocked("gh pr merge", auto, /gh pr merge/); +blocked("gh pr merge 12 --squash", headless, /gh pr merge/); +blocked("sudo git push --force", auto, /Force-push/); +blocked("FOO=1 command env -u BAR git push --force", auto, /Force-push/); +blocked("git status && git push --force", auto, /Force-push/); +blocked("echo ok | git push --force origin feature", headless, /Force-push/); +blocked("bash -c 'git push --force'", auto, /Force-push/); +blocked('echo "$(git push --force)"', auto, /Force-push/); +blocked("git commit -m 'leave it'", headless, /headless run stages/); +blocked("git push origin feature", headless, /headless run stages/); +blocked("git push", { ...auto, branch: "main" }, /main or master/); +blocked("git push origin", { ...auto, branch: "master" }, /main or master/); +blocked("git push origin main", auto, /main or master/); +blocked("git push main", auto, /main or master/); +blocked("git push origin HEAD:main", auto, /main or master/); +blocked("git push origin feature:refs/heads/master", auto, /main or master/); +blocked("git push origin refs/heads/main", auto, /main or master/); +blocked("git push", { ...auto, branch: "" }, /Could not read the current branch/); +blocked("git add .", judge, /read-only/); +blocked("git commit -m x", council, /read-only/); +blocked("git reset --hard", judge, /read-only/); +blocked("git checkout -- .", council, /read-only/); +blocked("git restore --staged .", judge, /read-only/); + +asked("git commit -m 'ship'", tui); +asked("git push origin feature", { role: "builder", hasUI: true, cwd: "/work" }); + +allowed("git status", headless); +allowed("git diff", judge); +allowed("git push origin feature", auto); +allowed("git push -u origin feature", auto); +allowed("git commit -m 'ship'", auto); +allowed("git push --delete feature", auto); +allowed('echo "git push --force"', headless); +allowed("git -C /tmp/repo push origin feature", auto); + +const nested = decision("git -C ../other push", { role: "autopilot", hasUI: false, cwd: "/work" }); +assert.equal(nested.action, "need-branch", JSON.stringify(nested)); +assert.equal(nested.dir, "/other"); + +const abs = decision("git -C /repo/sub push", { ...auto, branch: undefined }); +assert.equal(abs.action, "need-branch"); +assert.equal(abs.dir, "/repo/sub"); + +// A heredoc body is data. Seen live: autopilot's summary quoted `git push --force origin feat`. +allowed("cat > /tmp/s.txt <<'EOF'\nNot attempted: `git push --force origin feat` and git push origin HEAD:main.\nEOF\npython3 -c 'print(1)'", auto); +allowed("cat <<-\"END\" >x\n\tgit commit --no-verify\n\tEND", headless); +blocked("cat <x\ntext\nEOF\ngit push --force", auto, /Force-push/); + +assert.equal(decideTool("edit", undefined, judge).action, "block"); +assert.equal(decideTool("write", undefined, council).action, "block"); +assert.equal(decideTool("edit", undefined, headless).action, "allow"); +assert.equal(decideTool("write", undefined, auto).action, "allow"); +assert.equal(decideTool("read", undefined, judge).action, "allow"); + +console.log("PASS: pi guard decision table"); diff --git a/tests/pi_guard_test.sh b/tests/pi_guard_test.sh new file mode 100755 index 0000000..d765dea --- /dev/null +++ b/tests/pi_guard_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# The guard's decision table, plus a live Pi block when Pi is installed. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v node >/dev/null 2>&1; then + printf 'SKIP: node not found; Pi guard proof not run\n' + exit 0 +fi +node "$ROOT/tests/pi_guard_test.mjs" + +pi="${PI_GUARD_BIN:-}" +if [[ -z "$pi" ]] && command -v mise >/dev/null 2>&1; then + pi="$(mise which pi 2>/dev/null || true)" +fi +if [[ -z "$pi" || ! -x "$pi" || "$pi" == */mise/shims/* ]]; then + pi="$(command -v pi 2>/dev/null || true)" + [[ -n "$pi" && "$pi" != */mise/shims/* && -x "$pi" ]] || pi="" +fi +if [[ -z "$pi" ]]; then + printf 'SKIP: Pi not found; guard live proof not run\n' + exit 0 +fi +version="$("$pi" --version)" +if ! node -e 'const [a,b] = process.argv[1].split(".").map(Number); process.exit(a > 1 || (a === 1 && b >= 5) ? 0 : 1)' "$version"; then + printf 'SKIP: Pi %s is older than 1.5; guard live proof not run\n' "$version" + exit 0 +fi +PI_GUARD_BIN="$pi" node "$ROOT/tests/pi_guard_live.mjs" diff --git a/tests/usage_limit_reason_test.sh b/tests/usage_limit_reason_test.sh index 5a8607c..a4a1884 100644 --- a/tests/usage_limit_reason_test.sh +++ b/tests/usage_limit_reason_test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # shellcheck shell=bash -# A stub opencode replays recorded plan-limit errors. No live provider calls. +# Stub harnesses replay recorded plan-limit errors. No live provider calls. +# Existing cases pin harness = opencode so they do not follow the Pi default. set -euo pipefail unset BASH_ENV @@ -55,6 +56,10 @@ export HANDOFF_OPENCODE_BIN="$TMP_DIR/bin/opencode" export HANDOFF_ARGV_LOG="$TMP_DIR/argv.log" export HANDOFF_RUNS="$TMP_DIR/runs" export HANDOFF_STUB_PAYLOAD="$TMP_DIR/payload" +printf '[bstack]\nharness = "opencode"\nlead = "openai/gpt-6.1-sol"\n' >"$TMP_DIR/opencode.toml" +export BERCAIL_CONFIG="$TMP_DIR/opencode.toml" +export BERCAIL_BSTACK_MODELS="$TMP_DIR/no-bstack-models" +: >"$BERCAIL_BSTACK_MODELS" run_stage() { local stage="$1" name="$2" rc=0 @@ -164,6 +169,77 @@ test_rate_limit_has_no_reason() { printf 'PASS: a plain 429 yields no usage-limit reason\n' } +test_pi_limit_sets_reason() { + local profile="$TMP_DIR/pi-profile" guard header wt + mkdir -p "$profile/extensions" "$profile/agents" + guard="$profile/extensions/guard.ts" + printf '// guard\n' >"$guard" + jq -n --arg guard "$guard" --arg model "claude-bridge/lead-model:high" \ + '["pi","--model",$model,"-e","npm:pi-claude-bridge@0.11.0","-e",$guard]' >"$profile/lead.json" + printf 'claude=claude-bridge/family-claude#high\ngpt=openai/family-gpt#high\n' >"$profile/models" + printf '[bstack]\nharness = "pi"\nenabled = true\n' >"$TMP_DIR/pi.toml" + export BERCAIL_CONFIG="$TMP_DIR/pi.toml" + export HANDOFF_PI_PROFILE="$profile" + export HANDOFF_PI_BIN="$TMP_DIR/bin/pi" + cat >"$HANDOFF_PI_BIN" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${HANDOFF_ARGV_LOG:?}" +printf '1\n' >>"${HANDOFF_RUNS:?}" +cat "${HANDOFF_STUB_PAYLOAD:?}" +exit 0 +STUB + chmod +x "$HANDOFF_PI_BIN" + + header=11111111-2222-4333-8444-555555555555 + jq -nc --arg id "$header" --arg msg "Claude quota exceeded — rate limit (five_hour) — resets 9:00:00 PM: You've hit your 5-hour limit · resets 9pm" \ + '{type:"session",version:3,id:$id},{type:"message_end",message:{role:"assistant",stopReason:"error",errorMessage:$msg}}' \ + >"$HANDOFF_STUB_PAYLOAD" + wt="$(run_stage bstack pi-claude)" + assert_limit "$wt" "pi claude" + jq -e --arg id "$header" '.session_id == $id' "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "pi claude session: $(cat "$wt/.bercail/handoff-result.json")" + + header=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee + jq -nc --arg id "$header" --arg msg "You have hit your ChatGPT usage limit. Try again later." \ + '{type:"session",version:3,id:$id},{type:"message_end",message:{role:"assistant",stopReason:"error",errorMessage:$msg}}' \ + >"$HANDOFF_STUB_PAYLOAD" + wt="$(run_stage bstack pi-chatgpt)" + assert_limit "$wt" "pi chatgpt" + jq -e --arg id "$header" '.session_id == $id' "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "pi chatgpt session: $(cat "$wt/.bercail/handoff-result.json")" + + header=bbbbbbbb-cccc-4ddd-8eee-ffffffffffff + jq -nc --arg id "$header" --arg msg "429 Too many requests: xAI weekly limit unverified" \ + '{type:"session",version:3,id:$id},{type:"message_end",message:{role:"assistant",stopReason:"error",errorMessage:$msg}}' \ + >"$HANDOFF_STUB_PAYLOAD" + wt="$(run_stage bstack pi-xai)" + jq -e --arg id "$header" '.status == "failed" and .session_id == $id and (has("reason") | not)' \ + "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "xAI 429 must not set reason: $(cat "$wt/.bercail/handoff-result.json")" + + printf '%s\n' \ + '{"type":"session","id":"cccccccc-dddd-4eee-8fff-000000000001"}' \ + '{"type":"message_end","message":{"role":"assistant","stopReason":"error","errorMessage":"You'\''re out of extra usage · resets 6:30pm"}}' \ + >"$TMP_DIR/pi-extra.log" + [[ "$(handoff_usage_limit_reason "$TMP_DIR/pi-extra.log")" == usage-limit ]] \ + || fail "out of extra usage should be a Pi plan limit" + printf '%s\n' \ + '{"type":"session","id":"cccccccc-dddd-4eee-8fff-000000000002"}' \ + '{"type":"message_end","message":{"role":"assistant","stopReason":"error","errorMessage":"You'\''ve reached your ChatGPT workspace'\''s usage limit"}}' \ + >"$TMP_DIR/pi-workspace.log" + [[ "$(handoff_usage_limit_reason "$TMP_DIR/pi-workspace.log")" == usage-limit ]] \ + || fail "workspace usage limit should be a Pi plan limit" + printf '%s\n' \ + '{"type":"session","id":"cccccccc-dddd-4eee-8fff-000000000003"}' \ + '{"type":"message_end","message":{"role":"assistant","stopReason":"stop"}}' \ + "You've hit your 5-hour limit" >"$TMP_DIR/pi-stop.log" + [[ -z "$(handoff_usage_limit_reason "$TMP_DIR/pi-stop.log")" ]] \ + || fail "a Pi stop must not pick up a phrase on another line" + printf 'PASS: Pi error message_end yields usage-limit, and a plain xAI 429 does not\n' +} + test_chatgpt_limit_sets_reason test_claude_text_sets_reason test_rate_limit_has_no_reason +test_pi_limit_sets_reason