From a8c89692e0f639c838a4a0205a718773743fe2a8 Mon Sep 17 00:00:00 2001 From: juan Date: Sat, 17 Oct 2026 00:23:42 -0500 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20host:=20detect=20Pi?= =?UTF-8?q?=20as=20a=20third=20agent=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - super-board-host.sh: AI_AGENT=pi or PI_CODING_AGENT=true means pi, checked before the Codex ids so a pi launched from a Codex shell reports the innermost host - SUPER_BOARD_HOST accepts claude|codex|pi - test-host.sh covers the Pi markers, precedence and the override --- scripts/super-board-host.sh | 27 +++++++++++++++++---------- tests/test-host.sh | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/scripts/super-board-host.sh b/scripts/super-board-host.sh index 41c21d35..54e5894e 100755 --- a/scripts/super-board-host.sh +++ b/scripts/super-board-host.sh @@ -1,28 +1,35 @@ #!/usr/bin/env bash -# super-board-host.sh — which agent host is this shell inside: `claude` or `codex`. +# super-board-host.sh — which agent host is this shell inside: `claude`, `codex` or `pi`. # -# WHY. `/super-board run` launched from a Codex CLI session has no Claude Workflow tool, so the +# WHY. `/super-board run` launched from a Codex CLI or Pi session has no Claude Workflow tool, so the # workflow backend cannot start. The orchestrator checks its own tool list first; this is the # check a script can make, and the tie-breaker when the tool list is unclear. # -# THE SIGNAL. Codex CLI sets CODEX_THREAD_ID (and CODEX_SESSION_ID, the same value) in every +# THE SIGNALS. Codex CLI sets CODEX_THREAD_ID (and CODEX_SESSION_ID, the same value) in every # shell it runs. Observed 2026-10-04 on codex-cli 0.160.0 with `codex exec -s read-only`: # CODEX_THREAD_ID, CODEX_SESSION_ID, CODEX_VERSION, CODEX_SANDBOX=seatbelt, CODEX_CI=1. # Not used: CODEX_HOME (people export it in their own profile), CODEX_SANDBOX (absent when -# the sandbox is off). A Codex lane started from a Claude session also carries CLAUDECODE; the -# Codex ids still win there, because the innermost host is the one running this shell. +# the sandbox is off). Pi sets two process markers, documented in its environment-variables +# reference: AI_AGENT=pi (generic) and PI_CODING_AGENT=true (Pi-specific). Both are inherited +# by child processes; Pi's bash tool additionally injects PI_SESSION_ID/PI_MODEL, which are +# session state rather than host markers and are deliberately not consulted here. +# A lane started from another host also carries that host's ids; the ids of the innermost +# host win, because the innermost host is the one running this shell. Pi is checked first: +# `pi` launched from a Codex shell would otherwise inherit CODEX_THREAD_ID and misreport. # -# Usage: super-board-host.sh prints `claude` or `codex`, exit 0 -# SUPER_BOARD_HOST=claude|codex forces the answer (tests, or a host the check misreads). +# Usage: super-board-host.sh prints `claude`, `codex` or `pi`, exit 0 +# SUPER_BOARD_HOST=claude|codex|pi forces the answer (tests, or a host the check misreads). set -euo pipefail case "${SUPER_BOARD_HOST:-}" in - claude|codex) echo "$SUPER_BOARD_HOST"; exit 0 ;; + claude|codex|pi) echo "$SUPER_BOARD_HOST"; exit 0 ;; '') ;; - *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude or codex" >&2; exit 64 ;; + *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex or pi" >&2; exit 64 ;; esac -if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then +if [ "${AI_AGENT:-}" = "pi" ] || [ "${PI_CODING_AGENT:-}" = "true" ]; then + echo pi +elif [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then echo codex else echo claude diff --git a/tests/test-host.sh b/tests/test-host.sh index 02f669a4..9c932039 100644 --- a/tests/test-host.sh +++ b/tests/test-host.sh @@ -1,20 +1,29 @@ #!/usr/bin/env bash -# Tests super-board-host.sh — the Codex-host check that turns a plain `run` into `--codex`. +# Tests super-board-host.sh — the Codex/Pi-host check that turns a plain `run` into `--codex`/`--pi`. set -euo pipefail KIT=$(cd "$(dirname "$0")/.." && pwd) S="$KIT/scripts/super-board-host.sh" fail() { echo "FAIL: $1" >&2; exit 1; } -# A clean env, so the test answers the same inside Claude Code, Codex or CI. +# A clean env, so the test answers the same inside Claude Code, Codex, Pi or CI. host() { env -i PATH="$PATH" "$@" bash "$S"; } -[ "$(host)" = claude ] || fail 'no Codex ids means claude' +[ "$(host)" = claude ] || fail 'no host ids means claude' [ "$(host CODEX_THREAD_ID=01a1)" = codex ] || fail 'CODEX_THREAD_ID means codex' [ "$(host CODEX_SESSION_ID=01a1)" = codex ] || fail 'CODEX_SESSION_ID means codex' [ "$(host CODEX_THREAD_ID=01a1 CLAUDECODE=1)" = codex ] || fail 'a Codex lane under Claude is codex' [ "$(host CODEX_HOME=/x CODEX_SANDBOX=seatbelt)" = claude ] || fail 'CODEX_HOME/CODEX_SANDBOX alone are not a host' [ "$(host CODEX_THREAD_ID=)" = claude ] || fail 'an empty id is not a host' +[ "$(host PI_CODING_AGENT=true)" = pi ] || fail 'PI_CODING_AGENT means pi' +[ "$(host AI_AGENT=pi)" = pi ] || fail 'AI_AGENT=pi means pi' +[ "$(host AI_AGENT=pi CLAUDECODE=1)" = pi ] || fail 'a Pi session under Claude is pi' +[ "$(host AI_AGENT=pi CODEX_THREAD_ID=01a1)" = pi ] || fail 'a pi spawned from a Codex shell is pi (innermost wins)' +[ "$(host PI_CODING_AGENT=false)" = claude ] || fail 'PI_CODING_AGENT=false is not pi' +[ "$(host AI_AGENT=codex)" = claude ] || fail 'AI_AGENT alone is not a host' +[ "$(host PI_SESSION_ID=01a1)" = claude ] || fail 'session state alone is not a host' [ "$(host SUPER_BOARD_HOST=codex)" = codex ] || fail 'override forces codex' +[ "$(host SUPER_BOARD_HOST=pi)" = pi ] || fail 'override forces pi' [ "$(host SUPER_BOARD_HOST=claude CODEX_THREAD_ID=01a1)" = claude ] || fail 'override beats the env' +[ "$(host SUPER_BOARD_HOST=claude AI_AGENT=pi)" = claude ] || fail 'override beats the pi env' rc=0; host SUPER_BOARD_HOST=gemini >/dev/null 2>&1 || rc=$? [ "$rc" = 64 ] || fail "bad override exits 64, got $rc" echo "PASS: super-board-host" From bbbb202549cb27b30e03ec443ffea4f632fbb2ac Mon Sep 17 00:00:00 2001 From: juan Date: Sat, 17 Oct 2026 00:39:51 -0500 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20skills-readiness:=20?= =?UTF-8?q?resolve=20Pi's=20real=20skill=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --host pi: project .agents/skills stops at the owning repo root (same boundary as Codex), plus .pi/skills, ~/.agents/skills and the Pi agent directory (PI_CODING_AGENT_DIR, default ~/.pi/agent/skills) - the eight core skills keep the .claude/skills launcher contract for Pi - Pi settings skills/packages cannot be verified here: fail closed with an actionable message and accept an authoritative --catalog instead, mirroring unsupported Codex activation - tests: core direct read, native/user roots, ancestor boundary, settings and catalog paths --- scripts/super-board-skills-ready.py | 63 ++++++++++++++++++++++-- tests/test_skills_readiness.py | 74 +++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/scripts/super-board-skills-ready.py b/scripts/super-board-skills-ready.py index 87ce1a9c..714ffe8c 100755 --- a/scripts/super-board-skills-ready.py +++ b/scripts/super-board-skills-ready.py @@ -251,6 +251,36 @@ def uncomment(line): return disabled, bool(records) +def pi_skill_resources(paths): + """True when a Pi settings file carries skill-affecting config we cannot verify. + + Pi settings can add skills through `skills` resource arrays (plain paths, + `+`/`-`/`!` glob entries) and through `packages` that bundle skills. Resolving + those — glob semantics, trust layers, package contents — is not reimplemented + here: an authoritative catalog is required instead, exactly like unsupported + Codex activation. Unrelated settings (models, themes, tools) never matter. + """ + affected = False + for path in paths: + if not path.exists(): + continue + try: + data = json.loads(path.read_text()) + except ValueError: + raise ValueError('unsupported Pi settings: expected valid JSON in ' + str(path)) + if not isinstance(data, dict): + raise ValueError('unsupported Pi settings: expected an object in ' + str(path)) + for key in ('skills', 'packages'): + value = data.get(key) + if value is None: + continue + if not isinstance(value, list): + raise ValueError('unsupported Pi settings: ' + key + ' must be an array') + if value: + affected = True + return affected + + class Inventory: def __init__(self, root, host, home=None, catalog=None): self.root, self.host = Path(root).resolve(), host @@ -264,6 +294,33 @@ def __init__(self, root, host, home=None, catalog=None): if host == 'claude': self.roots = [self.root / '.claude/skills', self.home / '.claude/skills'] self.claude_plugins() + elif host == 'pi': + # Pi keeps the Agent Skills locations and adds its own roots. Project + # `.agents/skills` search stops at this independent repository's root, + # the same boundary rule Codex uses; `.pi/skills` is the Pi-native + # project surface. User roots: ~/.agents/skills plus the Pi agent + # directory (`PI_CODING_AGENT_DIR`, default ~/.pi/agent). + process = subprocess.run(['git', '-C', str(self.root), 'rev-parse', '--show-toplevel'], + text=True, capture_output=True) + boundary = Path(process.stdout.strip()).resolve() if process.returncode == 0 else self.root + scoped = [self.root] + if boundary in self.root.parents: + for parent in self.root.parents: + scoped.append(parent) + if parent == boundary: + break + pi_home = (self.home / '.pi/agent') if home else Path(os.environ.get('PI_CODING_AGENT_DIR', self.home / '.pi/agent')) + self.roots = [parent / '.agents/skills' for parent in scoped] + self.roots += [self.root / '.pi/skills', self.home / '.agents/skills', pi_home / 'skills'] + try: + if pi_skill_resources([pi_home / 'settings.json', self.root / '.pi/settings.json']): + raise ValueError('Pi skill resources or packages need an authoritative --catalog for effective trusted settings') + except ValueError: + if not catalog: + raise + # Unknown native activation semantics need a complete active inventory. + self.roots = [] + self.catalog_only = True else: # Codex discovery stops at this independent repository's root. process = subprocess.run(['git', '-C', str(self.root), 'rev-parse', '--show-toplevel'], @@ -377,8 +434,8 @@ def resolve(self, name): candidates[1:1] = self.project_exact.get(name, []) else: candidates = list(self.project_exact.get(name, [])) - # Core direct reads are part of the Codex launcher contract, helpers are not. - if self.host == 'codex' and name in CORE and not self.catalog_only: + # Core direct reads are part of the Codex and Pi launcher contract, helpers are not. + if self.host in ('codex', 'pi') and name in CORE and not self.catalog_only: candidates.insert(0, self.root / '.claude/skills' / name / 'SKILL.md') candidates += self.exact.get(name, []) for path in candidates: @@ -454,7 +511,7 @@ def error(self, message): def main(argv=None): parser = UsageParser(description=__doc__) parser.add_argument('--root', required=True) - parser.add_argument('--host', required=True, choices=['claude', 'codex']) + parser.add_argument('--host', required=True, choices=['claude', 'codex', 'pi']) selection = parser.add_mutually_exclusive_group(required=True) selection.add_argument('--cards') selection.add_argument('--profile', choices=['setup']) diff --git a/tests/test_skills_readiness.py b/tests/test_skills_readiness.py index 4b123fe5..506683c3 100644 --- a/tests/test_skills_readiness.py +++ b/tests/test_skills_readiness.py @@ -236,6 +236,80 @@ def test_codex_discovery_stops_at_independent_repository_root(self): path.write_text('in owning repository') self.run_cli(host='codex') + def test_pi_core_direct_read_native_and_user_roots(self): + # The eight core skills keep the `.claude/skills` launcher contract; + # helpers resolve from Pi's own surfaces, never from `.claude` alone. + helpers = ['tdd', 'implement', 'codebase-design', 'code-review', 'diagnosing-bugs', + 'verification-before-completion', 'testing-strategy'] + self.full() + self.run_cli(host='pi', expected=10) + agents = self.root / '.agents/skills'; agents.mkdir(parents=True) + for name in helpers: + (agents / name).symlink_to(self.root / '.claude/skills' / name, target_is_directory=True) + result = self.run_cli(host='pi') + core = next(e for e in result['entries'] if e['name'] == 'super-board') + self.assertEqual(core['resolved_path'], str((self.root / '.claude/skills/super-board/SKILL.md').resolve())) + self.assertTrue(all(Path(e['resolved_path']).is_file() for e in result['entries'] if e['status'] == 'resolved')) + # A helper only present under the native project root `.pi/skills` is found too. + (agents / 'tdd').unlink() + native = self.root / '.pi/skills/tdd/SKILL.md' + native.parent.mkdir(parents=True); native.write_text('native project skill') + result = self.run_cli(host='pi') + self.assertEqual(next(e['resolved_path'] for e in result['entries'] if e['name'] == 'mattpocock-skills:tdd'), str(native.resolve())) + # User surfaces: the Pi agent directory and the shared Agent Skills home. + native.rename(self.home / 'moved-out'); native.parent.rmdir() + user = self.home / '.pi/agent/skills/tdd/SKILL.md' + user.parent.mkdir(parents=True); user.write_text('pi agent directory skill') + result = self.run_cli(host='pi') + self.assertEqual(next(e['resolved_path'] for e in result['entries'] if e['name'] == 'mattpocock-skills:tdd'), str(user.resolve())) + user.unlink(); user.parent.rmdir() + shared = self.put('tdd', host='pi', root=self.home) + result = self.run_cli(host='pi') + self.assertEqual(next(e['resolved_path'] for e in result['entries'] if e['name'] == 'mattpocock-skills:tdd'), str(shared.resolve())) + + def test_pi_discovery_stops_at_independent_repository_root(self): + self.full('pi') + subprocess.run(['git','init','-q',str(self.root)], check=True) + path = self.root / '.agents/skills/tdd/SKILL.md'; path.unlink() + outside = self.root.parent / '.agents/skills/tdd/SKILL.md' + outside.parent.mkdir(parents=True); outside.write_text('outside owning repository') + self.run_cli(host='pi', expected=10) + nested = self.root / 'nested'; nested.mkdir() + self.root = nested + # The project root is still visible from an in-repository subdirectory. + path.write_text('in owning repository') + self.run_cli(host='pi') + + def test_pi_skill_resources_and_packages_require_catalog(self): + self.full('pi') + settings = self.root / '.pi/settings.json'; settings.parent.mkdir() + settings.write_text('{"theme":"system"}') + self.run_cli(host='pi') + for payload in ['{"skills":["./extra"]}', '{"packages":["npm:@fixture/skills"]}', + '{"skills":"nope"}', 'not json']: + settings.write_text(payload) + diagnostic = self.run_cli(host='pi', expected=64) + self.assertIn('Pi', diagnostic) + self.assertNotIn('Traceback', diagnostic) + settings.write_text('{"skills":[],"packages":[]}') + self.run_cli(host='pi') + # User-level resources affect every project and fail closed the same way. + user_settings = self.home / '.pi/agent/settings.json' + user_settings.parent.mkdir(parents=True) + user_settings.write_text('{"skills":["~/skills"]}') + self.run_cli(host='pi', expected=64) + user_settings.write_text('{}') + # An authoritative catalog resolves the effective inventory instead. + settings.write_text('{"skills":["./extra"]}') + catalog = self.root / 'active.json' + entries = [] + for name in CORE + ['tdd', 'implement', 'codebase-design', 'code-review', 'diagnosing-bugs', + 'verification-before-completion', 'testing-strategy']: + entries.append(dict(name=name, path=str((self.root / '.agents/skills' / name / 'SKILL.md').resolve()), + enabled=True, scope='project', project_root=str(self.root))) + catalog.write_text(json.dumps(dict(host='pi', skills=entries))) + self.run_cli(host='pi', extra=['--catalog', str(catalog)]) + def test_codex_native_disabled_paths_apply_to_real_symlink_targets(self): self.full('codex') path = self.root / '.agents/skills/tdd/SKILL.md' From d5930bea0e1b6de7bef1eaf2976ca940a069cd35 Mon Sep 17 00:00:00 2001 From: juan Date: Sat, 17 Oct 2026 00:45:59 -0500 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20pi:=20one=20headless?= =?UTF-8?q?=20Pi=20lane=20with=20explicit=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pi -p -a with a deterministic tool allowlist; project trust comes from -a so .pi/extensions (guards) and project skills load - explicit --pi[=model] is required: unlike Codex there is no Claude-usage fallback that may start Pi on its own - model precedence --pi= > pi.model > pi.ladders (or defaults) by tier x complexity; ungraded cards take the medium cell - Pi has no --output-schema: the worker writes its final JSON to a private result file and the wrapper validates it fail-closed; exit 9/10/79 keep the verification/skills/halt contract - test: opt-in refusal, flags, model precedence, ladders, review consent, result-file failures, separate-git-dir worktree --- scripts/super-board-pi-lane.sh | 216 +++++++++++++++++++++++++++++++++ tests/test-pi-lane.sh | 170 ++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100755 scripts/super-board-pi-lane.sh create mode 100644 tests/test-pi-lane.sh diff --git a/scripts/super-board-pi-lane.sh b/scripts/super-board-pi-lane.sh new file mode 100755 index 00000000..f1044e94 --- /dev/null +++ b/scripts/super-board-pi-lane.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# One headless Pi lane. --pi is the run owner's explicit opt-in; a Pi-host +# session counts as explicit (see references/run-workflow.md → "Pi runs"). +# Without --pi the lane refuses (exit 78): unlike Codex there is no +# Claude-usage fallback chain that could start Pi on its own. +# Usage: --config f --card '' --lane build|qa|review [--pi[=model]] +# [--tier low|medium|high] [--complexity low|medium|high] +# Model: --pi=, else pi.model, else pi.ladders (config) or PI_LADDERS +# [tier][complexity] when --tier is given (an ungraded card takes the medium +# cell), else Pi's own default model. +# Result: Pi has no --output-schema/--output-last-message flags, so the worker +# is told to write its final JSON to a private file and the wrapper validates +# it fail-closed, exactly like the Codex lane validates its schema output. +set -euo pipefail +exec python3 - "${BASH_SOURCE[0]}" "$@" <<'PY' +import argparse +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile + +def fail(message, code=64): + print(message, file=sys.stderr) + sys.exit(code) + +parser = argparse.ArgumentParser(description='Run one Pi board lane (no Claude sub-agents).') +parser.add_argument('--config', required=True) +parser.add_argument('--card', required=True, help='card JSON or a JSON file') +parser.add_argument('--lane', required=True, choices=['build', 'qa', 'review']) +parser.add_argument('--tier', choices=['low', 'medium', 'high'], help='run ladder (--low / default / --high)') +parser.add_argument('--complexity', choices=['low', 'medium', 'high'], help="router's grade for this card") +raw = sys.argv[2:] +explicit = False +model = None +rest = [] +for arg in raw: + if arg == '--pi': + explicit = True + elif arg.startswith('--pi='): + explicit = True + model = arg.partition('=')[2] + if not model.strip(): + fail('--pi= requires a non-empty model') + else: + rest.append(arg) +args = parser.parse_args(rest) +script_path = Path(sys.argv[1]).resolve() +if not explicit: + # Review and merge ownership always need the run owner's explicit choice; + # build/qa have no Pi fallback path either, so every non-explicit start is + # a mistake rather than a degrade. + if args.lane == 'review': + fail('Pi review refused: run must be started with explicit --pi; no fallback backend can own a merge.', 78) + fail('Pi lane refused: pass --pi (or run from a Pi session) — there is no Pi usage fallback.', 78) +try: + config_path = Path(args.config).resolve() + config = json.loads(config_path.read_text()) + card = json.loads(args.card if args.card.lstrip().startswith('{') else Path(args.card).read_text()) + if not isinstance(config, dict) or not isinstance(card, dict): + raise ValueError('config/card must be objects') + if type(card.get('number')) is not int or card['number'] < 1 or not isinstance(card.get('status'), str): + raise ValueError('card needs a positive issue number and status') + configured = config.get('pi', {}).get('model') if config.get('pi') else None + if configured is not None and not isinstance(configured, str): + raise ValueError('pi.model must be a string') + model = model if model is not None else (configured if configured and configured.strip() else None) + # Pi run ladders, the one copy: run tier x card complexity -> `pi --model`. + # Pi resolves patterns fuzzily across whatever providers the user connected, + # so the defaults are the Claude-family names with an optional `:thinking` + # suffix; boards on other providers override pi.ladders in the config. + PI_LADDERS = { + 'low': {'low': 'haiku', 'medium': 'sonnet', 'high': 'sonnet'}, + 'medium': {'low': 'sonnet', 'medium': 'sonnet', 'high': 'opus'}, + 'high': {'low': 'opus', 'medium': 'opus', 'high': 'opus'}, + } + configured_ladders = (config.get('pi') or {}).get('ladders') + if configured_ladders is not None: + if (not isinstance(configured_ladders, dict) or sorted(configured_ladders) != sorted(PI_LADDERS) + or any(not isinstance(row, dict) or sorted(row) != ['high', 'low', 'medium'] + or not all(isinstance(value, str) and value.strip() for value in row.values()) + for row in configured_ladders.values())): + raise ValueError('pi.ladders must mirror the ladder shape with non-empty string models') + PI_LADDERS = configured_ladders + if model is None and args.tier: + model = PI_LADDERS[args.tier][args.complexity or 'medium'] +except (OSError, ValueError, AttributeError) as error: + fail(f'Invalid Pi lane input: {error}') + +column = card['status'] +def result(status, detail): + print(json.dumps({'status': status, 'column': column, 'detail': detail})) + +readiness = script_path.with_name('super-board-verification-ready.py') +def require_verification_ready(): + if subprocess.run([sys.executable, str(readiness), '--config', str(config_path)]).returncode: + result('halted', 'verification-readiness: repair verify_commands; preserve claims, worktrees and card state, then restart explicitly') + sys.exit(9) +require_verification_ready() + +def git_path(option): + return Path(subprocess.check_output(['git', 'rev-parse', option], text=True).strip()).resolve() + +try: + root = git_path('--show-toplevel') + git_dir = git_path('--git-dir') + common_dir = git_path('--git-common-dir') +except subprocess.CalledProcessError: + fail('Pi lane must start inside the target git repository') +os.chdir(root) +helper = script_path.with_name('super-board-github-read.py') +if not helper.exists() or subprocess.run([sys.executable, str(helper), '--check']).returncode: + result('halted', 'required GitHub reads halted or helper missing; preserve claims and worktrees') + sys.exit(79) + +resolver_path = script_path.with_name('super-board-skills-ready.py') +try: + spec = importlib.util.spec_from_file_location('skills_ready', resolver_path) + resolver = importlib.util.module_from_spec(spec) + spec.loader.exec_module(resolver) + resolver.hydrate([card], root) + skills = resolver.report(root, 'pi', [card], lane=args.lane, + home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), catalog=os.environ.get('SUPER_BOARD_SKILLS_CATALOG')) +except SystemExit as error: + result('halted', 'required GitHub reads halted while hydrating skills; preserve claims and worktrees') + sys.exit(error.code) +except (OSError, ValueError, TypeError, KeyError) as error: + result('halted', 'skills-readiness: ' + str(error)) + sys.exit(64) +if not skills['ready']: + result('halted', 'skills-readiness: missing ' + ', '.join(dict.fromkeys(e['name'] for e in skills['entries'] if e['status'] == 'missing'))) + sys.exit(10) + +skill = {'build': 'super-build', 'qa': 'super-qa', 'review': 'super-review'}[args.lane] +section = {'build': 'Builder', 'qa': 'Tester', 'review': 'Reviewer'}[args.lane] + +# The launcher's own model/flag contract, split from the prompt so both the +# review consent text and the result-file path can reference them. +counts = {key: {'type': 'integer'} for key in ['fixed', 'notFixed', 'noLongerApplies']} +properties = { + 'status': {'type': 'string', 'enum': ['advanced', 'bounced', 'blocked', 'human-gate', 'failed', 'halted']}, + 'column': {'type': 'string'}, 'detail': {'type': 'string'}, + 'prUrl': {'type': ['string', 'null']}, 'branch': {'type': ['string', 'null']}, + 'priorFindings': {'anyOf': [{'type': 'null'}, {'type': 'object', 'properties': counts, + 'required': list(counts), 'additionalProperties': False}]}, +} +child = None +def interrupted(signum, frame): + if child is not None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + result('halted', 'lane interrupted; preserve claims and worktrees') + sys.exit(128 + signum) +signal.signal(signal.SIGTERM, interrupted) +signal.signal(signal.SIGINT, interrupted) +with tempfile.TemporaryDirectory(prefix='super-board-pi-') as temporary: + result_path = Path(temporary) / 'result.json' + prompt = f'''Run {skill} for issue #{card['number']} as a Pi lane worker. +First operation: recheck .claude/bin/super-board-skills-ready.py --root {root} --host pi --cards - --lane {args.lane} with the Card JSON below as a one-element stdin array, before worktree creation, classification or lifecycle writes. Exit 10 means status=halted, detail=skills-readiness:, preserve all state. Load resolved_path files and only declared fallback IDs from the report. Before activating any conditional helper, repeat with --require . +Resolved skill report: {json.dumps(skills)} +Config: {config_path} +Card: {json.dumps(card)} +Read .claude/skills/{skill}/SKILL.md and .claude/skills/super-board/references/run.md → {section} lifecycle; follow all its board, branch, worktree, comments, test and merge requirements. +Source .claude/bin/super-board-gh-guard.sh and use its quota checks. Required GitHub reads go through .claude/bin/super-board-github-read.py; a read that misses its --kind shape exits 64 for that call only and prints the valid form, so fix the read. Check --check before new writes, migrations and merges; exit 79 means status=halted, no more GitHub calls, preserve claims/worktree/card/approval. Never retry a mutation. +Use .claude/bin/super-board-card.sh for board reads/moves and .claude/bin/super-board-pr-body.sh for owned PR blocks; follow writing-standard.md and pr-author-notes.md. +Use your own worktree under .claude/worktrees/; never edit the main checkout. Clean up on ordinary exit, preserve work on halt/interruption. +Pi adaptation: do not launch Claude or other sub-agents. Perform preflight, classification, independent review and truth-check duties inline. This changes the execution host, never the evidence requirements, confidence threshold or merge policy. +Result file: {result_path} +Write your final JSON object (no other content) to the Result file above with the write tool as your last action; do not print it to stdout. Shape: status=advanced|bounced|blocked|human-gate|failed|halted; column=current board column; detail=one line; prUrl/branch when known, otherwise null; priorFindings=review counts or null. A non-advanced exit ends this card's lane chain. On merge-gate exit 9, return status=halted with detail starting exactly "verification-readiness:"; preserve work and never bounce/retry. +''' + if args.lane == 'build': + prompt += '''Before creating any worktree or changing code, perform run.md's Builder pre-flight inline: call super-board-preflight.sh, judge its candidates semantically, and act on proceed/hold/sequence/skipped. Blind preflight leaves Ready and returns failed. Your own issue branch PR is not a duplicate. Read the issue and all comments; preserve qa/bug/feature labels. Existing docs/ux/tests/refactor/tech-debt rows keep their label chain: classify complexity without adding feature/bug. If truly untyped, classify feature or bug, re-run skills-ready on the proposed labelled card before adding that label, never qa. Then follow the Builder lifecycle. +''' + elif args.lane == 'qa' and card['status'] == 'Ready': + prompt += 'This qa-labelled Ready card skips Building: move Ready → QA and create the issue branch from the base; test the existing product per run.md qa cards.\n' + elif args.lane == 'review': + prompt += '''The owner explicitly chose run --pi: you may review and own the merge. First load the latest ; grade prior R-ids fixed/not fixed/no longer applies, bounce any not fixed and edit the same report in place. Form hypotheses from the issue AC and diff before reading the Builder narrative. +Rerun the Tester's tests. When truth_gate triggers, apply super-review's fixed Adversarial mode brief inline in two distinct passes (code-grounder and historian); collect evidence and aggregate confidence against truth_threshold. Over-engineering is Should fix, never Blocker alone and never lowers confidence. Missing evidence fails closed. +Merge ONLY through the unchanged .claude/bin/super-board-merge-gate.sh, pinning the exact head you reviewed at review pass using --expect-head "$HEAD" --config --pr --subject --body-file <msg.md>. Never run bare gh pr merge or bypass the gate. Follow run.md Merge protocol and route every gate exit exactly: 2/5 rebase pass, 3 Blocked, 4 stay Review, 6 fresh review, 7/8 human gate with pinned approval-request; 9 verification-readiness means halt and preserve card/worktree/approval/claims until config repair and explicit restart, never a Build bounce or rebase retry. Respect human_approves_merge and merge_policy. Confirm MERGED <sha>, fetch base and prove merge-base --is-ancestor <sha> origin/<base> before closing issue or moving Done. +''' + # Pi needs no sandbox flags: it has no approval system, and the project's + # guard extension plus the worktree contract carry the same protections. + # -a trusts this repository's project config so .pi/extensions (guards) and + # project skills load; the tool allowlist keeps the lane deterministic. + command = ['pi', '-p', '-a', '--tools', 'read,bash,edit,write,grep,find,ls'] + if model and model.strip(): + command += ['--model', model] + command.append(prompt) + try: + child = subprocess.Popen(command, stdout=sys.stderr, text=True) + child.wait() + require_verification_ready() + if child.returncode: + result('halted' if child.returncode in (9, 10, 79) else 'failed', + 'verification-readiness: repair verify_commands and restart explicitly; preserve work' + if child.returncode == 9 else 'skills-readiness: required skills became unavailable; preserve work' if child.returncode == 10 else f'pi exited {child.returncode}') + sys.exit(child.returncode if child.returncode in (9, 10, 79) else 1) + parsed = json.loads(result_path.read_text()) + if (not isinstance(parsed, dict) or parsed.get('status') not in properties['status']['enum'] + or not all(isinstance(parsed.get(key), str) for key in ['column', 'detail'])): + raise ValueError('lane result needs status, column and detail') + print(json.dumps(parsed)) + sys.exit(10 if parsed['status'] == 'halted' and parsed['detail'].startswith('skills-readiness:') + else 9 if parsed['status'] == 'halted' and parsed['detail'].startswith('verification-readiness:') + else 79 if parsed['status'] == 'halted' else 0) + except (OSError, ValueError) as error: + result('failed', f'Pi returned no valid lane result: {error}') + sys.exit(1) +PY diff --git a/tests/test-pi-lane.sh b/tests/test-pi-lane.sh new file mode 100644 index 00000000..d128d70f --- /dev/null +++ b/tests/test-pi-lane.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Stubbed Pi only: explicit opt-in, model precedence and ladders, review consent, +# deterministic tool allowlist, result-file failures and wave routing/concurrency/ +# halt/stop. No network. +set -euo pipefail +KIT=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/repo" +git init -q "$T/repo" +python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/repo" +export SUPER_BOARD_SKILLS_HOME="$T/home"; mkdir -p "$SUPER_BOARD_SKILLS_HOME" +cat > "$T/bin/gh" <<'PYGH' +#!/usr/bin/env python3 +import json,sys +args=sys.argv[1:] +if args[:2]==['issue','view']: + print(json.dumps({'labels':[]} if args[args.index('--json')+1] == 'labels' else {'number':int(args[2]),'title':'fixture','body':''})) +else: + sys.exit('unexpected gh fixture call: '+str(args)) +PYGH +chmod +x "$T/bin/gh" +export SB_PI_TEST_LOG="$T/calls.jsonl" +export PATH="$T/bin:$PATH" +cat > "$T/bin/pi" <<'PY' +#!/usr/bin/env python3 +import fcntl, json, os, re, subprocess, sys, time +from pathlib import Path +args = sys.argv[1:] +prompt = args[-1] if args else '' +model = args[args.index('--model') + 1] if '--model' in args else 'default' +def log(entry): + with open(os.environ['SB_PI_TEST_LOG'], 'a') as stream: + fcntl.flock(stream, fcntl.LOCK_EX) + stream.write(json.dumps(entry) + '\n') +if 'Grade complexity' in prompt: + log(dict(args=args, model=model, number=int(re.search(r'issue #(\d+)', prompt).group(1)), lane='grade', active=1)) + output = Path(re.search(r'^Result file: (.+)$', prompt, re.M).group(1)) + output.write_text(json.dumps(dict(complexity=os.environ.get('SB_PI_TEST_GRADE', 'medium')))) + sys.exit(int(os.environ.get('SB_PI_TEST_GRADE_EXIT', '0'))) +card = json.loads(re.search(r'^Card: (.+)$', prompt, re.M).group(1)) +lane = re.search(r'Run super-(build|qa|review)', prompt).group(1) +with open(os.environ['SB_PI_TEST_LOG'], 'a') as stream: + fcntl.flock(stream, fcntl.LOCK_EX) + counter = Path(os.environ['SB_PI_TEST_LOG'] + '.active') + active = int(counter.read_text()) + 1 if counter.exists() else 1 + counter.write_text(str(active)) + stream.write(json.dumps(dict(args=args, model=model, prompt=prompt, number=card['number'], lane=lane, active=active)) + '\n') +if os.environ.get('SB_PI_TEST_SLEEP'): + descendant = subprocess.Popen(['sleep', '60']) + Path(os.environ['SB_PI_TEST_PID']).write_text(str(descendant.pid)) + time.sleep(60) +time.sleep(float(os.environ.get('SB_PI_TEST_DELAY', '0'))) +output = Path(re.search(r'^Result file: (.+)$', prompt, re.M).group(1)) +status = os.environ.get('SB_PI_TEST_STATUS', 'advanced') +column = {'build':'QA', 'qa':'Review', 'review':'Done'}[lane] if status == 'advanced' else card['status'] +if os.environ.get('SB_PI_TEST_INVALID'): + output.write_text('not json') +else: + output.write_text(json.dumps(dict(status=status, column=column, detail=os.environ.get('SB_PI_TEST_DETAIL', 'stub lane result'), prUrl=None, branch=None, priorFindings=None))) +print('pi log noise must not appear in result stdout') +with open(os.environ['SB_PI_TEST_LOG'], 'a') as stream: + fcntl.flock(stream, fcntl.LOCK_EX) + counter.write_text(str(int(counter.read_text()) - 1)) +sys.exit(int(os.environ.get('SB_PI_TEST_EXIT', '0'))) +PY +chmod +x "$T/bin/pi" +cd "$T/repo" +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +CARD='{"number":1,"status":"Ready","title":"example"}' +lane() { bash "$KIT/scripts/super-board-pi-lane.sh" --config "$T/config.json" --card "$CARD" --lane "$@" 2>"$T/stderr"; } +fail() { echo "FAIL: $*" >&2; cat "$T/stderr" >&2; exit 1; } +last_model() { tail -1 "$SB_PI_TEST_LOG" | jq -r '.model'; } + +RC=0; lane review >"$T/out" || RC=$? +[ "$RC" -eq 78 ] && [ ! -e "$SB_PI_TEST_LOG" ] || fail 'non-explicit review must refuse before Pi' +echo '{"usage_fallback":"pi","verify_commands":["git diff --check"]}' > "$T/config.json" +RC=0; lane build >"$T/out" || RC=$? +[ "$RC" -eq 78 ] || fail 'Pi has no usage fallback; a bare build must still refuse' +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +lane review --pi >"$T/out" +jq -e '.status == "advanced" and .column == "Done"' "$T/out" >/dev/null || fail 'explicit review allowed' +grep -q -- '--expect-head' "$SB_PI_TEST_LOG" || fail 'review must pin reviewed head to merge gate' +grep -q 'Never run bare gh pr merge' "$SB_PI_TEST_LOG" || fail 'review must keep merge protocol' +[ "$(last_model)" = default ] || fail 'no model uses the Pi default' +python3 - "$SB_PI_TEST_LOG" <<'PY' +import json, pathlib, sys +call = json.loads(pathlib.Path(sys.argv[1]).read_text().splitlines()[-1]) +args = call['args'] +assert args[0] == '-p' and '-a' in args, args +assert args[args.index('--tools') + 1] == 'read,bash,edit,write,grep,find,ls', args +assert '--model' not in args, args +assert 'Result file:' in call['prompt'] and 'Write your final JSON' in call['prompt'], call['prompt'][-400:] +assert 'Pi adaptation: do not launch Claude or other sub-agents' in call['prompt'] +PY +lane build --pi=flag-model >"$T/out" +[ "$(last_model)" = flag-model ] || fail 'flag model must reach --model' +echo '{"pi":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane qa --pi >"$T/out" +[ "$(last_model)" = config-model ] || fail 'config model must reach --model' +lane qa --pi=flag-model >"$T/out" +[ "$(last_model)" = flag-model ] || fail 'flag must beat config' +RC=0; lane build --pi= >"$T/out" || RC=$? +[ "$RC" -eq 64 ] || fail 'empty explicit model must refuse' +echo '{"pi":{"model":7},"verify_commands":["git diff --check"]}' > "$T/config.json" +RC=0; lane build --pi >"$T/out" || RC=$? +[ "$RC" -eq 64 ] || fail 'non-string pi.model must refuse' + +# Ladders: run tier x card grade -> model; an ungraded card takes the medium cell. +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +while read -r TIER GRADE WANT; do + lane build --pi --tier "$TIER" ${GRADE:+--complexity "$GRADE"} >"$T/out" + [ "$(last_model)" = "$WANT" ] || fail "ladder $TIER/$GRADE must pick $WANT, got $(last_model)" +done <<'CELLS' +medium low sonnet +medium medium sonnet +medium high opus +low low haiku +low medium sonnet +low high sonnet +high low opus +high medium opus +high high opus +CELLS +lane qa --pi --tier medium >"$T/out" +[ "$(last_model)" = sonnet ] || fail 'ungraded default card takes the medium cell' +lane qa --pi --tier low >"$T/out" +[ "$(last_model)" = sonnet ] || fail 'ungraded --low card takes the medium cell' +lane build --pi=flag-model --tier high --complexity high >"$T/out" +[ "$(last_model)" = flag-model ] || fail 'explicit --pi=<model> beats the ladder' +echo '{"pi":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --pi --tier low --complexity low >"$T/out" +[ "$(last_model)" = config-model ] || fail 'pi.model beats the ladder' +echo '{"pi":{"model":""},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --pi --tier high --complexity low >"$T/out" +[ "$(last_model)" = opus ] || fail 'empty pi.model leaves the ladder in charge' +echo '{"pi":{"ladders":{"low":{"low":"l-low","medium":"l-med","high":"l-high"},"medium":{"low":"m-low","medium":"m-med","high":"m-high"},"high":{"low":"h-low","medium":"h-med","high":"h-high"}}},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --pi --tier high --complexity low >"$T/out" +[ "$(last_model)" = h-low ] || fail 'pi.ladders override must drive the ladder' +echo '{"pi":{"ladders":{"low":{"low":"x"}}},"verify_commands":["git diff --check"]}' > "$T/config.json" +RC=0; lane build --pi --tier low >"$T/out" || RC=$? +[ "$RC" -eq 64 ] || fail 'malformed pi.ladders must refuse' +RC=0; lane build --pi --tier extreme >"$T/out" || RC=$? +[ "$RC" -ne 0 ] || fail 'unknown tier must refuse' + +# Failures: the result file is the only trusted output, fail closed. +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +SB_PI_TEST_INVALID=1 RC=0 +export SB_PI_TEST_INVALID +lane build --pi >"$T/out" || RC=$? +[ "$RC" -eq 1 ] && jq -e '.status == "failed"' "$T/out" >/dev/null || fail 'invalid result fails' +unset SB_PI_TEST_INVALID +RC=0; SB_PI_TEST_EXIT=2 lane build --pi >"$T/out" || RC=$? +[ "$RC" -eq 1 ] && jq -e '.status == "failed"' "$T/out" >/dev/null || fail 'nonzero pi exit never trusts success JSON' +RC=0; SB_PI_TEST_EXIT=9 lane build --pi >"$T/out" || RC=$? +[ "$RC" -eq 9 ] && jq -e '.status == "halted" and (.detail | startswith("verification-readiness:"))' "$T/out" >/dev/null || fail 'pi gate exit 9 preserves the verification halt' +RC=0; SB_PI_TEST_EXIT=10 lane build --pi >"$T/out" || RC=$? +[ "$RC" -eq 10 ] && jq -e '.status == "halted" and (.detail | startswith("skills-readiness:"))' "$T/out" >/dev/null || fail 'pi gate exit 10 preserves the skills halt' +RC=0; SB_PI_TEST_STATUS=halted SB_PI_TEST_DETAIL='verification-readiness: checks missing' lane review --pi >"$T/out" || RC=$? +[ "$RC" -eq 9 ] && jq -e '.status == "halted"' "$T/out" >/dev/null || fail 'structured readiness halt preserves code 9' + +# A separate-git-dir worktree still launches: Pi has no sandbox flags, only -a +# (trust this project's .pi config) and the deterministic tool allowlist. +mkdir -p "$T/super/.git/modules" +git init -q --separate-git-dir="$T/super/.git/modules/kit" "$T/submodule" +python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/submodule" +cd "$T/submodule" +lane review --pi >"$T/out" +jq -e '.status == "advanced"' "$T/out" >/dev/null || fail 'a separate-git-dir worktree must launch a Pi lane' +cd "$T/repo" +echo "PASS: test-pi-lane.sh (opt-in/models/ladders/review/results)" From 5822b0232680ac950e5176b844608a6b095b5435 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 00:50:24 -0500 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20pi:=20wave=20dispatc?= =?UTF-8?q?her,=20stop=20and=20calibration=20awareness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - super-board-pi-wave.sh: explicit --pi required, haiku router (pi.router_model) grades Ready cards, pinned models skip it, lane process groups/locks/halt contract identical to the Codex wave - super-board-stop.sh: stop Codex and Pi waves through one marker table, each wave TERM-cancels its lane groups before any claim is released - calibration refuses a completed receipt while a pi-wave marker exists - tests: router/pinning/fallback, routing summary, halt/readiness locks, concurrency, TERM cancellation and the Pi stop marker --- scripts/super-board-calibration.py | 3 +- scripts/super-board-pi-wave.sh | 278 +++++++++++++++++++++++++++++ scripts/super-board-stop.sh | 43 +++-- tests/test-codex-stop.sh | 29 ++- tests/test-pi-lane.sh | 106 ++++++++++- 5 files changed, 438 insertions(+), 21 deletions(-) create mode 100755 scripts/super-board-pi-wave.sh diff --git a/scripts/super-board-calibration.py b/scripts/super-board-calibration.py index 86a44acf..b52a1b21 100755 --- a/scripts/super-board-calibration.py +++ b/scripts/super-board-calibration.py @@ -108,7 +108,8 @@ def prepare(config, receipt_path, cwd): require(not inflight.exists() or not any(re.fullmatch(r'\d+', p.name) for p in inflight.iterdir()), 'unfinished-work') require(not (inflight / 'workflow-wave.lock').exists() and - not (root / '.claude/super-board/codex-wave.pid').exists(), 'unfinished-work') + not (root / '.claude/super-board/codex-wave.pid').exists() and + not (root / '.claude/super-board/pi-wave.pid').exists(), 'unfinished-work') selected = receipt.get('selected_issues') cards = receipt.get('cards') require(isinstance(selected, list) and 0 < len(selected) <= 100 and diff --git a/scripts/super-board-pi-wave.sh b/scripts/super-board-pi-wave.sh new file mode 100755 index 00000000..3e318b55 --- /dev/null +++ b/scripts/super-board-pi-wave.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Dispatch an explicitly selected Pi wave; stdout/--output matches the +# Claude workflow's {cards:[summary...], halted} reconciliation contract. +# Usage: --config f --cards '<JSON array>' --pi[=model] [--tier low|medium|high] [--output f] +# Unless one model is pinned (--pi=<model> or pi.model), a cheap router +# (pi.router_model, default haiku) grades each Ready card low/medium/high; the +# lane launcher maps tier x grade to a model through its PI_LADDERS table. +set -euo pipefail +exec python3 - "${BASH_SOURCE[0]}" "$@" <<'PY' +import argparse +import importlib.util +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import threading +import time + +parser = argparse.ArgumentParser(description='Dispatch one explicitly opted-in Pi wave.') +parser.add_argument('--config', required=True) +parser.add_argument('--cards', required=True, help='planner card array JSON or JSON file') +parser.add_argument('--output') +parser.add_argument('--tier', default='medium', choices=['low', 'medium', 'high']) +raw = sys.argv[2:] +pi_flags = [arg for arg in raw if arg == '--pi' or arg.startswith('--pi=')] +if len(pi_flags) != 1 or (pi_flags[0].startswith('--pi=') and not pi_flags[0].partition('=')[2].strip()): + parser.error('exactly one explicit --pi[=<non-empty-model>] is required') +args = parser.parse_args([arg for arg in raw if arg not in pi_flags]) +output_path = Path(args.output).resolve() if args.output else None +launcher = Path(sys.argv[1]).resolve().with_name('super-board-pi-lane.sh') +try: + config_path = Path(args.config).resolve() + config = json.loads(config_path.read_text()) + cards = json.loads(args.cards if args.cards.lstrip().startswith('[') else Path(args.cards).read_text()) + if not isinstance(config, dict) or not isinstance(cards, list): + raise ValueError('config must be an object; cards must be an array') + numbers = set() + for card in cards: + if (not isinstance(card, dict) or type(card.get('number')) is not int or card['number'] < 1 + or card.get('status') not in ['Ready', 'QA', 'Review']): + raise ValueError('each card needs a positive number and Ready/QA/Review status') + if card['number'] in numbers: + raise ValueError('duplicate card number') + numbers.add(card['number']) + if card.get('lane') not in [None, 'build', 'qa', 'review']: + raise ValueError('invalid card lane') + if card['status'] == 'Ready' and card.get('lane') == 'review': + raise ValueError('a Ready card must enter build or qa') + workers = config.get('max_workers', 0) + if type(workers) is not int or workers < 0: + raise ValueError('max_workers must be a non-negative integer') + workers = workers or max(1, min(16, (os.cpu_count() or 2) - 2)) + root = Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()).resolve() + configured = (config.get('pi') or {}).get('model') + pinned = pi_flags[0].startswith('--pi=') or (isinstance(configured, str) and configured.strip()) + router = (config.get('pi') or {}).get('router_model') or 'haiku' + if not isinstance(router, str) or not router.strip(): + raise ValueError('pi.router_model must be a non-empty string') + ROUTER_MODEL = router +except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.error(str(error)) +readiness = launcher.with_name('super-board-verification-ready.py') +if subprocess.run([sys.executable, str(readiness), '--config', str(config_path)]).returncode: + result = json.dumps({'cards': [dict(number=c['number'], finalStatus='halted', lastLane='none', + column=c['status'], detail='verification-readiness: repair verify_commands and restart explicitly', + lanesRun='none:halted') for c in cards], 'halted': True}) + if output_path: + output_path.write_text(result + '\n') + print(result) + sys.exit(9) +os.chdir(root) +resolver_path = launcher.with_name('super-board-skills-ready.py') +try: + spec = importlib.util.spec_from_file_location('skills_ready', resolver_path) + resolver = importlib.util.module_from_spec(spec) + spec.loader.exec_module(resolver) + resolver.hydrate(cards, root) + skills = resolver.report(root, 'pi', cards, home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), + catalog=os.environ.get('SUPER_BOARD_SKILLS_CATALOG')) +except SystemExit as error: + code, detail = error.code, 'required GitHub reads halted while hydrating skills; preserve claims and worktrees' +except (OSError, ValueError, TypeError, KeyError) as error: + code, detail = 64, 'skills-readiness: ' + str(error) +else: + code = 0 if skills['ready'] else 10 + detail = 'skills-readiness: missing ' + ', '.join(dict.fromkeys(e['name'] for e in skills['entries'] if e['status'] == 'missing')) +if code: + result = json.dumps(dict(cards=[dict(number=c['number'], finalStatus='halted', lastLane='none', column=c['status'], + detail=detail, lanesRun='none:halted') for c in cards], halted=True)) + if output_path: + output_path.write_text(result + '\n') + print(result) + sys.exit(code) +readiness_halted = threading.Event() +skills_halted = threading.Event() +halted = threading.Event() +mutex = threading.RLock() +active = set() +inflight = root / '.claude/super-board/inflight' +marker = root / '.claude/super-board/pi-wave.pid' +inflight.mkdir(parents=True, exist_ok=True) +owner = str(os.getpid()) + +def cancel(signum, frame): + halted.set() + with mutex: + processes = list(active) + for process in processes: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + # Process groups include the launcher, Pi, and shell descendants. Kill + # lingering descendants too, even when their launcher already exited. + time.sleep(2) + for process in processes: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + +signal.signal(signal.SIGTERM, cancel) +signal.signal(signal.SIGINT, cancel) +try: + with marker.open('x') as stream: + stream.write(owner + '\n') +except FileExistsError: + parser.error('Pi wave PID marker exists; reconcile the previous wave before starting another') + +# The router only grades scope; it writes nothing. A Pi run grades with the +# configured cheap model (pi.router_model, default haiku) rather than waking +# Claude; a failed grade falls back to the medium ladder cell. +def grade(card): + """Return (complexity or None, halted). A failed grade falls back to the medium cell.""" + if pinned or card['status'] != 'Ready': + return card.get('complexity') if card.get('complexity') in ['low', 'medium', 'high'] else None, False + if card.get('complexity') in ['low', 'medium', 'high']: + return card['complexity'], False + prompt = (f"Read GitHub issue #{card['number']} (\"{card.get('title', '')}\") - body and all comments - with " + f".claude/bin/super-board-github-read.py --kind issue -- issue view {card['number']} --json number,title,body and " + f"--kind comments -- api repos/{{owner}}/{{repo}}/issues/{card['number']}/comments --paginate --slurp. " + 'Grade complexity (low|medium|high) by the scope of change required. Read only: change no file, ' + 'label, comment or board state. Exit 79 from the read helper means stop and return nothing.\n') + with tempfile.TemporaryDirectory(prefix='super-board-pi-grade-') as temporary: + output = Path(temporary) / 'grade.json' + prompt += (f'Result file: {output}\n' + 'Write your final JSON object {"complexity": "low"|"medium"|"high"} (no other content) to the ' + 'Result file above with the write tool.\n') + command = ['pi', '-p', '-a', '--tools', 'read,bash,write', '--model', ROUTER_MODEL, prompt] + with mutex: + if halted.is_set(): + return None, True + try: + process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + text=True, start_new_session=True) + except OSError: + return None, False + active.add(process) + try: + process.wait() + finally: + with mutex: + active.discard(process) + if process.returncode == 79: + return None, True + try: + value = json.loads(output.read_text()).get('complexity') + except (OSError, ValueError, AttributeError): + return None, False + return (value if value in ['low', 'medium', 'high'] else None), False + +def run_card(card): + complexity, stop = grade(card) + if stop: + halted.set() + return dict(number=card['number'], finalStatus='halted', lastLane='none', column=card['status'], + detail='run halted while grading; preserve claims and worktrees', prUrl=None, + priorFindings=None, lanesRun='none:halted') + labels = [str(label).lower() for label in card.get('labels', [])] + first = card.get('lane') or ('qa' if 'qa' in labels else 'build') + entry = 'review' if card['status'] == 'Review' else 'qa' if card['status'] == 'QA' else first + lanes = ['build', 'qa', 'review'][['build', 'qa', 'review'].index(entry):] + history = [] + current = dict(card) + for lane in lanes: + with mutex: + if halted.is_set(): + history.append(dict(lane='none', status='halted', column=current['status'], detail='run halted; preserve claims and worktrees')) + break + boundary = resolver.report(root, 'pi', [current], lane=lane, + home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), catalog=os.environ.get('SUPER_BOARD_SKILLS_CATALOG')) + if not boundary['ready']: + skills_halted.set() + halted.set() + history.append(dict(lane=lane, status='halted', column=current['status'], + detail='skills-readiness: missing ' + ', '.join(e['name'] for e in boundary['entries'] if e['status'] == 'missing'))) + break + process = None + lock = inflight / str(card['number']) + try: + process = subprocess.Popen(['bash', str(launcher), '--config', str(config_path), + '--card', json.dumps(current), '--lane', lane, pi_flags[0], + '--tier', args.tier] + (['--complexity', complexity] if complexity else []), + stdout=subprocess.PIPE, text=True, start_new_session=True) + active.add(process) + lock.write_text(f'PID={process.pid}\nLANE={lane}\nSTARTED={time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}\n') + except OSError as error: + if process is not None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + active.discard(process) + history.append(dict(lane=lane, status='failed', column=current['status'], + detail=f'could not start {lane} lane: {error}')) + break + preserve_lock = False + try: + stdout, _ = process.communicate() + if process.returncode in (9, 10): + preserve_lock = True + (readiness_halted if process.returncode == 9 else skills_halted).set() + try: + result = json.loads(stdout) + if (not isinstance(result, dict) or result.get('status') not in + ['advanced', 'bounced', 'blocked', 'human-gate', 'failed', 'halted'] + or not all(isinstance(result.get(key), str) for key in ['column', 'detail'])): + raise ValueError('invalid lane result') + if process.returncode and result['status'] != 'halted': + raise ValueError(f'lane exited {process.returncode}') + except (ValueError, TypeError): + result = dict(status='halted' if process.returncode in (9, 10, 79) or halted.is_set() else 'failed', + column=current['status'], detail=f'{lane} lane returned no valid result (exit {process.returncode})') + with mutex: + if result['status'] == 'halted': + halted.set() + active.discard(process) + history.append(dict(lane=lane, **result)) + if result['status'] != 'advanced': + break + expected = {'build': 'QA', 'qa': 'Review', 'review': 'Done'}[lane] + if result['column'] != expected: + history[-1].update(status='failed', detail=f'{lane} claimed advanced without reaching {expected}') + break + current['status'] = result['column'] + current.pop('lane', None) + finally: + try: + if not preserve_lock and lock.read_text().startswith(f'PID={process.pid}\n'): + lock.unlink() + except FileNotFoundError: + pass + last = history[-1] + return dict(number=card['number'], finalStatus=last['status'], lastLane=last['lane'], + column=last['column'], detail=last['detail'], prUrl=last.get('prUrl'), + priorFindings=last.get('priorFindings'), + lanesRun=' → '.join(f"{item['lane']}:{item['status']}" for item in history)) + +try: + with ThreadPoolExecutor(max_workers=workers) as pool: + summaries = list(pool.map(run_card, cards)) + result = json.dumps(dict(cards=summaries, halted=halted.is_set())) + if output_path: + output = output_path + temporary = output.with_name(output.name + f'.{owner}.tmp') + temporary.write_text(result + '\n') + temporary.replace(output) + print(result) +finally: + if marker.exists() and marker.read_text().strip() == owner: + marker.unlink() +sys.exit(10 if skills_halted.is_set() else 9 if readiness_halted.is_set() else 79 if halted.is_set() else 0) +PY diff --git a/scripts/super-board-stop.sh b/scripts/super-board-stop.sh index 176a553a..02f41338 100755 --- a/scripts/super-board-stop.sh +++ b/scripts/super-board-stop.sh @@ -166,35 +166,42 @@ fi DISPATCHER_PIDS=$(pgrep -f 'super-board-run\.sh' 2>/dev/null || true) ORPHAN_WORKERS=$(pgrep -f 'claude -p .*super-board' 2>/dev/null || true) -CODEX_WAVE_MARKER=".claude/super-board/codex-wave.pid" -CODEX_WAVE_PID="" -if [ -f "$CODEX_WAVE_MARKER" ]; then - CODEX_WAVE_PID=$(cat "$CODEX_WAVE_MARKER") - case "$CODEX_WAVE_PID" in ''|*[!0-9]*) - log "🛑 invalid Codex wave PID marker; preserve claims and inspect $CODEX_WAVE_MARKER" +WAVE_SPECS=("Codex|codex-wave|.claude/super-board/codex-wave.pid|super-board-codex-wave" \ + "Pi|pi-wave|.claude/super-board/pi-wave.pid|super-board-pi-wave") +WAVE_PIDS=() +for spec in "${WAVE_SPECS[@]}"; do + IFS='|' read -r host name marker pattern <<< "$spec" + [ -f "$marker" ] || continue + pid=$(cat "$marker") + case "$pid" in ''|*[!0-9]*) + log "🛑 invalid ${host} wave PID marker; preserve claims and inspect $marker" exit 1 ;; esac - if kill -0 "$CODEX_WAVE_PID" 2>/dev/null; then + if kill -0 "$pid" 2>/dev/null; then # Refuse a reused PID, or an unavailable process identity, rather than # signal an unrelated process and release a still-running wave's claims. - if ! ps -p "$CODEX_WAVE_PID" -o args= 2>/dev/null | grep -q 'super-board-codex-wave'; then - log "🛑 cannot identify Codex wave PID $CODEX_WAVE_PID; preserve claims and inspect $CODEX_WAVE_MARKER" + if ! ps -p "$pid" -o args= 2>/dev/null | grep -q "$pattern"; then + log "🛑 cannot identify ${host} wave PID $pid; preserve claims and inspect $marker" exit 1 fi fi -fi + WAVE_PIDS+=("$host|$name|$pid") +done -if [ "${#WORKERS[@]}" -eq 0 ] && [ -z "$DISPATCHER_PIDS" ] && [ -z "$ORPHAN_WORKERS" ] && [ -z "$CODEX_WAVE_PID" ]; then +if [ "${#WORKERS[@]}" -eq 0 ] && [ -z "$DISPATCHER_PIDS" ] && [ -z "$ORPHAN_WORKERS" ] && [ "${#WAVE_PIDS[@]}" -eq 0 ]; then echo " ✓ nothing to stop — no dispatcher, no in-flight workers, no orphans" echo "════════════════════════════════════════════════════════" exit 0 fi -# Stop new Codex dispatch first. Its TERM handler cancels lane process groups -# and reaps them (up to five seconds), before any claim can be released. -if [ -n "$CODEX_WAVE_PID" ]; then - log "🛑 stopping Codex wave and its lane workers" - kill_pid "$CODEX_WAVE_PID" "codex-wave" 7 +# Stop new Codex/Pi dispatch first. Each TERM handler cancels lane process +# groups and reaps them (up to five seconds), before any claim can be released. +if [ "${#WAVE_PIDS[@]}" -gt 0 ]; then + for entry in "${WAVE_PIDS[@]}"; do + IFS='|' read -r host name pid <<< "$entry" + log "🛑 stopping ${host} wave and its lane workers" + kill_pid "$pid" "$name" 7 + done fi # 2. Per-worker wrap-up: comment, release claim, kill. @@ -237,11 +244,11 @@ if [ -d "$INFLIGHT_DIR" ] && [ -n "$(ls -A "$INFLIGHT_DIR" 2>/dev/null)" ]; then log "" log "🧽 cleared $INFLIGHT_DIR" fi -[ -z "$CODEX_WAVE_PID" ] || rm -f "$INFLIGHT_DIR/workflow-wave.lock" "$CODEX_WAVE_MARKER" +[ "${#WAVE_PIDS[@]}" -eq 0 ] || rm -f "$INFLIGHT_DIR/workflow-wave.lock" .claude/super-board/codex-wave.pid .claude/super-board/pi-wave.pid # 6. Summary. DISP_COUNT=$(echo "$DISPATCHER_PIDS" | tr ' ' '\n' | grep -c . || true) -[ -z "$CODEX_WAVE_PID" ] || DISP_COUNT=$((DISP_COUNT + 1)) +[ "${#WAVE_PIDS[@]}" -eq 0 ] || DISP_COUNT=$((DISP_COUNT + ${#WAVE_PIDS[@]})) echo "" echo "════════════════════════════════════════════════════════" echo " ✅ stopped: ${#WORKERS[@]} worker(s), ${DISP_COUNT} dispatcher(s)" diff --git a/tests/test-codex-stop.sh b/tests/test-codex-stop.sh index 5ec80666..f61d58f7 100644 --- a/tests/test-codex-stop.sh +++ b/tests/test-codex-stop.sh @@ -61,4 +61,31 @@ rc=0 [ "$rc" -eq 1 ] || { echo 'FAIL: reused PID not refused'; exit 1; } [ ! -s "$STOP_TRACE" ] || { echo 'FAIL: reused PID released claims'; exit 1; } [ -f "$TMP/.claude/super-board/inflight/13" ] || { echo 'FAIL: reused PID removed worker lock'; exit 1; } -echo 'PASS: test-codex-stop.sh (stop order, control markers, reused PID)' + +# The Pi wave marker gets the same stop-order protection as the Codex one. +rm -f "$TMP/.claude/super-board/codex-wave.pid" +python3 - "$TMP" <<'PY' & +import os, pathlib, signal, sys, time +root = pathlib.Path(sys.argv[1]) +def stop(*_): + with (root / 'trace').open('a') as f: + f.write('pi-wave-stopped\n') + sys.exit(0) +signal.signal(signal.SIGTERM, stop) +(root / '.claude/super-board/pi-wave.pid').write_text(str(os.getpid()) + '\n') +while True: + time.sleep(.05) +PY +wave_pid=$! +for attempt in {1..100}; do + [ -f "$TMP/.claude/super-board/pi-wave.pid" ] && break + sleep .02 +done +printf '#!/usr/bin/env bash\necho "python3 - /kit/scripts/super-board-pi-wave.sh"\n' > "$TMP/bin/ps" +: > "$STOP_TRACE" +(cd "$TMP" && PATH="$TMP/bin:$PATH" bash "$ROOT/scripts/super-board-stop.sh" test > stop.log) +wait "$wave_pid" +wave_pid="" +[ "$(head -1 "$STOP_TRACE")" = pi-wave-stopped ] || { echo 'FAIL: Pi claims released before wave stopped'; exit 1; } +[ ! -e "$TMP/.claude/super-board/pi-wave.pid" ] || { echo 'FAIL: Pi marker retained'; exit 1; } +echo 'PASS: test-codex-stop.sh (stop order, control markers, reused PID, Pi marker)' diff --git a/tests/test-pi-lane.sh b/tests/test-pi-lane.sh index d128d70f..543216de 100644 --- a/tests/test-pi-lane.sh +++ b/tests/test-pi-lane.sh @@ -167,4 +167,108 @@ cd "$T/submodule" lane review --pi >"$T/out" jq -e '.status == "advanced"' "$T/out" >/dev/null || fail 'a separate-git-dir worktree must launch a Pi lane' cd "$T/repo" -echo "PASS: test-pi-lane.sh (opt-in/models/ladders/review/results)" +# Wave: the haiku router grades Ready cards only when nothing pins a model. +wave() { bash "$KIT/scripts/super-board-pi-wave.sh" --config "$T/config.json" --cards "$CARDS" "$@" 2>"$T/stderr"; } +echo '{"max_workers":1,"verify_commands":["git diff --check"]}' > "$T/config.json" +CARDS='[{"number":1,"status":"Ready","title":"easy"},{"number":2,"status":"Review"}]' +: > "$SB_PI_TEST_LOG" +SB_PI_TEST_GRADE=low wave --pi --tier low >"$T/out" +python3 - "$SB_PI_TEST_LOG" <<'PY' +import json, pathlib, sys +calls = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] +grades = [call for call in calls if call['lane'] == 'grade'] +assert [call['number'] for call in grades] == [1], grades +assert grades[0]['model'] == 'haiku' +card1 = [call['model'] for call in calls if call['number'] == 1 and call['lane'] != 'grade'] +assert card1 == ['haiku'] * 3, card1 # --low x low on build, qa and review +card2 = [call['model'] for call in calls if call['number'] == 2] +assert card2 == ['sonnet'], card2 # ungraded Review card: --low medium cell +PY +: > "$SB_PI_TEST_LOG" +SB_PI_TEST_GRADE=high wave --pi >"$T/out" +jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "opus")' "$SB_PI_TEST_LOG" >/dev/null || fail 'default ladder sends a hard card to opus' +: > "$SB_PI_TEST_LOG" +SB_PI_TEST_GRADE_EXIT=1 wave --pi --tier high >"$T/out" +jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "opus")' "$SB_PI_TEST_LOG" >/dev/null || fail 'a failed grade falls back to the medium cell' +: > "$SB_PI_TEST_LOG" +echo '{"max_workers":1,"pi":{"router_model":"router-x"},"verify_commands":["git diff --check"]}' > "$T/config.json" +SB_PI_TEST_GRADE=low wave --pi >"$T/out" +jq -s -e '[.[] | select(.lane == "grade") | .model] == ["router-x"]' "$SB_PI_TEST_LOG" >/dev/null || fail 'pi.router_model drives the router' +echo '{"max_workers":1,"verify_commands":["git diff --check"]}' > "$T/config.json" +: > "$SB_PI_TEST_LOG" +wave --pi=pinned-model >"$T/out" +jq -s -e 'all(.lane != "grade") and all(.model == "pinned-model")' "$SB_PI_TEST_LOG" >/dev/null || fail 'a pinned model skips the router and wins every lane' +: > "$SB_PI_TEST_LOG" +RC=0; SB_PI_TEST_GRADE_EXIT=79 wave --pi >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && jq -e '.halted and .cards[0].finalStatus == "halted"' "$T/out" >/dev/null || fail 'router halt stops the wave' +jq -s -e 'all(.lane == "grade")' "$SB_PI_TEST_LOG" >/dev/null || fail 'router halt launches no lane' +RC=0; wave >"$T/out" || RC=$? +[ "$RC" -eq 2 ] || fail 'wave requires exactly one explicit --pi' +rm -f "$T/stderr" + +CARDS='[{"number":1,"status":"Ready"},{"number":2,"status":"Ready","labels":["qa"]},{"number":3,"status":"QA"},{"number":4,"status":"Review"}]' +echo '{"max_workers":1,"pi":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +: > "$SB_PI_TEST_LOG" +wave --pi=wave-model --output "$T/wave.json" > "$T/out" +cmp "$T/out" "$T/wave.json" || fail 'file and stdout wave result differ' +jq -e '.halted == false and (.cards | length) == 4 and .cards[0].lanesRun == "build:advanced → qa:advanced → review:advanced" and .cards[1].lanesRun == "qa:advanced → review:advanced" and .cards[2].lastLane == "review" and .cards[3].column == "Done"' "$T/out" >/dev/null || fail 'wave routing/summary' +[ "$(last_model)" = wave-model ] || fail 'wave propagates explicit model' +jq -s -e 'all(.active == 1)' "$SB_PI_TEST_LOG" >/dev/null || fail 'max_workers=1 must serialize cards' +[ ! -e .claude/super-board/pi-wave.pid ] && [ -z "$(ls -A .claude/super-board/inflight)" ] || fail 'completed wave leaves no PID/worker locks' +: > "$SB_PI_TEST_LOG" +SB_PI_TEST_STATUS=bounced wave --pi >"$T/out" +jq -e '.cards[0].lanesRun == "build:bounced"' "$T/out" >/dev/null || fail 'non-advanced ends card chain' +: > "$SB_PI_TEST_LOG" +RC=0; SB_PI_TEST_STATUS=halted wave --pi >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && [ "$(wc -l < "$SB_PI_TEST_LOG" | tr -d ' ')" -eq 1 ] || fail 'halt stops all new lane dispatch' +jq -e '.halted and (.cards | length) == 4' "$T/out" >/dev/null || fail 'halt summary preserves all cards' + +# A readiness halt returned by a running lane stops dispatch and preserves its owned lock. +: > "$SB_PI_TEST_LOG" +RC=0; SB_PI_TEST_EXIT=9 wave --pi >"$T/out" || RC=$? +[ "$RC" -eq 9 ] && [ "$(wc -l < "$SB_PI_TEST_LOG" | tr -d ' ')" -eq 1 ] || fail 'readiness halt stops new Pi dispatch' +jq -e '.halted and .cards[0].finalStatus == "halted" and (.cards[0].detail | contains("verification-readiness"))' "$T/out" >/dev/null || fail 'readiness is a setup halt, not failed/rebase' +[ -f .claude/super-board/inflight/1 ] || fail 'readiness halt preserves its lane lock for recovery' +[ ! -e .claude/super-board/pi-wave.pid ] || fail 'ended wave releases only its PID marker' +rm .claude/super-board/inflight/1 + +# A normal Pi completion can also report gate exit 9 in its structured result. +: > "$SB_PI_TEST_LOG" +RC=0; SB_PI_TEST_STATUS=halted SB_PI_TEST_DETAIL='verification-readiness: checks missing' wave --pi >"$T/out" || RC=$? +[ "$RC" -eq 9 ] && [ "$(wc -l < "$SB_PI_TEST_LOG" | tr -d ' ')" -eq 1 ] || fail 'structured readiness halt preserves code 9' +[ -f .claude/super-board/inflight/1 ] || fail 'structured readiness halt preserves lane state' +rm .claude/super-board/inflight/1 + +# Two card chains run together, but a cap of 1 above was serial. +echo '{"max_workers":2,"verify_commands":["git diff --check"]}' > "$T/config.json" +CARDS='[{"number":1,"status":"Review"},{"number":2,"status":"Review"}]' +: > "$SB_PI_TEST_LOG" +SB_PI_TEST_DELAY=1 wave --pi >"$T/out" +python3 - "$SB_PI_TEST_LOG" <<'PY' +import json, pathlib, sys +calls = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] +assert len(calls) == 2 and max(call['active'] for call in calls) == 2 +PY + +# If the numeric lock cannot be written, startup fails as a card result and +# reaps the already-created launcher rather than losing the whole wave summary. +mkdir .claude/super-board/inflight/1 +CARDS='[{"number":1,"status":"Review"}]' +wave --pi >"$T/out" +jq -e '.cards[0].finalStatus == "failed" and (.cards[0].detail | contains("could not start"))' "$T/out" >/dev/null || fail 'startup failure needs structured result' +rmdir .claude/super-board/inflight/1 + +# TERM reaps the Pi process group and its descendants before lock cleanup. +CARDS='[{"number":1,"status":"Review"}]' +SB_PI_TEST_SLEEP=1 SB_PI_TEST_PID="$T/descendant.pid" wave --pi >"$T/out" & +JOB=$! +for _ in {1..100}; do [ -s "$T/descendant.pid" ] && break; sleep 0.05; done +[ -s "$T/descendant.pid" ] || fail 'stub did not start' +WAVE_PID=$(cat .claude/super-board/pi-wave.pid) +kill -TERM "$WAVE_PID" +RC=0; wait "$JOB" || RC=$? +[ "$RC" -eq 79 ] || fail 'interrupted wave must halt' +DESCENDANT=$(cat "$T/descendant.pid") +! kill -0 "$DESCENDANT" 2>/dev/null || fail 'descendant survived wave cancellation' +[ ! -e .claude/super-board/pi-wave.pid ] && [ -z "$(ls -A .claude/super-board/inflight)" ] || fail 'interrupted wave leaves no live locks' +echo "PASS: test-pi-lane.sh (opt-in/models/ladders/review/results/wave/halt/cancellation)" From 776133b54ce3aed357710fb88f3f81c86e01ec36 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 00:56:08 -0500 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20pi:=20run=20contr?= =?UTF-8?q?act,=20skill=20surfaces=20and=20config=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-workflow.md gains "Pi runs": explicit --pi only (no Claude-usage fallback), host switch inside Pi, wave/lane invocation, no sandbox but -a trust plus guards extension, tier x grade ladders and pinning - skills-readiness.md documents Pi's discovery roots and the fail-closed settings/packages policy; onboard/README/SKILL.md route --pi and the Pi host; config-schema gains pi.model, pi.router_model, pi.ladders --- README.md | 18 +++-- skills/super-board/SKILL.md | 9 +-- skills/super-board/references/calibration.md | 2 +- .../super-board/references/config-schema.json | 19 +++++- skills/super-board/references/onboard.md | 2 +- skills/super-board/references/run-workflow.md | 66 +++++++++++++++---- .../references/skills-readiness.md | 19 +++++- 7 files changed, 109 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8b57d856..e50c6546 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Each name links to that skill's README. Adding a skill? Write its `SKILL.md` and /git-sync # commit, pull, push ``` -Flags: `run --low|--high` · `run --codex[=<model>]` · `super-collect --since 30d` +Flags: `run --low|--high` · `run --codex[=<model>]` · `run --pi[=<model>]` · `super-collect --since 30d` ## Guards & hooks @@ -134,7 +134,7 @@ Run automatically once installed. Python stdlib, JSON in, JSON out. ## Models -A cheap router grades each card easy, medium or hard first; the run flag picks the model for each grade. With `--codex`, every lane runs on Codex GPT models instead of Claude. +A cheap router grades each card easy, medium or hard first; the run flag picks the model for each grade. With `--codex` or `--pi`, every lane runs on that backend instead of Claude. | Command | Router | Easy card | Medium card | Hard card | | --- | --- | --- | --- | --- | @@ -144,12 +144,18 @@ A cheap router grades each card easy, medium or hard first; the run flag picks t | `/super-board run --codex` | gpt-6-luna | gpt-6.1-sol | gpt-6.1-sol | gpt-6-astra | | `/super-board run --codex --low` | gpt-6-luna | gpt-6-luna | gpt-6.1-sol | gpt-6.1-sol | | `/super-board run --codex --high` | gpt-6-luna | gpt-6-astra | gpt-6-astra | gpt-6-astra | +| `/super-board run --pi` | haiku | sonnet | sonnet | opus | +| `/super-board run --pi --low` | haiku | haiku | sonnet | sonnet | +| `/super-board run --pi --high` | haiku | opus | opus | opus | -`/super-board run --codex=<model>` pins one model for every card and skips the router. +`/super-board run --codex=<model>` or `--pi=<model>` pins one model for every card and skips the router. +The Pi names are defaults: Pi resolves a model pattern fuzzily across the providers you connected, so a board on another provider overrides `pi.ladders` (and `pi.model`/`pi.router_model`) in the config. -Run inside Codex? plain `/super-board run` switches to the Codex ladder by itself. Codex has no +Run inside Codex or Pi? plain `/super-board run` switches to that host's ladder by itself. Codex has no `/super-board` command: it finds skills in `.agents/skills/` (symlinks followed), so link -`.claude/skills/super-board` there and type `$super-board run`. +`.claude/skills/super-board` there and type `$super-board run`. Pi discovers the same +`.agents/skills/` locations, plus `.pi/skills/` and its agent directory; invoke it as +`/skill:super-board run`. There is no Claude-usage fallback into Pi: `--pi` (or the Pi host) is required. ## Writing standard @@ -188,7 +194,7 @@ MIT © Eric Tech ([LICENSE](LICENSE)). Built on [obra/superpowers](https://githu ### Worker skill availability Setup and dispatch resolve real, nonempty `SKILL.md` files for the actual -Claude or Codex host. Missing required skills halt before claims or workers; +Claude, Codex or Pi host. Missing required skills halt before claims or workers; lockfiles and arbitrary plugin caches do not count. The shared registry in `scripts/super-board-skills-ready.py` owns lane dependencies, overrides and the two declared ponytail fallbacks. Setup's full helper union may be red while a diff --git a/skills/super-board/SKILL.md b/skills/super-board/SKILL.md index 8ec440c2..49be9940 100644 --- a/skills/super-board/SKILL.md +++ b/skills/super-board/SKILL.md @@ -12,12 +12,12 @@ description: GitHub-Project-driven autonomous pipeline. Five verbs — onboard, | `super-board onboard` | interactive | one-time setup wizard; writes `.claude/super-board/configs/<slug>.json` | | `super-board lint` | interactive | walks active-pipeline issues, flags vague ACs, runs pre-flight readiness | | `super-board status` | interactive (read-only) | snapshot of active config, column counts, in-flight workers | -| `super-board run` | in-session (default) | the autonomous loop: waves of the `super-board-wave` dynamic workflow, run from this session (`worker_backend: "claude-p"` opts into the legacy headless runner `.claude/bin/super-board-run.sh`). Also the resume command — state lives on the board, not in process memory. Accepts a model-tier flag: `--low` (haiku/sonnet/opus ladder), default = medium (sonnet/opus/session), `--high` (opus for every card). `--codex [--low|--high]` overrides the backend for every lane with the matching Codex GPT ladder; `--codex=<model>` or `codex.model` pins one model for every card (see `references/run-workflow.md`). A host with no Workflow tool (a Codex session) runs as `--codex` by itself. | +| `super-board run` | in-session (default) | the autonomous loop: waves of the `super-board-wave` dynamic workflow, run from this session (`worker_backend: "claude-p"` opts into the legacy headless runner `.claude/bin/super-board-run.sh`). Also the resume command — state lives on the board, not in process memory. Accepts a model-tier flag: `--low` (haiku/sonnet/opus ladder), default = medium (sonnet/opus/session), `--high` (opus for every card). `--codex [--low\|--high]` overrides the backend for every lane with the matching Codex GPT ladder; `--codex=<model>` or `codex.model` pins one model for every card. `--pi [--low\|--high]` does the same on Pi with the matching Claude-family (or configured) ladder; `--pi=<model>`, `pi.model` and `pi.ladders` behave like their Codex counterparts (see `references/run-workflow.md`). A host with no Workflow tool (a Codex or Pi session) runs as `--codex`/`--pi` by itself. | | `super-board stop` | interactive | graceful shutdown: posts "stopped mid-flight" comments on every in-flight issue + PR, releases assignee mutexes, kills workers + dispatcher. Next `super-board run` resumes. | If invoked with no verb, ask which one. -In Codex the skill is not a slash command: it is `$super-board <verb>`, found in `.agents/skills/`. +In Codex the skill is not a slash command: it is `$super-board <verb>`, found in `.agents/skills/`. In Pi it is `/skill:super-board <verb>` (or `$super-board` where Pi's shared `.agents/skills/` discovery applies). ## Routing @@ -27,6 +27,7 @@ In Codex the skill is not a slash command: it is `$super-board <verb>`, found in | `super-board lint ...` | `references/lint.md` | | `super-board status ...` | `references/status.md` | | `super-board run --codex[=<model>] ...`, or any `run` from a host with no Workflow tool (overrides any configured backend) | `references/run-workflow.md` → "Codex runs" | +| `super-board run --pi[=<model>] ...`, or any `run` from a Pi host (overrides any configured backend) | `references/run-workflow.md` → "Pi runs" | | `super-board run ...` (default — `worker_backend` unset or `"workflow"`) | `references/run-workflow.md` (lane lifecycles still come from `references/run.md`) | | `super-board run ...` with config `worker_backend: "claude-p"` (legacy, explicit opt-in) | `references/run.md` | | `super-board stop ...` / "stop the run" / "pause the loop" / "kill super-board" | `references/stop.md` | @@ -40,8 +41,8 @@ In Codex the skill is not a slash command: it is `$super-board <verb>`, found in super-board is an **autonomous trader**. The interactive Claude session that invokes any of the five verbs is an **orchestrator**, not a worker. The orchestrator: -- Validates preconditions, then dispatches per the config's `worker_backend`: `"workflow"` (default) → stay in-session and run the wave loop in `references/run-workflow.md` (launch workflow, reconcile, repeat); `"claude-p"` (legacy, explicit opt-in only) → `nohup .claude/bin/super-board-run.sh`, report PID + log path, exit. Explicit `--codex` overrides either backend with headless Codex waves; so does a host with no Workflow tool, or `bash .claude/bin/super-board-host.sh` printing `codex`. The orchestrator never does product work itself. -- Delegates all build / QA / review work to workers — headless `claude -p` (claude-p backend), workflow lane agents (workflow backend), or headless `codex exec` (explicit `--codex`). +- Validates preconditions, then dispatches per the config's `worker_backend`: `"workflow"` (default) → stay in-session and run the wave loop in `references/run-workflow.md` (launch workflow, reconcile, repeat); `"claude-p"` (legacy, explicit opt-in only) → `nohup .claude/bin/super-board-run.sh`, report PID + log path, exit. Explicit `--codex` or `--pi` overrides either backend with headless Codex/Pi waves; so does a host with no Workflow tool, or `bash .claude/bin/super-board-host.sh` printing `codex`/`pi`. The orchestrator never does product work itself. +- Delegates all build / QA / review work to workers — headless `claude -p` (claude-p backend), workflow lane agents (workflow backend), or headless `codex exec` / `pi -p` (explicit `--codex`/`--pi`). - Must NOT do product work itself, must NOT patch the dispatcher mid-run, must NOT wait for workers, must NOT hold context for multi-card progress. If anything goes wrong during a run, the orchestrator captures the symptom and reports back — it does not silently expand the task into a fix. See `references/run.md` "Orchestrator delegation contract" for the full rule. diff --git a/skills/super-board/references/calibration.md b/skills/super-board/references/calibration.md index ca6407e1..aa451615 100644 --- a/skills/super-board/references/calibration.md +++ b/skills/super-board/references/calibration.md @@ -17,7 +17,7 @@ approval, Blocked card, partial delivery, or halt. An empty board with no landed work does not trigger calibration. Closed-as-not-planned cards are not merges. After confirming all waves/tasks ended, the host releases only its own native `workflow-wave.lock`, matching the run identity it saved at creation. Numeric -lane locks, the native workflow marker, or a Codex wave marker still present +lane locks, the native workflow marker, or a Codex/Pi wave marker still present mean unfinished work. An uncertain/other host's marker is preserved and learning is skipped; the adapter never clears or reaps markers. diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index c9d3be07..70a0e768 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -61,6 +61,23 @@ "model": null }, + // Optional Pi lane settings. Used by explicit `run --pi` or a Pi host; never + // selects the backend by itself and never grants Review/merge ownership. + "pi": { + // Optional model pattern passed to `pi --model` for every card, over the + // Pi ladder. `--pi=<model>` wins; absent/null lets the ladder pick + // (run tier x card grade, see scripts/super-board-pi-lane.sh). + "model": null, + // Optional cheap model grading each Ready card before its lanes (default + // "haiku"); resolved fuzzily across the providers the Pi session has. + "router_model": null, + // Optional override of the built-in tier x grade table. Keys must be + // low/medium/high tiers, each with low/medium/high grades and non-empty + // model strings; Pi accepts an optional `:thinking` suffix (for example + // "sonnet:high"). Absent/null keeps PI_LADDERS in the lane launcher. + "ladders": null + }, + "target": { // "repo" | "repo+url". A board always drives a repo; `url` is the live or // preview site the Tester may also hit. Testing a live site with no repo is @@ -263,7 +280,7 @@ // orchestrator runs super-board-usage.sh check; if the 5-hour or weekly // window is at or above this percentage, no new wave launches until it // resets. Default 95. See references/run-workflow.md → wave loop step 1. - // Explicit `run --codex` skips this Claude-only guard. + // Explicit `run --codex` or `run --pi` skips this Claude-only guard. "usage_pause_pct": 95, // What the orchestrator does while the usage guard pauses (exit 10). diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index 1d55e58e..b3304394 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -9,7 +9,7 @@ Invoked as `/super-board onboard` (get.sh / install.sh) or `/super-board:super-b ## Host-visible skill checks Checks use `.claude/bin/super-board-skills-ready.py --profile setup --host -<claude|codex>` and show `helper_readiness`: exact files, declared fallbacks, +<claude|codex|pi>` and show `helper_readiness`: exact files, declared fallbacks, optional skips and missing names. Setup checks the unconditional union; a selected card may need fewer helpers. Node or `skills-lock.json` never proves readiness. `check` and dispatch install nothing. Explicit `fix` retains helper diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 8721d90a..9102eb23 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -38,6 +38,41 @@ merge gate with `--expect-head <reviewed-SHA>` and verify landing per `run.md`. Concurrent reviews share that gate's merge mutex. Network and repo/git-dir writes use the lane launcher's sandbox settings, including an external submodule git dir. +## Pi runs + +`/super-board run --pi` uses headless Pi (pi.dev) for every lane of every wave, +overriding `worker_backend`. Use it to run the board on Pi's connected providers. +`--pi --low` / `--pi --high` pick the Pi ladder the same way the tier flags +pick Claude's (flag, else config `model_tier`, else medium); pass it as `--tier`. +A cheap router (`pi.router_model`, default `haiku`) grades each Ready card +low/medium/high, and the lane launcher's `PI_LADDERS` table maps tier × grade to +`--model <model>`; Pi resolves a model pattern fuzzily across connected providers, +so the defaults are Claude-family names and a board on other providers overrides +`pi.ladders` in the config. A card entering past Ready takes its tier's medium cell. +`--pi=<model>`, else optional `pi.model`, pins one model for every card and skips the router. +Keep this run choice in each wave and resume command; skip only the Claude usage guard. + +**Inside Pi, plain `run` is `--pi`.** Your tool list has no Workflow tool, or +`bash .claude/bin/super-board-host.sh` prints `pi` → run as `--pi` (tier flags and +`--pi=<model>` still apply); it counts as explicit `--pi`, Review included. +Say so once, in the first wave report: `🔁 pi host — running as --pi`. Never improvise a Claude workflow wave in Pi. + +After the same plan and claim steps, launch this command — foreground, or `tmux new -d` to keep the session responsive: +`bash .claude/bin/super-board-pi-wave.sh --config <config-path> --cards <claimed-cards.json> --pi[=<model>] --tier <low|medium|high> --output <wave-result.json>`. +The helper starts one `super-board-pi-lane.sh` per card/lane, cards in parallel, +each advancing Build → QA → Review only after a successful handoff (QA labels skip Build). +Concurrency uses positive `max_workers`, otherwise `max(1, min(16, cpus - 2))`. +Pre-flight/classification and truth-check run inline; there are no Claude sub-agents. +Each lane emits `{status,column,detail}`; the wave returns the same `cards` summary +as the workflow, so reconcile, manifest, board/comments and throttle steps stay the same. +Review is allowed only with explicit `--pi`, and unlike Codex there is no +Claude-usage fallback path into Pi: without `--pi` even `build`/`qa` refuse (exit 78). +Merges still use the unchanged merge gate with `--expect-head <reviewed-SHA>` and +verify landing per `run.md`. Concurrent reviews share that gate's merge mutex. +Pi has no sandbox flags: the lane runs with `-a` (trust this project) and a fixed +tool allowlist, so the guards extension in `.pi/extensions/` and the worktree +contract carry the network and repo-write protections. + ## Required GitHub evidence and a paused run Required reads use `.claude/bin/super-board-github-read.py --kind <shape> -- <gh args>`. @@ -52,7 +87,8 @@ Exit **79**, a workflow result with `halted: true`, or a failed `--check` ends t run. Check the helper's `--check` before claims, board/comment writes, each new lane/wave, and all migration/merge steps. The marker is shared through the main checkout's `.claude/super-board/github-halt.json`, including linked worktrees. -Cancel active workflow agents using local workflow controls (Codex: stop the background wave task); do not launch more +Cancel active workflow agents using local workflow controls (Codex/Pi: stop the wave task or +verified wave PID); do not launch more lanes. Already-running agents stop at their next checkpoint; this is not an instantaneous process-wide kill. Preserve worktrees, claims, card statuses, and approval evidence. Do not move cards to Done/Blocked or release claims using an @@ -76,7 +112,7 @@ The interactive session that runs this backend is the orchestrator. It: - polls the board, claims assignees, launches workflow waves, reconciles results, posts notifications, and reports to the user between waves; - does NOT do product work, patch lane skills mid-run, or hold per-card - build context. Workflow lane agents or explicitly selected Codex workers do all product work. + build context. Workflow lane agents or explicitly selected Codex/Pi workers do all product work. ## Preconditions (before the first wave) @@ -96,14 +132,17 @@ Run the same preconditions as `run.md` §Preconditions, minus PID checks: burning tokens): `{ echo '(async function(){'; sed 's/^export const meta/const meta/' .claude/workflows/super-board-wave.js; echo '})'; } | node --check --input-type=module` With `--codex`, instead require `codex` on PATH and `bash -n` on both Codex launcher scripts. + With `--pi`, require `pi` on PATH, `bash -n` on both Pi launcher scripts, and the + guards extension present in `.pi/extensions/` (no extension, no lane). 5. Wave marker FIRST, then the legacy check (lock-before-look closes the TOCTOU window where both backends pass each other's checks at once): a. Atomically create `.claude/super-board/inflight/workflow-wave.lock` (mkdir -p the directory) containing the config slug and start time: `(set -C; printf 'SLUG=%s\nSTARTED=%s\n' <slug> "$(date -u +%FT%TZ)" > <lock>)`. Before replacing an existing marker, check `/workflows` and verify the PID - in `.claude/super-board/codex-wave.pid` (plain numeric PID) with `kill -0` plus its process command. - Only if neither backend has an active wave and no repo-local Codex lane + in `.claude/super-board/codex-wave.pid` or `.claude/super-board/pi-wave.pid` + (plain numeric PID) with `kill -0` plus its process command. + Only if neither backend has an active wave and no repo-local Codex/Pi lane remains is the marker stale from a crashed run — replace it. Remove the lock when the run ends or stops. The legacy dispatcher refuses to start (and halts mid-run) while it exists. @@ -111,8 +150,9 @@ Run the same preconditions as `run.md` §Preconditions, minus PID checks: `pgrep -f 'super-board-run.sh'` (the legacy dispatcher idles between dispatches with zero workers alive) and `pgrep -f 'claude -p .*super-board run'` are empty, AND `/workflows` - shows no running super-board-wave, AND no `super-board-codex-wave.sh` or - `super-board-codex-lane.sh` process belongs to this repo. If a legacy run is detected, + shows no running super-board-wave, AND no `super-board-codex-wave.sh`, + `super-board-codex-lane.sh`, `super-board-pi-wave.sh` or + `super-board-pi-lane.sh` process belongs to this repo. If a legacy run is detected, remove the lock just created and stop — the legacy run won. 6. Crash-recovery sweep (the workflow backend's equivalent of the legacy reaper): with no wave running, strip `bot_identity` from any @@ -137,7 +177,7 @@ Repeat until a done condition or halt gate fires: quota: `gh api repos/<o>/<r>/issues/<n>/comments -f body=…` for a comment, `gh api -X DELETE repos/<o>/<r>/issues/<n>/assignees -f 'assignees[]=<bot>'` to release a claim. Board moves have no REST route; they wait for the reset. - **Usage guard** (Claude waves only; skip for `--codex`, explicit or host-switched) — `bash .claude/bin/super-board-usage.sh check --config <config-path>` + **Usage guard** (Claude waves only; skip for `--codex`/`--pi`, explicit or host-switched) — `bash .claude/bin/super-board-usage.sh check --config <config-path>` (Claude 5-hour and weekly plan usage; threshold `usage_pause_pct`, default 95): - exit 0 → launch. - exit 10 with `usage_fallback: "codex"` → resolve the selected remaining @@ -181,7 +221,7 @@ Repeat until a done condition or halt gate fires: read-only with `.claude/bin/super-board-github-read.py --kind issue -- issue view <N> --json number,title,body`. Keep number, status, labels and literal body in a JSON array file. Run `python3 .claude/bin/super-board-skills-ready.py - --root <repo-root> --host <claude|codex> --cards <cards.json>` for the actual + --root <repo-root> --host <claude|codex|pi> --cards <cards.json>` for the actual worker host before sweep/resume/refresh/flag/stranded actions or Claim. Exit 10 names missing helpers: halt, preserve all state, repair the exact visible skill files and restart explicitly. Exit 64 means invalid input. @@ -241,8 +281,8 @@ Repeat until a done condition or halt gate fires: bot_identity is unset — accepted single-orchestrator risk: without it there is no cross-session claim at all, so never run two orchestrators (or /loop re-entries) against the same board without bot_identity. -4. **Launch** — With `--codex`, or no Workflow tool, or the host check printing `codex`, use - "Codex runs" above; otherwise Workflow tool with +4. **Launch** — With `--codex` or `--pi`, or no Workflow tool, or the host check printing + `codex`/`pi`, use "Codex runs" / "Pi runs" above; otherwise Workflow tool with `scriptPath: .claude/workflows/super-board-wave.js` and `args: { configPath, cards, humanApprovesMerge, tier }` (`cards` hydrated after the planner: each carries `lane` and `labels`, and a `qa` card skips the Builder). Runs in the background; the @@ -285,7 +325,7 @@ Repeat until a done condition or halt gate fires: `block_rate_alert_pct` of initial Ready; or the user says stop. The halt note is four lines: `🛑 halted — <gate>` · the evidence (counts, the cards that kept bouncing) · what is left in flight or claimed · `Resume: - /super-board run <slug> [--codex[=<model>]] [--low|--high]` (retain the run's choice) and who must act first. + /super-board run <slug> [--codex[=<model>]] [--pi[=<model>]] [--low|--high]` (retain the run's choice) and who must act first. 8. Loop to 1. For unattended cadence, the user may wrap this loop in /loop; the orchestrator must still stop at halt gates. @@ -306,7 +346,7 @@ reported as skipped and does not change the completed product work. - Stop: `x` on the run in `/workflows` (or TaskStop), then release assignees for in-flight cards and post "stopped mid-flight" comments (same protocol as `references/stop.md`). Remove `.claude/super-board/inflight/workflow-wave.lock`. -- Codex stop: with GitHub available, run `bash .claude/bin/super-board-stop.sh <slug>`; it validates the wave PID and allows SIGTERM to cancel child lanes before releasing claims. Apply the same claim/comment/lock protocol to ALL selected claimed cards, including queued cards. During a GitHub halt, send SIGTERM only to the verified wave PID, wait for child cancellation, and preserve claims without GitHub writes. +- Codex/Pi stop: with GitHub available, run `bash .claude/bin/super-board-stop.sh <slug>`; it validates the wave PID (`codex-wave.pid` / `pi-wave.pid`) and allows SIGTERM to cancel child lanes before releasing claims. Apply the same claim/comment/lock protocol to ALL selected claimed cards, including queued cards. During a GitHub halt, send SIGTERM only to the verified wave PID, wait for child cancellation, and preserve claims without GitHub writes. - Resume: just run again — board state is the only state. A workflow stopped mid-wave can also be resumed in-session via `resumeFromRunId` (completed lane agents return cached results). @@ -336,6 +376,8 @@ don't stall on prompts: "Bash(bash .claude/bin/super-board-throttle.sh:*)", "Bash(bash .claude/bin/super-board-codex-lane.sh:*)", "Bash(bash .claude/bin/super-board-codex-wave.sh:*)", + "Bash(bash .claude/bin/super-board-pi-lane.sh:*)", + "Bash(bash .claude/bin/super-board-pi-wave.sh:*)", "Bash(bash .claude/bin/super-board-host.sh:*)", "Bash(bash .claude/bin/super-board-pr-body.sh:*)", "Bash(gh pr edit:*)", plus your project's test runners (e.g. "Bash(npm test:*)", "Bash(npx playwright:*)"). diff --git a/skills/super-board/references/skills-readiness.md b/skills/super-board/references/skills-readiness.md index 2331433f..dd07304d 100644 --- a/skills/super-board/references/skills-readiness.md +++ b/skills/super-board/references/skills-readiness.md @@ -1,7 +1,7 @@ # Skill readiness before dispatch Run `python3 .claude/bin/super-board-skills-ready.py --root <project> --host -<claude|codex> --cards <cards.json>` before any claims or wave writes. The cards +<claude|codex|pi> --cards <cards.json>` before any claims or wave writes. The cards array carries number, status, labels and literal issue body; hydrate absent bodies read-only with the guarded GitHub reader (`--hydrate` does this). For a worker boundary add `--lane build|qa|review`. Before using a conditional @@ -47,3 +47,20 @@ host's effective active catalog; the resolver uses only its exact enabled paths rather than guessing trust or layer precedence. Ordinary project configs with no activation leave discovery unchanged. See [Codex project configuration](https://learn.chatgpt.com/docs/config-file/config-basic). + +Pi sees the same scoped `.agents/skills` search (current directory through the +owning Git root) plus project `.pi/skills`, user `~/.agents/skills`, and the Pi +agent directory's skills (`~/.pi/agent/skills`, or `PI_CODING_AGENT_DIR`). +Its core `.claude` direct reads are supported; helper files in `.claude` alone +are not Pi availability. Discovery follows [Pi skill +documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md). + +Pi settings can add skills too: `skills` resource arrays (plain paths and +`+`/`-`/`!` glob entries) in `.pi/settings.json` or the agent directory's +`settings.json`, and `packages` that may bundle skills. Resolving those — glob +semantics, project trust, package contents — is not reimplemented here. When +either settings file carries a non-empty `skills` or `packages` list, provide +the host's effective active catalog; the resolver fails closed (exit 64) and +uses only its exact enabled paths. Ordinary settings (models, themes, tools) +leave discovery unchanged. See [Pi +settings](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/settings.md). From 088b42868d8fde755797a408034495d770af64c5 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 01:06:51 -0500 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20pi:=20guard=20hooks?= =?UTF-8?q?=20run=20as=20a=20Pi=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .pi/extensions/super-board-guards.ts translates Pi tool calls (bash, read, grep, edit, write) into the same stdin JSON the .claude guards already read, turns a deny into {block, reason} and appends PostToolUse key-literal warnings to the tool result; a missing script or crashing guard fails open - guard-protected-push stays opt-in: the extension runs it only when settings.json wires it, the same switch onboard and --protect-main flip - cleanup-wt --auto --fetch runs detached on interactive session start only; lanes already get the merge gate's --post-merge pass - install.sh copies extensions/*.ts to .pi/extensions/ (skipped by --no-hooks; installed by --no-hooks --protect-main for the push guard) - setup.py accepts --host pi; tests drive the real extension through Node type stripping against a stub Pi API and fake guard scripts --- README.md | 2 +- extensions/super-board-guards.ts | 162 +++++++++++++++++++++++++ hooks/README.md | 17 +++ install.sh | 18 ++- scripts/super-board-setup.py | 6 +- tests/fixtures/pi_guards.mjs | 196 +++++++++++++++++++++++++++++++ tests/test-install.sh | 14 ++- tests/test-pi-guards.sh | 7 ++ tests/test-setup.sh | 6 + 9 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 extensions/super-board-guards.ts create mode 100644 tests/fixtures/pi_guards.mjs create mode 100644 tests/test-pi-guards.sh diff --git a/README.md b/README.md index e50c6546..9aff227a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Flags: `run --low|--high` · `run --codex[=<model>]` · `run --pi[=<model>]` · ## Guards & hooks -Run automatically once installed. Python stdlib, JSON in, JSON out. +Run automatically once installed. Python stdlib, JSON in, JSON out. In Pi the same guards run through `.pi/extensions/super-board-guards.ts`. | | | | --- | --- | diff --git a/extensions/super-board-guards.ts b/extensions/super-board-guards.ts new file mode 100644 index 00000000..b8003395 --- /dev/null +++ b/extensions/super-board-guards.ts @@ -0,0 +1,162 @@ +/** + * super-board guards — Pi port of the .claude guard hooks. + * + * Pi loads project extensions only after the project is trusted (headless lanes + * pass `-a`). Each handler builds the same stdin JSON the Python guards already + * understand, runs the guard with python3, and turns a PreToolUse deny into + * `{ block, reason }`. A missing script or a broken guard never blocks a call, + * exactly like the hooks' fail-open contract. + * + * Mirrors hooks/settings-snippet.json: + * Bash -> guard-worktree-path, guard-delete-outside, guard-secrets, guard-key-literals + * Read|Grep -> guard-secrets + * Edit|Write -> guard-key-literals (PreToolUse; PostToolUse re-reads the file) + * session_start -> cleanup-wt.py --auto --fetch (interactive sessions only) + * guard-protected-push runs only when `.claude/settings.json` wires it + * (install.sh --protect-main / onboard), the same opt-in switch Claude uses. + * + * Pi's tool names and inputs differ from Claude's; `claudeTool` and + * `claudeInput` below are the whole translation layer. + */ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type Json = Record<string, unknown>; + +/** Pi tool name -> the Claude hook tool name the Python guards match on. */ +function claudeTool(toolName: string): string | undefined { + switch (toolName) { + case "bash": + return "Bash"; + case "read": + return "Read"; + case "grep": + return "Grep"; + case "edit": + return "Edit"; + case "write": + return "Write"; + default: + return undefined; + } +} + +/** Pi tool input -> the Claude hook tool_input fields the guards read. */ +function claudeInput(toolName: string, input: Json): Json { + if (toolName === "read") return { file_path: input.path }; + if (toolName === "write") return { file_path: input.path, content: input.content }; + if (toolName === "grep") return { pattern: input.pattern, path: input.path, glob: input.glob }; + if (toolName === "edit") { + const edits = Array.isArray(input.edits) ? (input.edits as Json[]) : []; + return { + file_path: input.path, + edits: edits.map((edit) => ({ old_string: edit.oldText, new_string: edit.newText })), + new_string: edits.map((edit) => String(edit.newText ?? "")).join("\n"), + }; + } + return input; +} + +/** Run one guard; returns its parsed stdout, or undefined on missing script/error/no output. */ +function runGuard(file: string, project: string, payload: Json): Json | undefined { + try { + if (!existsSync(file)) return undefined; + const result = spawnSync("python3", [file], { + cwd: project, + input: JSON.stringify(payload), + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, CLAUDE_PROJECT_DIR: project }, + }); + const text = (result.stdout ?? "").trim(); + return text ? (JSON.parse(text) as Json) : undefined; + } catch { + return undefined; // a broken guard must never block unrelated work + } +} + +function denyReason(output: Json | undefined): string | undefined { + const specific = (output?.hookSpecificOutput ?? {}) as Json; + if (specific.permissionDecision === "deny") { + return String(specific.permissionDecisionReason ?? "Blocked by super-board guard"); + } + return undefined; +} + +/** The protect-main guard is opt-in: settings.json says whether it was wired. */ +function protectsMain(project: string): boolean { + try { + const settings = join(project, ".claude", "settings.json"); + return existsSync(settings) && readFileSync(settings, "utf8").includes("guard-protected-push.py"); + } catch { + return false; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + const project = ctx.cwd; + const tool = claudeTool(event.toolName); + if (!tool) return undefined; + const hooks = join(project, ".claude", "hooks"); + const guards: string[] = []; + if (event.toolName === "bash") { + guards.push(join(hooks, "guard-worktree-path.py"), join(hooks, "guard-delete-outside.py")); + if (protectsMain(project)) guards.push(join(hooks, "guard-protected-push.py")); + } + if (event.toolName === "bash" || event.toolName === "read" || event.toolName === "grep") { + guards.push(join(hooks, "guard-secrets.py")); + } + if (event.toolName === "bash" || event.toolName === "edit" || event.toolName === "write") { + guards.push(join(hooks, "guard-key-literals.py")); + } + const payload = { + hook_event_name: "PreToolUse", + tool_name: tool, + tool_input: claudeInput(event.toolName, event.input as Json), + cwd: project, + }; + for (const guard of guards) { + const reason = denyReason(runGuard(guard, project, payload)); + if (reason) return { block: true, reason }; + } + return undefined; + }); + + // PostToolUse key-literals: re-read what reached disk and surface the warning + // with the tool result, where Claude's additionalContext would land. + pi.on("tool_result", async (event, ctx) => { + if (event.toolName !== "edit" && event.toolName !== "write") return undefined; + if (event.isError) return undefined; + const output = runGuard(join(ctx.cwd, ".claude", "hooks", "guard-key-literals.py"), ctx.cwd, { + hook_event_name: "PostToolUse", + tool_name: claudeTool(event.toolName)!, + tool_input: claudeInput(event.toolName, event.input as Json), + cwd: ctx.cwd, + }); + const specific = (output?.hookSpecificOutput ?? {}) as Json; + if (!specific.additionalContext) return undefined; + return { content: [...event.content, { type: "text" as const, text: String(specific.additionalContext) }] }; + }); + + // SessionStart parity: cleanup-wt --auto --fetch. Interactive sessions only — + // headless lanes get the post-merge cleanup through the merge gate, and a + // detached cleanup next to parallel lane worktrees would only add races. + pi.on("session_start", async (_event, ctx) => { + if (ctx.mode !== "tui" && ctx.mode !== "rpc") return; + const script = join(ctx.cwd, ".claude", "hooks", "cleanup-wt.py"); + if (!existsSync(script)) return; + try { + spawn("python3", [script, "--auto", "--fetch"], { + cwd: ctx.cwd, + detached: true, + stdio: "ignore", + env: { ...process.env, CLAUDE_PROJECT_DIR: ctx.cwd }, + }).unref(); + } catch { + // fail open, like the hook's `|| true` + } + }); +} diff --git a/hooks/README.md b/hooks/README.md index 08745fa7..1bae28ae 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -79,6 +79,23 @@ project and merges the block below (kept in `settings-snippet.json`) into } ``` +## Pi port: `.pi/extensions/super-board-guards.ts` + +Pi uses its own extension API instead of Claude Code hooks. `install.sh` copies +`extensions/super-board-guards.ts` into the target's `.pi/extensions/`, with the same +switches as the hooks: skipped by `--no-hooks`, installed by `--no-hooks --protect-main` +for the push guard alone. The extension translates Pi's tool calls (`bash`, `read`, +`grep`, `edit`, `write`) into the same stdin JSON these scripts already read, runs them +with `python3`, and turns a `permissionDecision: deny` into `{ block: true, reason }`. +A missing script, a crash or malformed output never blocks a call — the hooks' fail-open +contract. PostToolUse key-literals warnings ride along with the tool result, and +`cleanup-wt.py --auto --fetch` runs detached on interactive session start only (lanes +already get the merge gate's `--post-merge` run). `guard-protected-push.py` stays +opt-in: the extension runs it only when `.claude/settings.json` wires it, the same +switch `onboard` and `install.sh --protect-main` flip. Pi loads project extensions +only after the project is trusted; interactive users answer the trust prompt once, and +headless lanes pass `-a`. + ## Opt-in: block pushes to the base branch `guard-protected-push.py` — PreToolUse `Bash`: denies a `git push` that would update or delete diff --git a/install.sh b/install.sh index 009fbe83..a7adcd62 100755 --- a/install.sh +++ b/install.sh @@ -145,7 +145,17 @@ copy_file() { fi } -for script in super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do +# Pi loads project guards from .pi/extensions/; the extension drives the same +# .claude/hooks/*.py scripts, so it is only useful when they were installed. +install_pi_extension() { + mkdir -p "$TARGET/.pi/extensions" + for ext in "$REPO_ROOT"/extensions/*.ts; do + [ -f "$ext" ] || continue + copy_file "$ext" "$TARGET/.pi/extensions/$(basename "$ext")" + done +} + +for script in super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-pi-lane.sh super-board-pi-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do if [ -f "$REPO_ROOT/scripts/$script" ]; then copy_file "$REPO_ROOT/scripts/$script" "$TARGET/.claude/bin/$script" chmod +x "$TARGET/.claude/bin/$script" @@ -188,15 +198,17 @@ if [ "$HOOKS" -eq 1 ]; then done SNIPPETS="$REPO_ROOT/hooks/settings-snippet.json" if [ "$PROTECT" -eq 1 ]; then SNIPPETS="$SNIPPETS $REPO_ROOT/hooks/settings-protect-main.json"; fi + install_pi_extension # shellcheck disable=SC2086 - echo " 🛡️ $n guard hooks → .claude/settings.json ($(merge_settings $SNIPPETS))" + echo " 🛡️ $n guard hooks → .claude/settings.json + .pi/extensions/ ($(merge_settings $SNIPPETS))" elif [ "$PROTECT" -eq 1 ]; then # --no-hooks --protect-main: the one guard asked for, nothing else. This is the # command onboard names when its protect-main question finds no guard script. mkdir -p "$TARGET/.claude/hooks" copy_file "$REPO_ROOT/hooks/guard-protected-push.py" "$TARGET/.claude/hooks/guard-protected-push.py" chmod +x "$TARGET/.claude/hooks/guard-protected-push.py" - echo " 🛡️ --no-hooks: only the push guard → .claude/settings.json ($(merge_settings "$REPO_ROOT/hooks/settings-protect-main.json"))" + install_pi_extension + echo " 🛡️ --no-hooks: only the push guard → .claude/settings.json + .pi/extensions/ ($(merge_settings "$REPO_ROOT/hooks/settings-protect-main.json"))" else echo " ⏭️ guard hooks skipped (--no-hooks)" fi diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index acfab70f..32632218 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -71,7 +71,7 @@ } SKILLS = ["super-board", "super-build", "super-qa", "super-review", "super-collect", "visual", "git-sync", "ui-refine-loop"] OLD_SKILL_DIRS = ["super-refine", "cleanup-wt", "arch-loop"] -BIN = ["super-board-project-verifier.py", "super-board-skills-ready.py", "super-board-host.sh", "super-board-codex-wave.sh", "super-board-codex-lane.sh", "super-board-calibration.py", "super-board-verification-ready.py", "super-board-github-read.py", "super-board-collect-state.py", "super-board-run.sh", "super-board-gh-guard.sh", "super-board-card.sh", "super-board-status.py", "super-board-wave-plan.sh", +BIN = ["super-board-project-verifier.py", "super-board-skills-ready.py", "super-board-host.sh", "super-board-codex-wave.sh", "super-board-codex-lane.sh", "super-board-pi-wave.sh", "super-board-pi-lane.sh", "super-board-calibration.py", "super-board-verification-ready.py", "super-board-github-read.py", "super-board-collect-state.py", "super-board-run.sh", "super-board-gh-guard.sh", "super-board-card.sh", "super-board-status.py", "super-board-wave-plan.sh", "super-board-deps.sh", "super-board-preflight.sh", "super-board-merge-gate.sh", "super-board-merge-policy.py", "super-board-approval.py", "super-board-env-check.sh", "super-board-agents-md.py", "super-board-settings.py", "super-board-setup.py", "super-board-usage.sh", "super-board-pr-body.sh", @@ -962,8 +962,8 @@ def main(argv): if cmd in ("check", "fix"): pack = find_pack(flag(rest, "--pack")) host, home, catalog = flag(rest, "--host", "claude"), flag(rest, "--home", os.environ.get("SUPER_BOARD_SKILLS_HOME")), flag(rest, "--catalog", os.environ.get("SUPER_BOARD_SKILLS_CATALOG")) - if host not in ("claude", "codex"): - raise ValueError("--host must be claude or codex") + if host not in ("claude", "codex", "pi"): + raise ValueError("--host must be claude, codex or pi") res = check(root, pack, host, home, catalog) if cmd == "check" else fix(root, pack, "--no-helpers" not in rest, dry, host, home, catalog) if res.get("error") == "pack-not-found": print(json.dumps(res, indent=1)) diff --git a/tests/fixtures/pi_guards.mjs b/tests/fixtures/pi_guards.mjs new file mode 100644 index 00000000..3d0b1484 --- /dev/null +++ b/tests/fixtures/pi_guards.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Harness for extensions/super-board-guards.ts — run via tests/test-pi-guards.sh. +// +// Stubs Pi's extension API (registering only what the extension uses), plants +// fake .claude/hooks/*.py guards that log the payload they received, and drives +// the handlers directly. No real Pi, no network: node + python3 only. Node's +// built-in type stripping runs the TypeScript extension as-is. +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const PACK = process.argv[2]; +let failures = 0; +const ok = (condition, message) => { + if (!condition) { + failures += 1; + console.error(`FAIL: ${message}`); + } +}; +const until = async (check, ms = 3000) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await check()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return false; +}; + +const root = await mkdtemp(join(tmpdir(), "sb-pi-guards-")); +const hooks = join(root, ".claude", "hooks"); +const log = join(root, "guards.jsonl"); +await mkdir(hooks, { recursive: true }); +process.env.GUARD_LOG = log; + +async function stub(dir, name, body) { + const text = [ + "#!/usr/bin/env python3", + "import json, os, sys", + 'payload = json.loads(sys.stdin.read() or "{}")', + 'with open(os.environ["GUARD_LOG"], "a") as fh:', + ` fh.write(json.dumps({"guard": "${name}", "project": os.environ.get("CLAUDE_PROJECT_DIR"), "payload": payload}) + "\\n")`, + body, + ].join("\n"); + await writeFile(join(dir, name), `${text}\n`, { mode: 0o755 }); +} + +// worktree: allows. delete: crashes with garbage (fail-open case). secrets: +// denies reads/greps. key-literals: warns only after edits. push: denies. +await stub(hooks, "guard-worktree-path.py", "pass"); +await stub(hooks, "guard-delete-outside.py", 'print("boom")\nsys.exit(1)'); +await stub( + hooks, + "guard-secrets.py", + 'if payload.get("tool_name") in ("Read", "Grep"):\n' + + ' print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "secret file blocked"}}))', +); +await stub( + hooks, + "guard-key-literals.py", + 'if payload.get("hook_event_name") == "PostToolUse":\n' + + ' print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "live key on disk at line 3"}}))', +); +await stub( + hooks, + "guard-protected-push.py", + 'print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "protected branch"}}))', +); + +const module = await import(pathToFileURL(join(PACK, "extensions", "super-board-guards.ts")).href); +const handlers = new Map(); +module.default({ on: (name, handler) => handlers.set(name, handler) }); +const ctx = { cwd: root, mode: "print", hasUI: false }; + +const readLog = async () => { + try { + return (await readFile(log, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)); + } catch { + return []; + } +}; +const clearLog = () => writeFile(log, ""); + +// Unmapped tools (find, ls, custom) never spawn a guard. +await clearLog(); +ok((await handlers.get("tool_call")({ toolName: "find", input: { pattern: "x" } }, ctx)) === undefined, "unmapped tools pass through"); +ok((await readLog()).length === 0, "unmapped tools spawn no guard"); + +// Bash runs worktree/delete/secrets/key-literals; the crash in delete fails open. +await clearLog(); +let result = await handlers.get("tool_call")({ toolName: "bash", input: { command: "git push origin main" } }, ctx); +ok(result === undefined, "a crashing guard never blocks a call"); +let entries = await readLog(); +ok(entries.filter((entry) => entry.guard === "guard-protected-push.py").length === 0, "the opt-in push guard stays off until settings.json wires it"); +for (const name of ["guard-worktree-path.py", "guard-delete-outside.py", "guard-secrets.py", "guard-key-literals.py"]) { + ok(entries.some((entry) => entry.guard === name), `bash reaches ${name}`); +} +const worktree = entries.find((entry) => entry.guard === "guard-worktree-path.py"); +ok( + worktree.payload.hook_event_name === "PreToolUse" && + worktree.payload.tool_name === "Bash" && + worktree.payload.tool_input.command === "git push origin main" && + worktree.payload.cwd === root && + worktree.project === root, + "bash arrives in the Claude hook payload shape", +); + +// Wired settings.json turns the opt-in push guard on. +await writeFile( + join(root, ".claude", "settings.json"), + JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/guard-protected-push.py' }] }] } }), +); +await clearLog(); +result = await handlers.get("tool_call")({ toolName: "bash", input: { command: "git push origin main" } }, ctx); +ok(result?.block === true && result.reason === "protected branch", "a wired push guard blocks with its reason"); +ok((await readLog()).some((entry) => entry.guard === "guard-protected-push.py"), "a wired push guard runs for bash"); +await rm(join(root, ".claude", "settings.json")); + +// Read: path -> file_path; secrets denies; key-literals never sees it. +await clearLog(); +result = await handlers.get("tool_call")({ toolName: "read", input: { path: ".env", offset: 1, limit: 5 } }, ctx); +ok(result?.block === true && result.reason === "secret file blocked", "reading a secret is blocked"); +entries = await readLog(); +ok( + entries.length === 1 && entries[0].guard === "guard-secrets.py" && entries[0].payload.tool_name === "Read" && entries[0].payload.tool_input.file_path === ".env", + "read maps path to file_path and runs only the secrets guard", +); + +// Grep: pattern/path/glob survive the translation. +await clearLog(); +result = await handlers.get("tool_call")({ toolName: "grep", input: { pattern: "KEY", path: "src", glob: "*.env" } }, ctx); +ok(result?.block === true, "grepping for secrets is blocked"); +entries = await readLog(); +ok( + entries[0].payload.tool_name === "Grep" && entries[0].payload.tool_input.pattern === "KEY" && entries[0].payload.tool_input.path === "src" && entries[0].payload.tool_input.glob === "*.env", + "grep maps pattern/path/glob", +); + +// Edit: edits[] -> new_string and edits[].new_string. +await clearLog(); +result = await handlers.get("tool_call")({ toolName: "edit", input: { path: "app/config.py", edits: [{ oldText: 'K = ""', newText: 'K = "sk-live-123"' }] } }, ctx); +ok(result === undefined, "edit passes when guards allow"); +entries = await readLog(); +ok( + entries.length === 1 && + entries[0].payload.tool_name === "Edit" && + entries[0].payload.tool_input.file_path === "app/config.py" && + entries[0].payload.tool_input.new_string === 'K = "sk-live-123"' && + entries[0].payload.tool_input.edits[0].new_string === 'K = "sk-live-123"', + "edit maps edits to the Claude Edit shape", +); + +// Write: content survives. +await clearLog(); +await handlers.get("tool_call")({ toolName: "write", input: { path: "notes.txt", content: "hello" } }, ctx); +entries = await readLog(); +ok( + entries[0].payload.tool_name === "Write" && entries[0].payload.tool_input.file_path === "notes.txt" && entries[0].payload.tool_input.content === "hello", + "write maps path/content", +); + +// PostToolUse key-literals warning rides with the tool result. +const appended = await handlers.get("tool_result")( + { toolName: "edit", input: { path: "notes.txt", edits: [] }, content: [{ type: "text", text: "edited" }], isError: false }, + ctx, +); +ok(appended?.content?.length === 2 && appended.content[1].text === "live key on disk at line 3", "a post-write warning is appended to the tool result"); +ok((await handlers.get("tool_result")({ toolName: "edit", input: {}, content: [], isError: true }, ctx)) === undefined, "failed tool results are left alone"); +ok((await handlers.get("tool_result")({ toolName: "read", input: {}, content: [], isError: false }, ctx)) === undefined, "only edit/write results are re-checked"); + +// A project without hook scripts stays untouched (fail-open install). +const empty = await mkdtemp(join(tmpdir(), "sb-pi-empty-")); +ok((await handlers.get("tool_call")({ toolName: "bash", input: { command: "rm -rf /" } }, { cwd: empty, mode: "print" })) === undefined, "missing guard scripts leave calls untouched"); + +// SessionStart parity: interactive only, cleanup-wt --auto --fetch, detached. +const emptyHooks = join(empty, ".claude", "hooks"); +const cleanupMarker = join(empty, "cleanup.args"); +await mkdir(emptyHooks, { recursive: true }); +await writeFile( + join(emptyHooks, "cleanup-wt.py"), + '#!/usr/bin/env python3\nimport os, sys\nopen(os.path.join(os.path.dirname(__file__), "..", "..", "cleanup.args"), "w").write(" ".join(sys.argv[1:]))\n', + { mode: 0o755 }, +); +await handlers.get("session_start")({}, { cwd: empty, mode: "print" }); +await new Promise((resolve) => setTimeout(resolve, 150)); +ok(!(await readFile(cleanupMarker, "utf8").catch(() => "")), "print-mode session start skips cleanup"); +await handlers.get("session_start")({}, { cwd: empty, mode: "tui" }); +ok(await until(async () => (await readFile(cleanupMarker, "utf8").catch(() => "")) === "--auto --fetch"), "interactive session start runs cleanup-wt --auto --fetch"); + +await rm(root, { recursive: true, force: true }); +await rm(empty, { recursive: true, force: true }); +if (failures > 0) { + console.error(`${failures} assertion(s) failed`); + process.exit(1); +} +console.log("PASS: test-pi-guards.sh (payload mapping, fail-open, opt-in push, session start)"); diff --git a/tests/test-install.sh b/tests/test-install.sh index acdb2e5b..8b7ba9bf 100755 --- a/tests/test-install.sh +++ b/tests/test-install.sh @@ -166,7 +166,8 @@ rm -rf "$T" T=$(mktemp -d) "$INSTALL" "$T" >/dev/null 2>&1 for s in super-board-run.sh super-board-stop.sh super-board-wave-plan.sh \ - super-board-codex-lane.sh super-board-codex-wave.sh super-board-card.sh super-board-throttle.sh super-board-host.sh \ + super-board-codex-lane.sh super-board-codex-wave.sh super-board-pi-lane.sh super-board-pi-wave.sh \ + super-board-card.sh super-board-throttle.sh super-board-host.sh \ super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-usage.sh super-board-pr-body.sh \ super-review-file-refactor.sh super-qa-file-bug.sh; do [ -x "$T/.claude/bin/$s" ] || fail "$s was not installed, or is not executable" @@ -186,6 +187,9 @@ for h in guard-worktree-path.py guard-secrets.py guard-key-literals.py guard-del done [ ! -e "$T/.claude/skills/cleanup-wt" ] || fail "cleanup-wt is a hook now, not a skill" [ ! -e "$T/.claude/hooks/dev" ] && [ ! -e "$T/.claude/hooks/gate-skill-evals.py" ] || fail "hooks/dev/ (skill-eval gate) must never be installed into a target" +[ -f "$T/.pi/extensions/super-board-guards.ts" ] || fail "the Pi guards extension was not installed" +grep -q 'guard-worktree-path.py' "$T/.pi/extensions/super-board-guards.ts" || fail "the Pi extension must drive the guard scripts" +grep -q 'guard-protected-push.py' "$T/.pi/extensions/super-board-guards.ts" || fail "the Pi extension must know the opt-in push guard" S="$T/.claude/settings.json" jq -e '.permissions.allow == ["Bash(ls:*)"]' "$S" >/dev/null || fail "existing permissions were lost" jq -e '[.hooks.PreToolUse[].hooks[].command] | index("echo mine") != null' "$S" >/dev/null || fail "existing PreToolUse hook was lost" @@ -207,11 +211,12 @@ jq -e '[.hooks[] | [.[].hooks[].command] as $c | ($c | length) == ($c | unique | || fail "a hook command appears twice in one event" rm -rf "$T" -# 7 — --no-hooks: no hook scripts, no settings.json written. +# 7 — --no-hooks: no hook scripts, no settings.json written, no Pi extension. T=$(mktemp -d) "$INSTALL" --no-hooks "$T" >/dev/null 2>&1 [ ! -e "$T/.claude/hooks" ] || fail "--no-hooks still installed hooks" [ ! -e "$T/.claude/settings.json" ] || fail "--no-hooks still wrote settings.json" +[ ! -e "$T/.pi/extensions" ] || fail "--no-hooks still installed the Pi guards extension" [ -f "$T/.claude/skills/super-board/SKILL.md" ] || fail "--no-hooks must still install the skills" rm -rf "$T" @@ -220,6 +225,7 @@ rm -rf "$T" T=$(mktemp -d) "$INSTALL" --no-hooks --protect-main "$T" >/dev/null 2>&1 [ "$(ls "$T/.claude/hooks")" = "guard-protected-push.py" ] || fail "--no-hooks --protect-main should install only the push guard, got: $(ls "$T/.claude/hooks")" +[ -f "$T/.pi/extensions/super-board-guards.ts" ] || fail "--no-hooks --protect-main should still port the push guard to Pi" jq -e '[.hooks[][].hooks[].command] == ["python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-protected-push.py"]' "$T/.claude/settings.json" >/dev/null \ || fail "--no-hooks --protect-main should wire only the push guard" rm -rf "$T" @@ -264,7 +270,7 @@ OUT=$(SUPER_BOARD_QUIET_NEXT=1 "$INSTALL" "$T" 2>&1) [ "$(echo "$OUT" | head -1)" = "🔧 installing" ] || fail "quiet mode should start at the installing group, got: $OUT" echo "$OUT" | grep -q "next:" && fail "quiet mode must not print the next step" echo "$OUT" | grep -q "✓ skills, scripts and workflows → .claude/" || fail "quiet mode should print the grouped files line" -echo "$OUT" | grep -q "🛡️ 6 guard hooks → .claude/settings.json (new file)" || fail "guard line should count hooks, got: $OUT" +echo "$OUT" | grep -q "🛡️ 6 guard hooks → .claude/settings.json + .pi/extensions/ (new file)" || fail "guard line should count hooks, got: $OUT" [ "$(echo "$OUT" | wc -l | tr -d ' ')" -le 3 ] || fail "grouped output should be ≤ 3 lines here, got: $OUT" rm -rf "$T" @@ -285,7 +291,7 @@ rm -rf "$T" T=$(mktemp -d); printf '# AGENTS.md\n- mine\n' > "$T/AGENTS.md" "$INSTALL" --no-hooks "$T" >/dev/null 2>&1 [ "$(cat "$T/AGENTS.md")" = "$(printf '# AGENTS.md\n- mine')" ] || fail "install must not add a block uninvited" -for s in super-board-skills-ready.py super-board-host.sh super-board-codex-wave.sh super-board-codex-lane.sh super-board-env-check.sh super-board-merge-policy.py super-board-agents-md.py super-board-settings.py super-board-setup.py; do +for s in super-board-skills-ready.py super-board-host.sh super-board-codex-wave.sh super-board-codex-lane.sh super-board-pi-wave.sh super-board-pi-lane.sh super-board-env-check.sh super-board-merge-policy.py super-board-agents-md.py super-board-settings.py super-board-setup.py; do [ -x "$T/.claude/bin/$s" ] || fail "$s was not installed" done rm -rf "$T" diff --git a/tests/test-pi-guards.sh b/tests/test-pi-guards.sh new file mode 100644 index 00000000..d0edf7e9 --- /dev/null +++ b/tests/test-pi-guards.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Node's type stripping runs extensions/super-board-guards.ts against a stub Pi +# extension API and fake guard scripts (tests/fixtures/pi_guards.mjs): the Pi +# payload mapping, fail-open contract, opt-in push guard and session start. +set -euo pipefail +KIT=$(cd "$(dirname "$0")/.." && pwd) +exec node "$KIT/tests/fixtures/pi_guards.mjs" "$KIT" diff --git a/tests/test-setup.sh b/tests/test-setup.sh index fd90b397..4ab68a06 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -41,6 +41,12 @@ for helper in super-board-approval.py super-board-github-read.py super-board-pro echo "$OUT" | q '.missing == []' || fail "repair must leave no missing scripts: $OUT" done +# 1c — host validation: codex and pi are accepted, anything else refuses. +OUT=$(python3 "$SETUP" check --root "$T" --host pi || true) +echo "$OUT" | q '.missing' || fail "pi host should be accepted: $OUT" +RC=0; python3 "$SETUP" check --root "$T" --host p1 >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 2 ] || fail "--host must accept claude, codex or pi (rc=$RC)" + # 2 — upgrade: an older super-board (VERSION 1.8.2, removed skill folders, a qa-only # config with Skipped, old collect keys, telegram) is detected and upgraded with # no question, backed up first, and listed as "Upgraded for you". From 6f57a487e156b79a301d1c6b85959a48732e50f3 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 01:06:55 -0500 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=96=20[release]=203.2.0=20?= =?UTF-8?q?=E2=80=94=20Pi=20as=20a=20third=20agent=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RELEASE-NOTES.md | 35 +++++++++++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index dde5f349..94e0cc79 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,40 @@ # Release notes +## v3.2.0 — 2026-10-17 + +Pi (pi.dev) is a third agent host: `/super-board run --pi` drains the board on Pi's models. + +- ✨ **A Pi lane and wave.** `super-board-pi-wave.sh` → `super-board-pi-lane.sh` runs every + lane headless (`pi -p -a`) with the same claims, worktrees, handoff and halt contract as + Codex. Pi has no `--output-schema`: the worker writes its final JSON to a private result + file that the wrapper validates fail-closed (a missing, malformed or contradicting file + fails the lane; exit 9/10/79 keep their halt meanings). +- ✨ **`--pi[=<model>] [--low|--high]`.** Ladders default to haiku/sonnet/opus and Pi resolves + a model pattern fuzzily across the providers you connected; a haiku router grades each + Ready card and `pi.model` / `pi.router_model` / `pi.ladders` override the defaults. + `--pi` is always required: unlike Codex there is no Claude-usage fallback that may start + Pi on its own, and `review` without the flag refuses with its own message. +- ✨ **A Pi session means `--pi`.** `super-board-host.sh` reads `AI_AGENT=pi` / + `PI_CODING_AGENT=true` before the Codex ids, a plain `run` inside Pi takes the Pi ladder, + and Pi invokes the skill as `/skill:super-board run`. +- 🛡️ **The guards port to Pi.** `.pi/extensions/super-board-guards.ts` translates Pi tool + calls into the same payloads the `.claude` hooks read, so worktree, secrets, key-literal, + delete-outside and the opt-in protected-push guard behave identically — fail-open, like + the hooks. `install.sh` installs it; `--no-hooks` skips it, and + `--no-hooks --protect-main` installs it with the push guard alone. +- 🔧 **Stop and calibration know Pi.** `super-board-stop.sh` validates and stops a Pi wave + (its own `pi-wave.pid`) with the same SIGTERM-then-claims order; a completed calibration + receipt is refused while a Pi wave marker exists. +- 🔎 **Skill readiness resolves Pi's real surfaces**: the scoped `.agents/skills` search, + project `.pi/skills`, user and agent-directory roots, and the fail-closed + `skills`/`packages` settings policy (authoritative `--catalog` unblocks it). +- 📝 **Docs.** `references/run-workflow.md` gains "Pi runs", the README models table gains + Pi rows, and `references/config-schema.json` documents the `pi` block. +- 🧪 **Offline tests.** `tests/test-pi-lane.sh` (opt-in, models, ladders, wave, halt, + cancellation), the Pi marker scenarios in `tests/test-codex-stop.sh`, and + `tests/test-pi-guards.sh`, which runs the real extension through Node's type stripping + against a stub Pi API. + ## v3.1.2 — 2026-10-11 `/super-collect` dedupes on the exact fingerprint and says when Sentry or PostHog is down. diff --git a/VERSION b/VERSION index ef538c28..944880fa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.2 +3.2.0 From b450489ae66d5567cb53fab2652a89b2e785bb78 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 01:20:50 -0500 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20pi:=20ladders=20re?= =?UTF-8?q?solve=20to=20the=20claude-*-latest=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a live smoke showed bare haiku/sonnet/opus resolving to a ':batch' catalog variant that no chat endpoint accepts; the PI_LADDERS defaults and the router default now use claude-haiku-latest / claude-sonnet-latest / claude-opus-latest, which resolve unambiguously and take :thinking - the worktree guard's reason no longer sends Pi users to Claude-only tools: it names the host's own isolation and the Claude spelling as one example - docs (run-workflow, README table, config schema, release notes) and the lane tests follow the new defaults --- README.md | 11 +++--- RELEASE-NOTES.md | 13 ++++--- hooks/guard-worktree-path.py | 4 +-- install.sh | 5 +-- scripts/super-board-pi-lane.sh | 14 ++++---- scripts/super-board-pi-wave.sh | 11 +++--- .../super-board/references/config-schema.json | 5 +-- skills/super-board/references/run-workflow.md | 10 +++--- tests/test-pi-lane.sh | 34 +++++++++---------- 9 files changed, 60 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 9aff227a..dd573ef7 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,15 @@ A cheap router grades each card easy, medium or hard first; the run flag picks t | `/super-board run --codex` | gpt-6-luna | gpt-6.1-sol | gpt-6.1-sol | gpt-6-astra | | `/super-board run --codex --low` | gpt-6-luna | gpt-6-luna | gpt-6.1-sol | gpt-6.1-sol | | `/super-board run --codex --high` | gpt-6-luna | gpt-6-astra | gpt-6-astra | gpt-6-astra | -| `/super-board run --pi` | haiku | sonnet | sonnet | opus | -| `/super-board run --pi --low` | haiku | haiku | sonnet | sonnet | -| `/super-board run --pi --high` | haiku | opus | opus | opus | +| `/super-board run --pi` | claude-haiku-latest | claude-sonnet-latest | claude-sonnet-latest | claude-opus-latest | +| `/super-board run --pi --low` | claude-haiku-latest | claude-haiku-latest | claude-sonnet-latest | claude-sonnet-latest | +| `/super-board run --pi --high` | claude-haiku-latest | claude-opus-latest | claude-opus-latest | claude-opus-latest | `/super-board run --codex=<model>` or `--pi=<model>` pins one model for every card and skips the router. -The Pi names are defaults: Pi resolves a model pattern fuzzily across the providers you connected, so a board on another provider overrides `pi.ladders` (and `pi.model`/`pi.router_model`) in the config. +The Pi names are defaults: Pi resolves a model pattern across the providers you connected, and bare +`haiku`/`sonnet`/`opus` can land on a `:batch` catalog variant no chat endpoint accepts, so the +defaults use the `claude-<family>-latest` aliases — a board on another provider overrides +`pi.ladders` (and `pi.model`/`pi.router_model`) in the config, and a `:thinking` suffix still applies. Run inside Codex or Pi? plain `/super-board run` switches to that host's ladder by itself. Codex has no `/super-board` command: it finds skills in `.agents/skills/` (symlinks followed), so link diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 94e0cc79..a33fbe3c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -9,11 +9,14 @@ Pi (pi.dev) is a third agent host: `/super-board run --pi` drains the board on P Codex. Pi has no `--output-schema`: the worker writes its final JSON to a private result file that the wrapper validates fail-closed (a missing, malformed or contradicting file fails the lane; exit 9/10/79 keep their halt meanings). -- ✨ **`--pi[=<model>] [--low|--high]`.** Ladders default to haiku/sonnet/opus and Pi resolves - a model pattern fuzzily across the providers you connected; a haiku router grades each - Ready card and `pi.model` / `pi.router_model` / `pi.ladders` override the defaults. - `--pi` is always required: unlike Codex there is no Claude-usage fallback that may start - Pi on its own, and `review` without the flag refuses with its own message. +- ✨ **`--pi[=<model>] [--low|--high]`.** Ladders default to the + `claude-haiku-latest` / `claude-sonnet-latest` / `claude-opus-latest` aliases and Pi resolves + a model pattern across the providers you connected (bare `haiku`/`sonnet`/`opus` can land on + a `:batch` catalog variant no chat endpoint accepts — found in a live smoke); a + `claude-haiku-latest` router grades each Ready card and `pi.model` / `pi.router_model` / + `pi.ladders` override the defaults. `--pi` is always required: unlike Codex there is no + Claude-usage fallback that may start Pi on its own, and `review` without the flag refuses + with its own message. - ✨ **A Pi session means `--pi`.** `super-board-host.sh` reads `AI_AGENT=pi` / `PI_CODING_AGENT=true` before the Codex ids, a plain `run` inside Pi takes the Pi ladder, and Pi invokes the skill as `/skill:super-board run`. diff --git a/hooks/guard-worktree-path.py b/hooks/guard-worktree-path.py index 77b5ec78..8bfe70f6 100755 --- a/hooks/guard-worktree-path.py +++ b/hooks/guard-worktree-path.py @@ -157,8 +157,8 @@ def check(command, cwd, root_of, env): where = ("resolves to " + resolved) if resolved else "cannot be resolved before it runs" return ( 'Blocked: worktree path "%s" %s, outside %s/.\n' - "Worktrees live only under .claude/worktrees/ so cleanup-wt and Claude Code's sweep can see them.\n" - 'Prefer the Agent tool\'s isolation: "worktree" or `claude --worktree <name>`. If you need git directly:\n' + "Worktrees live only under .claude/worktrees/ so cleanup-wt and the host's worktree sweep can see them.\n" + 'Prefer your host\'s isolation (Claude Code: the Agent tool\'s "worktree" option or `claude --worktree <name>`). If you need git directly:\n' " git worktree add .claude/worktrees/<name> -b <branch> <base>\n" "(from the repo root, with a literal path)." % (raw, where, allowed) ) diff --git a/install.sh b/install.sh index a7adcd62..5ed25991 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # super-board installer. # Copies skills/, scripts/, workflows/ and the guard hooks into the target -# project's .claude/ tree, and merges the hook settings into -# .claude/settings.json (backed up first, never duplicated). +# project's .claude/ tree, the Pi guard extension into .pi/extensions/, and +# merges the hook settings into .claude/settings.json (backed up first, never +# duplicated). # # Usage: # ./install.sh [--no-hooks] [--protect-main] [target-project-dir] diff --git a/scripts/super-board-pi-lane.sh b/scripts/super-board-pi-lane.sh index f1044e94..ae0f3c6c 100755 --- a/scripts/super-board-pi-lane.sh +++ b/scripts/super-board-pi-lane.sh @@ -69,13 +69,15 @@ try: raise ValueError('pi.model must be a string') model = model if model is not None else (configured if configured and configured.strip() else None) # Pi run ladders, the one copy: run tier x card complexity -> `pi --model`. - # Pi resolves patterns fuzzily across whatever providers the user connected, - # so the defaults are the Claude-family names with an optional `:thinking` - # suffix; boards on other providers override pi.ladders in the config. + # Pi resolves patterns fuzzily across whatever providers the user connected. + # Bare `haiku`/`sonnet`/`opus` can land on a `:batch` catalog variant that + # no chat endpoint accepts, so the defaults use the unambiguous + # `claude-<family>-latest` aliases (an optional `:thinking` suffix still + # applies); boards on other providers override pi.ladders in the config. PI_LADDERS = { - 'low': {'low': 'haiku', 'medium': 'sonnet', 'high': 'sonnet'}, - 'medium': {'low': 'sonnet', 'medium': 'sonnet', 'high': 'opus'}, - 'high': {'low': 'opus', 'medium': 'opus', 'high': 'opus'}, + 'low': {'low': 'claude-haiku-latest', 'medium': 'claude-sonnet-latest', 'high': 'claude-sonnet-latest'}, + 'medium': {'low': 'claude-sonnet-latest', 'medium': 'claude-sonnet-latest', 'high': 'claude-opus-latest'}, + 'high': {'low': 'claude-opus-latest', 'medium': 'claude-opus-latest', 'high': 'claude-opus-latest'}, } configured_ladders = (config.get('pi') or {}).get('ladders') if configured_ladders is not None: diff --git a/scripts/super-board-pi-wave.sh b/scripts/super-board-pi-wave.sh index 3e318b55..3e52ead4 100755 --- a/scripts/super-board-pi-wave.sh +++ b/scripts/super-board-pi-wave.sh @@ -3,8 +3,9 @@ # Claude workflow's {cards:[summary...], halted} reconciliation contract. # Usage: --config f --cards '<JSON array>' --pi[=model] [--tier low|medium|high] [--output f] # Unless one model is pinned (--pi=<model> or pi.model), a cheap router -# (pi.router_model, default haiku) grades each Ready card low/medium/high; the -# lane launcher maps tier x grade to a model through its PI_LADDERS table. +# (pi.router_model, default claude-haiku-latest) grades each Ready card +# low/medium/high; the lane launcher maps tier x grade to a model through its +# PI_LADDERS table. set -euo pipefail exec python3 - "${BASH_SOURCE[0]}" "$@" <<'PY' import argparse @@ -57,7 +58,7 @@ try: root = Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()).resolve() configured = (config.get('pi') or {}).get('model') pinned = pi_flags[0].startswith('--pi=') or (isinstance(configured, str) and configured.strip()) - router = (config.get('pi') or {}).get('router_model') or 'haiku' + router = (config.get('pi') or {}).get('router_model') or 'claude-haiku-latest' if not isinstance(router, str) or not router.strip(): raise ValueError('pi.router_model must be a non-empty string') ROUTER_MODEL = router @@ -132,8 +133,8 @@ except FileExistsError: parser.error('Pi wave PID marker exists; reconcile the previous wave before starting another') # The router only grades scope; it writes nothing. A Pi run grades with the -# configured cheap model (pi.router_model, default haiku) rather than waking -# Claude; a failed grade falls back to the medium ladder cell. +# configured cheap model (pi.router_model, default claude-haiku-latest) rather +# than waking Claude; a failed grade falls back to the medium ladder cell. def grade(card): """Return (complexity or None, halted). A failed grade falls back to the medium cell.""" if pinned or card['status'] != 'Ready': diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index 70a0e768..605e29e7 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -69,12 +69,13 @@ // (run tier x card grade, see scripts/super-board-pi-lane.sh). "model": null, // Optional cheap model grading each Ready card before its lanes (default - // "haiku"); resolved fuzzily across the providers the Pi session has. + // "claude-haiku-latest"); resolved across the providers the Pi session has. "router_model": null, // Optional override of the built-in tier x grade table. Keys must be // low/medium/high tiers, each with low/medium/high grades and non-empty // model strings; Pi accepts an optional `:thinking` suffix (for example - // "sonnet:high"). Absent/null keeps PI_LADDERS in the lane launcher. + // "claude-sonnet-latest:high"). Absent/null keeps PI_LADDERS in the lane + // launcher (the claude-<family>-latest aliases). "ladders": null }, diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 9102eb23..e926fd0a 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -44,11 +44,13 @@ use the lane launcher's sandbox settings, including an external submodule git di overriding `worker_backend`. Use it to run the board on Pi's connected providers. `--pi --low` / `--pi --high` pick the Pi ladder the same way the tier flags pick Claude's (flag, else config `model_tier`, else medium); pass it as `--tier`. -A cheap router (`pi.router_model`, default `haiku`) grades each Ready card +A cheap router (`pi.router_model`, default `claude-haiku-latest`) grades each Ready card low/medium/high, and the lane launcher's `PI_LADDERS` table maps tier × grade to -`--model <model>`; Pi resolves a model pattern fuzzily across connected providers, -so the defaults are Claude-family names and a board on other providers overrides -`pi.ladders` in the config. A card entering past Ready takes its tier's medium cell. +`--model <model>`, an optional `:thinking` suffix included; Pi resolves a model +pattern across connected providers, so the defaults are the `claude-<family>-latest` +aliases (bare `haiku`/`sonnet`/`opus` can land on a `:batch` catalog variant no chat +endpoint accepts) and a board on other providers overrides `pi.ladders` in the config. +A card entering past Ready takes its tier's medium cell. `--pi=<model>`, else optional `pi.model`, pins one model for every card and skips the router. Keep this run choice in each wave and resume command; skip only the Claude usage guard. diff --git a/tests/test-pi-lane.sh b/tests/test-pi-lane.sh index 543216de..e3436497 100644 --- a/tests/test-pi-lane.sh +++ b/tests/test-pi-lane.sh @@ -111,20 +111,20 @@ while read -r TIER GRADE WANT; do lane build --pi --tier "$TIER" ${GRADE:+--complexity "$GRADE"} >"$T/out" [ "$(last_model)" = "$WANT" ] || fail "ladder $TIER/$GRADE must pick $WANT, got $(last_model)" done <<'CELLS' -medium low sonnet -medium medium sonnet -medium high opus -low low haiku -low medium sonnet -low high sonnet -high low opus -high medium opus -high high opus +medium low claude-sonnet-latest +medium medium claude-sonnet-latest +medium high claude-opus-latest +low low claude-haiku-latest +low medium claude-sonnet-latest +low high claude-sonnet-latest +high low claude-opus-latest +high medium claude-opus-latest +high high claude-opus-latest CELLS lane qa --pi --tier medium >"$T/out" -[ "$(last_model)" = sonnet ] || fail 'ungraded default card takes the medium cell' +[ "$(last_model)" = claude-sonnet-latest ] || fail 'ungraded default card takes the medium cell' lane qa --pi --tier low >"$T/out" -[ "$(last_model)" = sonnet ] || fail 'ungraded --low card takes the medium cell' +[ "$(last_model)" = claude-sonnet-latest ] || fail 'ungraded --low card takes the medium cell' lane build --pi=flag-model --tier high --complexity high >"$T/out" [ "$(last_model)" = flag-model ] || fail 'explicit --pi=<model> beats the ladder' echo '{"pi":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" @@ -132,7 +132,7 @@ lane build --pi --tier low --complexity low >"$T/out" [ "$(last_model)" = config-model ] || fail 'pi.model beats the ladder' echo '{"pi":{"model":""},"verify_commands":["git diff --check"]}' > "$T/config.json" lane build --pi --tier high --complexity low >"$T/out" -[ "$(last_model)" = opus ] || fail 'empty pi.model leaves the ladder in charge' +[ "$(last_model)" = claude-opus-latest ] || fail 'empty pi.model leaves the ladder in charge' echo '{"pi":{"ladders":{"low":{"low":"l-low","medium":"l-med","high":"l-high"},"medium":{"low":"m-low","medium":"m-med","high":"m-high"},"high":{"low":"h-low","medium":"h-med","high":"h-high"}}},"verify_commands":["git diff --check"]}' > "$T/config.json" lane build --pi --tier high --complexity low >"$T/out" [ "$(last_model)" = h-low ] || fail 'pi.ladders override must drive the ladder' @@ -178,18 +178,18 @@ import json, pathlib, sys calls = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] grades = [call for call in calls if call['lane'] == 'grade'] assert [call['number'] for call in grades] == [1], grades -assert grades[0]['model'] == 'haiku' +assert grades[0]['model'] == 'claude-haiku-latest' card1 = [call['model'] for call in calls if call['number'] == 1 and call['lane'] != 'grade'] -assert card1 == ['haiku'] * 3, card1 # --low x low on build, qa and review +assert card1 == ['claude-haiku-latest'] * 3, card1 # --low x low on build, qa and review card2 = [call['model'] for call in calls if call['number'] == 2] -assert card2 == ['sonnet'], card2 # ungraded Review card: --low medium cell +assert card2 == ['claude-sonnet-latest'], card2 # ungraded Review card: --low medium cell PY : > "$SB_PI_TEST_LOG" SB_PI_TEST_GRADE=high wave --pi >"$T/out" -jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "opus")' "$SB_PI_TEST_LOG" >/dev/null || fail 'default ladder sends a hard card to opus' +jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "claude-opus-latest")' "$SB_PI_TEST_LOG" >/dev/null || fail 'default ladder sends a hard card to opus' : > "$SB_PI_TEST_LOG" SB_PI_TEST_GRADE_EXIT=1 wave --pi --tier high >"$T/out" -jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "opus")' "$SB_PI_TEST_LOG" >/dev/null || fail 'a failed grade falls back to the medium cell' +jq -s -e '[.[] | select(.number == 1 and .lane != "grade") | .model] | all(. == "claude-opus-latest")' "$SB_PI_TEST_LOG" >/dev/null || fail 'a failed grade falls back to the medium cell' : > "$SB_PI_TEST_LOG" echo '{"max_workers":1,"pi":{"router_model":"router-x"},"verify_commands":["git diff --check"]}' > "$T/config.json" SB_PI_TEST_GRADE=low wave --pi >"$T/out" From ec88349956c42c04349453cc55892a014f2c0dae Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 01:22:53 -0500 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20pi:=20schedule=20?= =?UTF-8?q?line=20names=20the=20Pi=20skill=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/super-collect/references/scheduled-intake.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/skills/super-collect/references/scheduled-intake.md b/skills/super-collect/references/scheduled-intake.md index 302213aa..b4df65bd 100644 --- a/skills/super-collect/references/scheduled-intake.md +++ b/skills/super-collect/references/scheduled-intake.md @@ -26,9 +26,10 @@ Confirm the active config matches the intended repo/board, then test a dry run. > and stop; do not clear the halt or blindly retry create/comment. Adapt invocation to the host: Claude uses `/super-collect --yes`; Codex uses -`$super-collect --yes`. A local timer can start that host's normal agent runner -with this prompt. A cloud job needs equivalent repo, tool, skill and credential -access. No universal scheduler service is bundled. +`$super-collect --yes`; Pi uses `/skill:super-collect --yes`. A local timer can +start that host's normal agent runner with this prompt. A cloud job needs +equivalent repo, tool, skill and credential access. No universal scheduler +service is bundled. ## Same-host overlap and receipts From 4e630bb4008d36c8f844ad4df06d69c7a2ba0d43 Mon Sep 17 00:00:00 2001 From: juan <juan@localhost> Date: Sat, 17 Oct 2026 01:31:34 -0500 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=85=20[test]=20get:=20guard=20line?= =?UTF-8?q?=20names=20the=20Pi=20extension=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test-get.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-get.sh b/tests/test-get.sh index 2eb6d1fc..67eb0804 100755 --- a/tests/test-get.sh +++ b/tests/test-get.sh @@ -91,7 +91,7 @@ out=$(bash "$GET" --target "$T" 2>&1) || fail "grouped-output install failed: $o order=$(printf '%s\n' "$out" | grep -oE '^(🧩|🔍|📦|🔧|🧠|🎉|👉)' | tr -d '\n') [ "$order" = "🧩🔍📦🔧🧠🎉👉" ] || fail "groups out of order ($order): $out" printf '%s\n' "$out" | grep -q '^ ✓ curl · tar · python3 · gh · jq · node$' || fail "checks line wrong: $out" -printf '%s\n' "$out" | grep -q '^ 🛡️ 6 guard hooks → .claude/settings.json (new file)$' || fail "guard line wrong: $out" +printf '%s\n' "$out" | grep -q '^ 🛡️ 6 guard hooks → .claude/settings.json + .pi/extensions/ (new file)$' || fail "guard line wrong: $out" printf '%s\n' "$out" | grep -q '^ ! CLAUDE.md holds its own rules — onboard offers a merge$' || fail "CLAUDE.md hint missing: $out" printf '%s\n' "$out" | grep -q '^🧠 helper skills: installed$' || fail "helper line wrong: $out" [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" -le 12 ] || fail "output should stay ≤ 12 lines: $out"