diff --git a/CLAUDE.md b/CLAUDE.md index bf942a7a..3820726a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,10 +41,10 @@ See `skills/super-board/references/rate-limit-etiquette.md` for the full discipl ## Installation contract -`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] ` copies the pack +`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,pi,opencode] ` copies the pack once into the target project's shared `.agents/` tree, then links each host folder into it: one link per skill under `skills/`, one per file under `bin/`, `workflows/`, `hooks/`. `.claude/` is -always linked; `.codex/` and `.opencode/` only when that folder already exists, a config `host` +always linked; `.codex/`, `.pi/` and `.opencode/` only when that folder already exists, a config `host` key names it, `super-board-host.sh` reports it, or `--hosts` asks. A real-directory `.claude/` install is replaced by links on upgrade; a user's own links and anything not super-board's are left alone; where no symlink can be made the file is copied. Settings and run state stay in `.claude/`. diff --git a/docs/hosts.md b/docs/hosts.md index 57ba5403..6126ee62 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -7,6 +7,7 @@ super-board runs inside an agent host: the CLI whose session types `/super-board |---|---|---|---|---| | `claude` | Claude Code 2.1.302 | `/super-board run` (Workflow backend) | `claude` then `/login`; `gh auth login` | None. The reference host. | | `codex` | codex-cli 0.160.0 | `/super-board run` inside Codex runs as `--codex`; from Claude, `/super-board run --codex` | `codex login`; `gh auth login` | No Workflow tool, so every lane is headless `codex exec`. Review runs only with explicit `--codex` (a host-switched plain `run` counts). No Claude usage guard. | +| `pi` | Pi 1.3.0 (print-mode source also checked at 1.2.0) | `/super-board run --pi` | Configure `pi.provider` and `pi.model`, authenticate that provider, and `gh auth login` | Fresh `pi --print` process per lane. Explicit skills, existing hook checks through the installed Pi guard adapter, and shared merge/readiness gates. Pi tool settings do not provide an OS sandbox. Claude stays the default pending acceptance. | | `opencode` | OpenCode v2.0.22 | Not supported yet: the run halts with a host-selection request | `opencode auth login`; `gh auth login` | No OpenCode lane backend yet. `permission.external_directory: deny` is not enforced in v2.0.22 (`patch` still writes outside the repo), so OpenCode runs stay attended-only once a backend lands. | Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. @@ -15,7 +16,7 @@ Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. `super-board-host.sh [--config ]` prints one value. First match wins: -1. `SUPER_BOARD_HOST=claude|codex|opencode`, the manual override. Any other value exits 64. +1. `SUPER_BOARD_HOST=claude|codex|pi|opencode`, the manual override. Any other value exits 64. 2. `CODEX_THREAD_ID` or `CODEX_SESSION_ID` → `codex`. 3. `OPENCODE=1` → `opencode`. Checked before `CLAUDECODE`, which leaks into an OpenCode session started from Claude Code. `AGENT=1` is ignored: other tools set it too. @@ -35,3 +36,16 @@ Onboard always writes `host` into `.claude/super-board/configs/.json`. - Codex: env of `codex exec -s read-only`, 2026-10-04 (recorded in the script header). - OpenCode: env diff of an `opencode run` shell, 2026-10-19: `OPENCODE=1`, `OPENCODE_SESSION_ID`, `OPENCODE_TERMINAL=1`, `AGENT=1`. + +## Pi selection + +`--pi` overrides either worker backend. Run the existing wave helper with +`--pi` in place of `--codex`: `.claude/bin/super-board-codex-wave.sh` selects +`super-board-pi-lane.sh`. Do not combine the flags. The config must provide +non-empty `pi.provider` and `pi.model`. Pi never uses the Codex model ladder. +Use `SUPER_BOARD_HOST=pi` when selecting Pi from an environment with another +host's inherited signals. No unverified Pi environment variable is treated as a host sign. +The Pi adapter refuses unsupported command hooks and explicit Claude `permissions.deny` +or `permissions.ask` rules before dispatch. These rules must not disappear when changing hosts. +The existing `codex-wave.pid` marker owns both one-shot backends, so stop and +status keep using the same process identity and recovery paths. diff --git a/docs/super-board/runs/issue-12-qa-v1/REPORT.md b/docs/super-board/runs/issue-12-qa-v1/REPORT.md new file mode 100644 index 00000000..5ccf985f --- /dev/null +++ b/docs/super-board/runs/issue-12-qa-v1/REPORT.md @@ -0,0 +1,72 @@ +# Issue 12 QA, version 1 + +Result: pass for the scoped helper correction. Independent Review is still required. + +- Tested source head: `0c127906c5ed6b9040e71a8310d456572051c920`. +- Historical regression-only head: `c47c2a369e0b6920441c548b966acc7d6ed76295`. +- Branch: `issue-12-tolerate-absent-phase-display-labels`, PR #13. +- Only permitted merge destination: `codex/pi-pilot-20261020`. +- Host: fresh Pi QA lane, Python 3.14.6, git 2.50.1, macOS. +- Rerun command for Review: `bash tests/run-safety.sh`. + +## P1. Preflight + +Pi QA skill readiness returned `ready=true` before any worktree or lifecycle write. Required skills were loaded. Guarded complete PR metadata preceded the diff read with `--meta`. Issue comments, PR comments, review comments and thread replies were read completely. All thread and reply pagination ended. Five author-note threads have no replies or findings. No prior Reviewer report exists. + +The card was in QA. PR #13 was open and draft at the tested head. Its base was the configured pilot branch. The latest base was fetched at `b7abfda3576cdd2a0d6a69a04c3315abaa6bcb8b`. The issue merge-base is `c957c978d1f4c5ecad45d3e89e9c4fa2cbce4c29`. QA used only `.claude/worktrees/issue-12-qa` and preserved the Builder's exact source. + +PR #1 overlap is the authorized stacked correction, not duplicate work. The Builder's expected-conflict note remains. This lane did not modify PR #1. No `repairReservation` was supplied. Read-only verification consumes no repair. No canonical record or repair-count/phase label was changed. + +## P2. Classification and AC ledger + +This is a Python CLI integration defect, not a browser defect. Unit coverage was mapped first, integration coverage second, and browser coverage last. The accepted seam is the existing installed wave/lane/handoff/card helpers, with external GitHub and model processes replaced by fixtures. No new runner, framework or product code was added. + +| AC | Unit proof | Integration or operational proof | Browser | +|---|---|---|---| +| AC1, old code fails faithfully | n/a, external CLI rejection | `tests/test_repair_wave.py:121` at regression-only `c47c2a3` failed for both hosts with `79 != 0 : 'loop:repair-diagnosis' not found` | n/a, no UI | +| AC2, first failure retains zero | `tests/test_repair_budget.py:104,108` | `tests/test_repair_wave.py:121` asserts one idle/0 canonical record, no phase-label creation, then one reserved/1 dispatch | n/a | +| AC3, counts and exact phase | `tests/test_repair_budget.py:22-84,162,205` | `tests/test_repair_wave.py:157,181,190,202,211` covers normal/final success, diagnosis/final exhaustion, invalid diagnosis and interrupted reservations | n/a | +| AC4, unknown outcome halts | `tests/test_repair_budget.py:122` | `tests/test_repair_wave.py:139` applies the edit, loses the response, then asserts halt/79, one edit, retained claim and no replay or new dispatch | n/a | +| AC5, preserve explicit restart boundary | n/a, operational constraint | Guarded reads found one idle/0 record with pending_lane=build on #9 and unchanged draft PR #11. No fixture restart or mutation was performed. Actual resumed live acceptance belongs to #7 after this fix is accepted. | n/a | + +AC5 is a preservation check, not a claim that the later live restart passed. The owner explicitly separated that restart from this lane. + +## Fresh verification + +| Command | Revision | Result | +|---|---|---| +| `python3 tests/test_repair_wave.py RepairWave.test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch` | disposable export of `c47c2a3` | Expected red, exit 1, two matching host failures, 4.454 seconds | +| `python3 tests/test_repair_wave.py` | `0c12790` | Exit 0, 7 tests, 68.662 seconds | +| `python3 tests/test_repair_budget.py` | `0c12790` | Exit 0, 23 tests, 8.993 seconds | +| `bash tests/run-safety.sh` | `0c12790` | Exit 0, `PASS: 69 safety suites`; includes 23 budget and 7 wave tests again | +| `git diff --check` | QA worktree | Exit 0 | + +The full 916-line safety log was inspected. No suite failed. Existing unrelated ResourceWarning and DeprecationWarning messages were retained. The current run installed the pack from this QA checkout into fresh disposable test repos. It did not use stale ignored installed helpers from the pilot root. + +`verification.txt` contains exact selected output and SHA-256 hashes of the complete raw logs. Complete logs, guarded reads, classification and task receipts remain in `/Users/maxchan/Documents/Codex/2026-10-20/op/outputs/super-board-handoff/implementation-evidence/finish-20261021/issue-12-qa/`. + +## Test-gap check + +E1. Empty attached labels and absent repository phase labels are pinned by wave :121. Historical red proves this test can catch the reported symptom. + +E2. Counts 0/1/2, stricter cap 0, invalid boolean count, duplicate owner, interrupted reservation and old cause history are pinned by budget :39/:69/:79 and wave :102/:211. + +E3. Attached final cleanup and diagnosis/final/exhausted projection are pinned by wave :157/:181/:190. Dropping phase removal would leave `loop:repair-final` attached and fail the success test. Suppressing mutation errors or replaying would fail wave :139. Assertions observe literal intended counts and phases, not a recomputation through the production transition. + +E4. One canonical record, unrelated `docs` label survival, malformed canonical state, readback and halt ordering are pinned by wave :121/:157 and budget :108/:122/:148/:155/:219. Existing source-string order checks are supplemental, not the sole proof. + +No High gap was found. Test gaps not written: Medium, no dedicated combined witness starts with all three stale phase labels attached at once. Individual attached-phase cleanup and exact end states are covered. Money, dates, volume, form input, accessibility and UI states are n/a to the changed membership branch. No optional app verifier is installed. Its starter template is not run evidence. + +## P3. Separate truth-check + +H1. Canonical durability precedes display projection and dispatch. Held by source `scripts/super-board-repair.py:133-153`, wave `:242,282`, and the readback/halt tests. + +H2. Unknown mutations remain fatal. Held by unchanged `check=True`, wave `:351-354`, and the lost-response witness. No failure is ignored or blindly replayed. + +H3. Reservation ownership, cause history, counts and head-bound merge policy are unchanged. Held by the five-line product diff and the budget, wave and merge-gate suites. Read-only QA did not reserve or complete an attempt. + +H4. The preserved live fixture remains halted. Issue #9 has canonical comment `6305789024`, last updated `2026-10-21T01:32:18Z`, with one idle/0 record and pending_lane=build. Attached labels remain `tests` and `loop:rebuild-0`. PR #11 remains draft/open at `fc109484c356e68b884447b25408740aa9879ea8`. Existing #7 worktree, both nested fixture roots and claims 8/9 remain in place. No fixture state, signal, claim, branch or PR was reset. + +Standards pass: no scope expansion, source comment additions, copied pack files or helpers. Spec pass: AC1-4 have independent executable proof and AC5's preservation boundary holds. No auth, money or destructive-schema behavior changes. Graph tools and native subagents are unavailable, so preflight, classification and truth-check ran as distinct passes in this fresh lane. Review must use another fresh session. + +Principle used: Prove It Works required historical red plus fresh installed-artifact and full-suite verification instead of accepting the Builder's report. No deployment, release tag, merge or live acceptance restart was attempted. Screenshots are intentionally omitted because this change has no UI. diff --git a/docs/super-board/runs/issue-12-qa-v1/verification.txt b/docs/super-board/runs/issue-12-qa-v1/verification.txt new file mode 100644 index 00000000..0ee68939 --- /dev/null +++ b/docs/super-board/runs/issue-12-qa-v1/verification.txt @@ -0,0 +1,93 @@ +Issue 12 QA evidence, source 0c127906c5ed6b9040e71a8310d456572051c920 + +Historical command, c47c2a369e0b6920441c548b966acc7d6ed76295: +python3 tests/test_repair_wave.py RepairWave.test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch +Expected exit 1. Actual exit 1 + +FF +====================================================================== +FAIL: test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch (__main__.RepairWave.test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch) (host='codex') +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/sb-issue12-qa-historical.mIEcOy/tests/test_repair_wave.py", line 126, in test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch + self.assertEqual(process.returncode,0,process.stderr) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssertionError: 79 != 0 : 'loop:repair-diagnosis' not found + + +====================================================================== +FAIL: test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch (__main__.RepairWave.test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch) (host='pi') +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/sb-issue12-qa-historical.mIEcOy/tests/test_repair_wave.py", line 126, in test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch + self.assertEqual(process.returncode,0,process.stderr) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssertionError: 79 != 0 : 'loop:repair-diagnosis' not found + + +---------------------------------------------------------------------- +Ran 1 test in 4.454s + +FAILED (failures=2) + +Command: python3 tests/test_repair_wave.py +Actual exit 0 + +....... +---------------------------------------------------------------------- +Ran 7 tests in 68.662s + +OK + +Command: python3 tests/test_repair_budget.py +Actual exit 0 + +....................... +---------------------------------------------------------------------- +Ran 23 tests in 8.993s + +OK + +Command: bash tests/run-safety.sh +Actual exit 0 +69 complete suite groups, no ::error:: entries. + +PASS: 69 safety suites + +SHA-256 of complete raw logs: + +a1da42f44813a7c7533b469e5af3547fa8fc807eaf897254a15d705725311328 historical-red.log + +b44c9495e53ea188ace0608282b6d63882adf4ec200817d41af2d6fda5fc7540 qa-repair-wave.log + +c52726445ca9a7c26b1b2b4c3a35734aed16bfaccc481083f9062cf9b3c5fadc qa-repair-budget.log + +25d934fb4d86ed5323cb4265c955870dbe3d9331d5ba28505d8dd9a5509a5cd3 qa-safety.log + +Raw evidence root: /Users/maxchan/Documents/Codex/2026-10-20/op/outputs/super-board-handoff/implementation-evidence/finish-20261021/issue-12-qa + +Read-only fixture reconciliation: +{ + "canonicalRecordCount": 1, + "canonicalState": { + "causes": [ + "Initial assertion exited 1 on the operator-held health fault. Pushed fc10948 and posted actual failure proof on the issue and draft PR. No repair consumed or canonical repair state changed." + ], + "pending_lane": "build", + "phase": "idle", + "prUrl": "https://github.com/maxchangm/super-board/pull/11", + "repairs": 0 + }, + "commentId": 6305789024, + "commentUpdatedAt": "2026-10-21T01:32:18Z", + "labels": [ + "tests", + "loop:rebuild-0" + ], + "assignees": [], + "prState": "open", + "prDraft": true, + "prHead": "fc109484c356e68b884447b25408740aa9879ea8", + "mutationsPerformed": false, + "resumePerformed": false +} diff --git a/docs/super-board/runs/issue-4-qa-v1/REPORT.md b/docs/super-board/runs/issue-4-qa-v1/REPORT.md new file mode 100644 index 00000000..0dd986a5 --- /dev/null +++ b/docs/super-board/runs/issue-4-qa-v1/REPORT.md @@ -0,0 +1,41 @@ +# Issue 4 QA + +## Preflight pass +- PR #5 head: `74b8d70037bfbd2121050548bdcd28058e8c8ad7`. +- Base: `codex/pi-pilot-20261020` at `ae886d7f9b6f20d5c6d41cee417fa1287c90a354`. +- Current issue and all issue/PR comments read through the guarded reader. +- All nine review threads contain author explanations only. No replies or QA findings. Pagination is complete. +- Existing branch and PR retained. No repair reservation supplied or consumed. Canonical repair state and repair labels remain unchanged. + +## Classification and test plan +The confirmed seam is each real launcher CLI. Python unittest drives shell/Python launchers with recording external CLI stubs. No browser, service, authentication or production target applies. + +| AC | Observable check | Unit | Component/integration | E2E CLI | +|---|---|---|---|---| +| AC1 | Mismatch refuses before startup effects, with identical checkout snapshots and zero external calls | n/a: boundary behavior | n/a: same real CLI seam | `tests/test_checkout_identity.py:116-131` | +| AC2 | Nonzero exit, both resolved roots, no traceback or readiness error | n/a: boundary behavior | n/a: same real CLI seam | `tests/test_checkout_identity.py:123-128` | +| AC3 | Absolute, symlink, subdirectory, relative, omitted/null and matching linked paths reach workers in the caller checkout | n/a: boundary behavior | n/a: same real CLI seam | `tests/test_checkout_identity.py:133-155` | +| AC4 | Old revision fails and corrected revision passes, across source/installed Pi/Codex lanes/waves and distinct linked worktrees | n/a: cross-process behavior | installation repair: `tests/test_checkout_identity.py:168-179` | `tests/test_checkout_identity.py:109-155` | +| AC5 | Independent regression and all configured safety suites pass | n/a: suite orchestration | `tests/run-safety.sh:14-37` | Review must rerun and use the unchanged head-pinned merge gate | + +## Test-gap check +- Fill order: unit, component/integration, then E2E CLI. No AC relies on a unit mock as its only proof. +- Path classes pinned: sibling and linked mismatches, matching linked checkout, absolute/relative paths, symlink alias, caller subdirectory, omitted/null, blank/whitespace/non-string/NUL inputs (`tests/test_checkout_identity.py:116-165`). +- Startup ordering pinned with valid and empty verification commands. Filesystem hashes and Git status cover tracked files and runtime files outside Git metadata. GH/model recording asserts zero calls on refusal. +- Installation delivery and missing-helper repair are exercised through the real installer/setup entrypoints (`tests/test_checkout_identity.py:37-39,168-179`). +- Tests inspect real CLI exits, diagnostics, filesystem state and worker cwd. Recording stubs replace external services, not checkout validation. Expected roots come from fixture locations. +- Red capability checked on test-first commit `a2f109f`, before the guard existed. No product mutation or duplicate checker was needed. +- Test gaps (not written): low, filesystem mutation after validation is outside the startup-identity scope. Git object bytes are not snapshotted, but Git status is checked and the guard precedes operational Git writes by source inspection. No High gaps found. +- Optional project verifier absent. Ordinary issue AC checks remain authoritative. + +## Truth-check pass +- Fresh historical run: `python3 tests/test_checkout_identity.py` on an archive of `a2f109f` exits 1 with 81 failing subcases. Valid paths pass. Mismatched direct lanes advance to QA and waves reach Done with recording stubs, reproducing the reported defect. +- Fresh current run: `python3 tests/test_checkout_identity.py` exits 0, four methods pass in 59.348 seconds. Its 137 subcases cover 32 mismatches, 56 valid paths, 48 invalid inputs and setup repair. +- Fresh full run: `bash tests/run-safety.sh` exits 0, all 69 suites pass with zero suite-error markers. The checkout regression passes again inside the full suite in 56.633 seconds. +- `git diff --check` and the base-to-head whitespace check pass. The merge gate, merge policy, repair engine and full-suite runner have no diff against the base. +- Source checks confirm refusal precedes readiness, hydration and runtime writes: Pi lane lines 58-69, Codex lane lines 94-106, shared wave lines 60-75. The helper compares canonical checkout directories, not common Git metadata. +- Full outputs retained at `/tmp/pi-issue-4-qa/{regression-red,regression-green,safety-full}.log`. `RESULTS.log` contains the captured result excerpts and full-log hashes. The full suite emits ResourceWarning/DeprecationWarning messages in unrelated tests, but no suite fails. +- Principles applied: Prove It Works selected the real source/installed CLI boundaries. Sequence work into verifiable units separated preflight, coverage classification, historical red, current green and the full suite. + +## Handoff +QA passes its assigned checks. AC1-AC4 are proved. AC5 remains open only for the fresh Reviewer reruns and unchanged head-pinned merge gate. No product or test implementation changed. Review must rerun `python3 tests/test_checkout_identity.py && bash tests/run-safety.sh`. Screenshots intentionally omitted because all ACs concern command-line behavior. No deployment or release tag is authorized. diff --git a/docs/super-board/runs/issue-4-qa-v1/RESULTS.log b/docs/super-board/runs/issue-4-qa-v1/RESULTS.log new file mode 100644 index 00000000..a9e6a847 --- /dev/null +++ b/docs/super-board/runs/issue-4-qa-v1/RESULTS.log @@ -0,0 +1,36 @@ +Pi QA issue #4, independent verification, 2026-10-20T12:53:06Z +Tested head: 74b8d70037bfbd2121050548bdcd28058e8c8ad7 +Historical head: a2f109ffb57e9ca3904517d83dbec1930495a33e + +Historical command: python3 tests/test_checkout_identity.py +Exit: 1 (expected pre-fix failure) +FAIL: test_mismatches_refuse_before_any_startup_work (boundary=('pi', False, False), actual='b', verify=True) +AssertionError: 0 == 0 : {"status": "advanced", "column": "QA", "detail": "fixture", "prUrl": null, "branch": null, "priorFindings": null} +Ran 4 tests in 97.902s +FAILED (failures=81) + +Current command: python3 tests/test_checkout_identity.py +Exit: 0 +.... +---------------------------------------------------------------------- +Ran 4 tests in 59.348s +OK + +Full command: bash tests/run-safety.sh +Exit: 0 +::group::tests/test_checkout_identity.py +.... +---------------------------------------------------------------------- +Ran 4 tests in 56.633s +OK +::endgroup:: +PASS: 69 safety suites +Suite groups: 69. Suite-error markers: 0. + +Whitespace commands: git diff --check; git diff --check origin/codex/pi-pilot-20261020...HEAD +Exit: 0 for both. No output. + +Full local log SHA-256: +f774cd71e569a485f7133941d919b3a441522c55cec94005350260461fcb4187 /tmp/pi-issue-4-qa/regression-red.log +1288743966ec4d676f97448ccd82b5945b1bf8d3a3181a38776656feb09ee6f1 /tmp/pi-issue-4-qa/regression-green.log +f368a9326e81a6448ea1860b666247974fa2e074112e4d930793da16803c9000 /tmp/pi-issue-4-qa/safety-full.log diff --git a/install.sh b/install.sh index fd899e6e..a66bd517 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # merges the hook settings into .claude/settings.json (backed up first, never duplicated). # # Usage: -# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] [target-project-dir] +# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,pi,opencode] [target-project-dir] # Defaults to the current working directory. --no-hooks skips the guard hooks # and leaves settings.json untouched. --protect-main also wires the opt-in # guard that blocks direct and force pushes to main/master/base_branch; with @@ -14,7 +14,7 @@ # super-board onboard asks the questions (protect main is one of them). # # Layout: .agents/{skills/,bin,workflows,hooks} hold the files. Each host -# folder (.claude/ always; .codex/ and .opencode/ when that folder already exists, +# folder (.claude/ always; .codex/, .pi/ and .opencode/ when that folder already exists, # a config "host" key names it, super-board-host.sh reports it, or --hosts asks) # gets one link per skill under skills/ and one link per file under bin/, # workflows/ and hooks/. Nothing that is not super-board's is touched. Where a @@ -63,16 +63,16 @@ if [ ! -d "$TARGET" ]; then exit 64 fi -# Hosts: claude always; codex/opencode when asked, configured or detected. +# Hosts: claude always; codex/pi/opencode when asked, configured or detected. HOSTS="claude" add_host() { case " $HOSTS " in *" $1 "*) ;; *) HOSTS="$HOSTS $1" ;; esac; } for h in $(echo "$ASKED_HOSTS" | tr ',' ' '); do case "$h" in - claude|codex|opencode) add_host "$h" ;; - *) echo "unknown host: $h (use claude, codex or opencode)" >&2; exit 64 ;; + claude|codex|pi|opencode) add_host "$h" ;; + *) echo "unknown host: $h (use claude, codex, pi or opencode)" >&2; exit 64 ;; esac done -for h in codex opencode; do [ ! -d "$TARGET/.$h" ] || add_host "$h"; done +for h in codex pi opencode; do [ ! -d "$TARGET/.$h" ] || add_host "$h"; done for h in $(python3 - "$TARGET/.claude/super-board/configs" <<'PY' import glob, json, os, sys for p in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): @@ -80,11 +80,12 @@ for p in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): h = json.load(open(p)).get("host") except Exception: continue - if h in ("codex", "opencode"): + if h in ("codex", "pi", "opencode"): print(h) PY ); do add_host "$h"; done -[ "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" != codex ] || add_host codex +detected_host=$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true) +case "$detected_host" in codex|pi|opencode) add_host "$detected_host" ;; esac QUIET="${SUPER_BOARD_QUIET_NEXT:-}" PACK_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION" 2>/dev/null || echo '?')" @@ -226,7 +227,7 @@ for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do install_skill "$skill" "$SHARED/skills/$skill" || true done -SCRIPTS="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" +SCRIPTS="super-board-checkout.py 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-pi-lane.sh super-board-pi-guards.py super-board-pi-guards.ts super-board-repair.py super-board-lane-result.py 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" for script in $SCRIPTS; do if [ -f "$REPO_ROOT/scripts/$script" ]; then copy_file "$REPO_ROOT/scripts/$script" "$SHARED/bin/$script" diff --git a/scripts/super-board-checkout.py b/scripts/super-board-checkout.py new file mode 100644 index 00000000..80e3f387 --- /dev/null +++ b/scripts/super-board-checkout.py @@ -0,0 +1,20 @@ +"""Validate configured checkout identity before launcher startup work.""" +from pathlib import Path + + +def require_checkout(actual_root: Path, configured_path: str | None) -> Path: + actual = actual_root.resolve() + if configured_path is None: + return actual + if not isinstance(configured_path, str) or not configured_path.strip(): + raise ValueError(f'Invalid repo.path {configured_path!r}; actual checkout: {actual}') + try: + configured = Path(configured_path) + if not configured.is_absolute(): + configured = actual / configured + configured = configured.resolve() + except (OSError, RuntimeError, ValueError) as error: + raise ValueError(f'Invalid repo.path {configured_path!r}; actual checkout: {actual}: {error}') from error + if actual != configured: + raise ValueError(f'Checkout mismatch: actual={actual}; configured={configured}') + return actual diff --git a/scripts/super-board-codex-lane.sh b/scripts/super-board-codex-lane.sh index 4710ffb7..e3e09f9c 100755 --- a/scripts/super-board-codex-lane.sh +++ b/scripts/super-board-codex-lane.sh @@ -13,6 +13,7 @@ import importlib.util import json import os from pathlib import Path +import runpy import signal import subprocess import sys @@ -24,6 +25,7 @@ def fail(message, code=64): parser = argparse.ArgumentParser(description='Run one Codex board lane (no Claude sub-agents).') parser.add_argument('--config', required=True) +parser.add_argument('--diagnosis', action='store_true', help='fresh read-only diagnosis, no lifecycle mutations') 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)') @@ -84,7 +86,6 @@ 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() @@ -93,8 +94,13 @@ try: root = git_path('--show-toplevel') git_dir = git_path('--git-dir') common_dir = git_path('--git-common-dir') + require_checkout = runpy.run_path(str(script_path.with_name('super-board-checkout.py')))['require_checkout'] + root = require_checkout(root, (config.get('repo') or {}).get('path')) except subprocess.CalledProcessError: fail('Codex lane must start inside the target git repository') +except (OSError, ValueError) as error: + fail(f'Invalid Codex lane input: {error}') +require_verification_ready() 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: @@ -122,6 +128,7 @@ if not skills['ready']: skill = {'build': 'super-build', 'qa': 'super-qa', 'review': 'super-review'}[args.lane] section = {'build': 'Builder', 'qa': 'Tester', 'review': 'Reviewer'}[args.lane] prompt = f'''Run {skill} for issue #{card['number']} as a Codex lane worker. +The dispatcher owns canonical repair-state transitions for this wave. It persisted Card.repairReservation before dispatch when present. Consume that attempt once, write normal failure proof on the issue/PR and return the outcome. Do not reserve again or modify the canonical machine state or repair-count/phase labels; the dispatcher records completion. Initial implementation and read-only verification are not repairs. A fresh independent diagnosis must precede any final targeted repair. For phase final, target diagnosis_cause only and use diagnosis_test to falsify that cause before changing code. First operation: recheck .claude/bin/super-board-skills-ready.py --root {root} --host codex --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} @@ -144,30 +151,10 @@ Rerun the Tester's tests. When truth_gate triggers, apply super-review's fixed A 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. ''' -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}]}, -} -schema = {'type': 'object', 'properties': properties, 'required': list(properties), 'additionalProperties': False} -JSON_TYPES = {'object': dict, 'array': list, 'string': str, 'null': type(None)} -def conforms(value, rule): - # The subset of JSON Schema the lane schema uses: type, enum, anyOf, properties/required/additionalProperties. - if 'anyOf' in rule: - return any(conforms(value, option) for option in rule['anyOf']) - kinds = rule['type'] if isinstance(rule['type'], list) else [rule['type']] - if not any(type(value) is int if kind == 'integer' else isinstance(value, JSON_TYPES[kind]) for kind in kinds): - return False - if 'enum' in rule and value not in rule['enum']: - return False - if isinstance(value, dict): - if set(value) - set(rule['properties']) or not set(rule['required']) <= set(value): - return False - return all(conforms(value[key], rule['properties'][key]) for key in value) - return True +spec = importlib.util.spec_from_file_location('lane_result', script_path.with_name('super-board-lane-result.py')) +lane_result = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lane_result) +schema, conforms = lane_result.schema, lane_result.conforms child = None def interrupted(signum, frame): if child is not None: @@ -185,16 +172,28 @@ with tempfile.TemporaryDirectory(prefix='super-board-codex-') as temporary: schema_path = Path(temporary) / 'schema.json' output = Path(temporary) / 'result.json' schema_path.write_text(json.dumps(schema)) + if args.diagnosis: + prompt = f'''You are a fresh independent read-only diagnostician for issue #{card['number']}. +Read accepted behavior, failing proof and prior attempted causes from the Card below. +Inspect relevant local source only. Do not modify code, push, merge or write lifecycle state. +Identify a different cause and a falsifying test command with expected result. A new hash alone is insufficient. +Return the existing lane JSON, status advanced, column {column}, prUrl/branch/priorFindings null. +Put a JSON string object with cause, test and expected in detail. Use empty strings if no different testable cause exists. +The test must be a directly executable shell command. Use single quotes around Python code, without escaped outer quotes. +Card: {json.dumps(card)} +''' # A custom profile deliberately does NOT extend :workspace, which protects # .git as read-only. Explicit git/common-dir grants also cover submodules. # https://learn.chatgpt.com/docs/permissions → File access limited to workspace filesystem = {':root': 'read', ':slash_tmp': 'write', ':tmpdir': 'write', str(root): 'write', str(git_dir): 'write', str(common_dir): 'write'} + if args.diagnosis: + filesystem = {':root': 'read'} grants = '{' + ', '.join(json.dumps(key) + '=' + json.dumps(value) for key, value in filesystem.items()) + '}' command = ['codex', 'exec', '-C', str(root), '-c', 'approval_policy="never"', '-c', 'default_permissions="super-board-lane"', '-c', 'permissions.super-board-lane.filesystem=' + grants, - '-c', 'permissions.super-board-lane.network.enabled=true', + '-c', 'permissions.super-board-lane.network.enabled=' + ('false' if args.diagnosis else 'true'), '--output-schema', str(schema_path), '--output-last-message', str(output), '-'] if model and model.strip(): command[2:2] = ['-m', model] diff --git a/scripts/super-board-codex-wave.sh b/scripts/super-board-codex-wave.sh index 0c55b6e0..34b53a4d 100755 --- a/scripts/super-board-codex-wave.sh +++ b/scripts/super-board-codex-wave.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Dispatch an explicitly selected Codex wave; stdout/--output matches the +# Dispatch an explicitly selected Codex or Pi wave; stdout/--output matches the # Claude workflow's {cards:[summary...], halted} reconciliation contract. -# Usage: --config f --cards '<JSON array>' --codex[=model] [--tier low|medium|high] [--output f] +# Usage: --config f --cards '<JSON array>' (--codex[=model] | --pi) [--tier low|medium|high] [--output f] # Unless one model is pinned (--codex=<model> or codex.model), a cheap router # (ROUTER_MODEL) grades each Ready card low/medium/high; the lane launcher maps # tier x grade to a model through its CODEX_LADDERS table. @@ -13,25 +13,28 @@ from concurrent.futures import ThreadPoolExecutor import json import os from pathlib import Path +import runpy import signal +import shutil import subprocess import sys import tempfile import threading import time -parser = argparse.ArgumentParser(description='Dispatch one explicitly opted-in Codex wave.') +parser = argparse.ArgumentParser(description='Dispatch one explicitly opted-in Codex or 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:] -codex = [arg for arg in raw if arg == '--codex' or arg.startswith('--codex=')] +codex = [arg for arg in raw if arg == '--pi' or arg == '--codex' or arg.startswith('--codex=')] +host = 'pi' if codex == ['--pi'] else 'codex' if len(codex) != 1 or (codex[0].startswith('--codex=') and not codex[0].partition('=')[2].strip()): - parser.error('exactly one explicit --codex[=<non-empty-model>] is required') + parser.error('exactly one explicit --pi or --codex[=<non-empty-model>] is required') args = parser.parse_args([arg for arg in raw if arg not in codex]) output_path = Path(args.output).resolve() if args.output else None -launcher = Path(sys.argv[1]).resolve().with_name('super-board-codex-lane.sh') +launcher = Path(sys.argv[1]).resolve().with_name('super-board-pi-lane.sh' if host == 'pi' else 'super-board-codex-lane.sh') try: config_path = Path(args.config).resolve() config = json.loads(config_path.read_text()) @@ -55,7 +58,12 @@ try: 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('codex') or {}).get('model') + require_checkout = runpy.run_path(str(launcher.with_name('super-board-checkout.py')))['require_checkout'] + root = require_checkout(root, (config.get('repo') or {}).get('path')) + pi_config = config.get('pi') + if host == 'pi' and (not isinstance(pi_config, dict) or any(not isinstance(pi_config.get(key), str) or not pi_config[key].strip() or pi_config[key].startswith('-') for key in ['provider', 'model'])): + raise ValueError('Pi requires explicit pi.provider and pi.model') + configured = (config.get(host) or {}).get('model') pinned = codex[0].startswith('--codex=') or (isinstance(configured, str) and configured.strip()) except (OSError, ValueError, subprocess.CalledProcessError) as error: parser.error(str(error)) @@ -75,7 +83,7 @@ try: resolver = importlib.util.module_from_spec(spec) spec.loader.exec_module(resolver) resolver.hydrate(cards, root) - skills = resolver.report(root, 'codex', cards, home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), + skills = resolver.report(root, host, 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' @@ -91,6 +99,30 @@ if code: output_path.write_text(result + '\n') print(result) sys.exit(code) +if host == 'pi': + if not shutil.which('pi'): + result = json.dumps(dict(cards=[dict(number=c['number'], finalStatus='halted', lastLane='none', column=c['status'], detail='Pi executable unavailable; install configured Pi before dispatch', prUrl=None, priorFindings=None, lanesRun='none:halted') for c in cards], halted=True)) + if output_path: + output_path.write_text(result + '\n') + print(result) + sys.exit(79) + guards = launcher.with_name('super-board-pi-guards.py') + extension = launcher.with_name('super-board-pi-guards.ts') + guard_env = dict(os.environ, SUPER_BOARD_PI_ROOT=str(root)) + if not guards.is_file() or not extension.is_file() or subprocess.run([sys.executable, str(guards), '--check'], env=guard_env, stdout=sys.stderr).returncode: + result = json.dumps(dict(cards=[dict(number=c['number'], finalStatus='halted', lastLane='none', column=c['status'], + detail='Pi guards unavailable or unsupported project hook; preserve claims and worktrees', + prUrl=None, priorFindings=None, lanesRun='none:halted') for c in cards], halted=True)) + if output_path: + output_path.write_text(result + '\n') + print(result) + sys.exit(79) +result_spec = importlib.util.spec_from_file_location('lane_result', launcher.with_name('super-board-lane-result.py')) +lane_result = importlib.util.module_from_spec(result_spec) +result_spec.loader.exec_module(lane_result) +repair_spec = importlib.util.spec_from_file_location('repair_budget', launcher.with_name('super-board-repair.py')) +repair_budget = importlib.util.module_from_spec(repair_spec) +repair_spec.loader.exec_module(repair_budget) readiness_halted = threading.Event() skills_halted = threading.Event() halted = threading.Event() @@ -192,7 +224,7 @@ def run_card(card): 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, 'codex', [current], lane=lane, + boundary = resolver.report(root, host, [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() @@ -201,15 +233,67 @@ def run_card(card): detail='skills-readiness: missing ' + ', '.join(e['name'] for e in boundary['entries'] if e['status'] == 'missing'))) break process = None + reservation = None + handoff = repair_budget.CanonicalHandoff(card['number']) lock = inflight / str(card['number']) try: + with lock.open('x') as claim: + claim.write(f'OWNER={owner}\nLANE={lane}\n') + reservation = handoff.prepare(owner, lane, labels if lane == entry else (), config.get('rebuild_cap', 2)) + if reservation: + if reservation and reservation['phase'] == 'diagnosis': + diagnosis_card = dict(current, repairReservation=reservation) + diagnosis_card['body'] = (current.get('body', '') + '\nCanonical failure evidence and prior causes:\n' + json.dumps(reservation) + '\nDurable failure handoffs:\n' + json.dumps(handoff.comments)) + diagnosis = subprocess.Popen(['bash', str(launcher), '--config', str(config_path), + '--card', json.dumps(diagnosis_card), '--lane', lane, codex[0], '--diagnosis'], + stdout=subprocess.PIPE, stderr=None, text=True, start_new_session=True) + active.add(diagnosis) + lock.write_text(f'PID={diagnosis.pid}\nLANE={lane}\nREPAIR_PHASE=diagnosis\nSTARTED={time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}\n') + mutex.release() + try: + diagnosis_stdout, _ = diagnosis.communicate() + finally: + mutex.acquire() + active.discard(diagnosis) + if halted.is_set() or diagnosis.returncode < 0 or diagnosis.returncode in (9, 10, 79): + raise ValueError('independent diagnosis interrupted/readiness halted; preserve reservation for reconciliation') + try: + if diagnosis.returncode: + raise ValueError(f'independent diagnosis exited {diagnosis.returncode}') + diagnosis_result = json.loads(diagnosis_stdout) + if not lane_result.conforms(diagnosis_result, lane_result.schema) or diagnosis_result['status'] != 'advanced' or diagnosis_result['column'] != current['status']: + raise ValueError('diagnosis did not return a valid independent verdict') + verdict = json.loads(diagnosis_result['detail']) + if not isinstance(verdict, dict): + raise ValueError('diagnosis evidence must be an object') + for key in ('cause', 'test', 'expected'): + if not isinstance(verdict.get(key), str): + raise ValueError('invalid diagnosis evidence') + except (ValueError, TypeError, KeyError) as exc: + handoff.event('failed', owner, 'independent diagnosis failed: ' + str(exc)) + raise ValueError('independent diagnosis failed; repair budget exhausted') from exc + state = handoff.event('diagnosed', owner, verdict['cause'], + (verdict['test'] + ' => ' + verdict['expected']) if verdict['test'].strip() and verdict['expected'].strip() else '') + if state['phase'] == 'exhausted': + raise ValueError('repair diagnosis found no different testable cause; budget exhausted') + reservation = handoff.reserve(owner, labels, config.get('rebuild_cap', 2)) + current['repairReservation'] = reservation + current['body'] = current.get('body', '') + '\nReserved repair cause and evidence:\n' + json.dumps(reservation) process = subprocess.Popen(['bash', str(launcher), '--config', str(config_path), '--card', json.dumps(current), '--lane', lane, codex[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: + except (OSError, ValueError, subprocess.CalledProcessError, KeyError) as error: + if isinstance(error, ValueError) and 'exhausted' in str(error): + try: + handoff.block(config_path, str(error)) + history.append(dict(lane=lane, status='blocked', column='Blocked', detail=str(error))) + lock.unlink() + break + except (OSError, subprocess.CalledProcessError): + pass if process is not None: try: os.killpg(process.pid, signal.SIGKILL) @@ -217,8 +301,16 @@ def run_card(card): pass process.wait() active.discard(process) - history.append(dict(lane=lane, status='failed', column=current['status'], - detail=f'could not start {lane} lane: {error}')) + if process is None and reservation is None and isinstance(error, OSError) and not isinstance(error, FileExistsError): + try: + if lock.read_text().startswith(f'OWNER={owner}\n'): + lock.unlink() + except FileNotFoundError: + pass + if isinstance(error, (FileExistsError, ValueError, subprocess.CalledProcessError, KeyError)): + halted.set() + history.append(dict(lane=lane, status='halted' if halted.is_set() else 'failed', column=current['status'], + detail=f'could not start {lane} lane; reconcile existing claim before retry: {error}')) break preserve_lock = False try: @@ -241,6 +333,25 @@ def run_card(card): if result['status'] == 'halted': halted.set() active.discard(process) + expected = {'build': 'QA', 'qa': 'Review', 'review': 'Done'}[lane] + if result['status'] == 'advanced' and result['column'] != expected: + result.update(status='failed', column=current['status'], detail=f'{lane} claimed advanced without reaching {expected}') + if result['status'] != 'halted': + try: + if reservation: + handoff.event('failed' if result['status'] in ('bounced', 'failed') else 'succeeded', owner, + cause=result['detail'] if result['status'] in ('bounced', 'failed') else '', + pending_lane=('qa' if result['column'] == 'QA' else 'build') if result['status'] in ('bounced', 'failed') else None, pr_url=result.get('prUrl')) + elif result['status'] in ('bounced', 'failed'): + handoff.event('failed', owner, cause=result['detail'], + pending_lane='qa' if result['column'] == 'QA' else 'build', pr_url=result.get('prUrl')) + if handoff.state and handoff.state.get('phase') == 'exhausted': + handoff.block(config_path, 'final targeted repair failed; automated budget exhausted') + result.update(status='blocked', column='Blocked', detail='repair budget exhausted; see canonical failure handoff') + except (OSError, ValueError, subprocess.CalledProcessError) as error: + preserve_lock = True + halted.set() + result = dict(status='halted', column=current['status'], detail=f'repair handoff persistence failed: {error}') history.append(dict(lane=lane, **result)) if result['status'] != 'advanced': break @@ -250,9 +361,10 @@ def run_card(card): break current['status'] = result['column'] current.pop('lane', None) + current.pop('repairReservation', None) finally: try: - if not preserve_lock and lock.read_text().startswith(f'PID={process.pid}\n'): + if not preserve_lock and not halted.is_set() and lock.read_text().startswith(f'PID={process.pid}\n'): lock.unlink() except FileNotFoundError: pass diff --git a/scripts/super-board-github-read.py b/scripts/super-board-github-read.py index 3a8b845f..f3b236f6 100644 --- a/scripts/super-board-github-read.py +++ b/scripts/super-board-github-read.py @@ -297,6 +297,11 @@ def read(args, kind='json', meta=None, recovering=False): raise ValueError('retry helper accepts read-only GitHub commands only') if not recovering: check_halt() + if args[0] == 'api' and '--slurp' in args and any( + arg in ('--jq', '-q', '--template', '-t') or arg.startswith(('--jq=', '--template=')) + for arg in args): + raise ValueError('gh api --slurp cannot be combined with --jq/-q or --template/-t; ' + 'fetch paginated raw JSON with --paginate --slurp, then run jq locally') operation = {'args': args, 'kind': kind, 'meta': meta} last = 'GitHub read failed' shape_only = True # every attempt reached GitHub and got a payload of the wrong shape diff --git a/scripts/super-board-host.sh b/scripts/super-board-host.sh index 87900a7a..08cace95 100755 --- a/scripts/super-board-host.sh +++ b/scripts/super-board-host.sh @@ -26,7 +26,7 @@ # prints `claude`, `codex` or `opencode`, exit 0 # exit 65 no sign: nothing on stdout, how to choose on stderr # exit 64 SUPER_BOARD_HOST or config `host` is not one of the three -# SUPER_BOARD_HOST=claude|codex|opencode forces the answer (tests, or a host the check +# SUPER_BOARD_HOST=claude|codex|pi|opencode forces the answer (tests, or a host the check # misreads). set -euo pipefail @@ -38,9 +38,9 @@ case "${1:-}" in esac case "${SUPER_BOARD_HOST:-}" in - claude|codex|opencode) echo "$SUPER_BOARD_HOST"; exit 0 ;; + claude|codex|pi|opencode) echo "$SUPER_BOARD_HOST"; exit 0 ;; '') ;; - *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex or opencode" >&2; exit 64 ;; + *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex, pi or opencode" >&2; exit 64 ;; esac if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then @@ -51,10 +51,10 @@ elif [ -n "${CLAUDECODE:-}" ]; then echo claude elif saved=$( [ -f "$config" ] && jq -r '.host // empty' "$config" 2>/dev/null ) && [ -n "$saved" ]; then case "$saved" in - claude|codex|opencode) echo "$saved" ;; - *) echo "super-board-host.sh: config host must be claude, codex or opencode, got '$saved'" >&2; exit 64 ;; + claude|codex|pi|opencode) echo "$saved" ;; + *) echo "super-board-host.sh: config host must be claude, codex, pi or opencode, got '$saved'" >&2; exit 64 ;; esac else - echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode, or set \"host\" in the board config" >&2 + echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|pi|opencode, or set \"host\" in the board config" >&2 exit 65 fi diff --git a/scripts/super-board-lane-result.py b/scripts/super-board-lane-result.py new file mode 100644 index 00000000..401d256c --- /dev/null +++ b/scripts/super-board-lane-result.py @@ -0,0 +1,25 @@ +"""The shared one-shot lane result contract. No lifecycle state lives here.""" +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}]}, +} +schema = {'type': 'object', 'properties': properties, 'required': list(properties), 'additionalProperties': False} +JSON_TYPES = {'object': dict, 'array': list, 'string': str, 'null': type(None)} +def conforms(value, rule): + # The subset of JSON Schema the lane schema uses: type, enum, anyOf, properties/required/additionalProperties. + if 'anyOf' in rule: + return any(conforms(value, option) for option in rule['anyOf']) + kinds = rule['type'] if isinstance(rule['type'], list) else [rule['type']] + if not any(type(value) is int if kind == 'integer' else isinstance(value, JSON_TYPES[kind]) for kind in kinds): + return False + if 'enum' in rule and value not in rule['enum']: + return False + if isinstance(value, dict): + if set(value) - set(rule['properties']) or not set(rule['required']) <= set(value): + return False + return all(conforms(value[key], rule['properties'][key]) for key in value) + return True diff --git a/scripts/super-board-merge-policy.py b/scripts/super-board-merge-policy.py index 167a71eb..166ab72f 100755 --- a/scripts/super-board-merge-policy.py +++ b/scripts/super-board-merge-policy.py @@ -30,8 +30,9 @@ lines are reported separately. Per-file counts come from `files[].additions/ deletions`; without them the PR totals are used. - merge_policy.always_human.<category>: any label, path glob or added-line - keyword match → human. Categories ship as money, auth, schema; setting - always_human replaces them all (`{}` turns category gating off). + keyword match → human. Custom rules extend mandatory money, auth, schema + and external-sharing rules. Empty maps cannot disable mandatory categories. + Matches conservatively route review; they do not prove semantic safety. - migrations: a changed path matching migrations.globs makes this a migration PR. Every env in allowed_envs with a command runs (a command of "-" means the deploy pipeline applies it: nothing to run). target_env not in allowed_envs, or @@ -54,15 +55,20 @@ "keywords": ["stripe.", "payment_intent", "price_id", "invoice.create"], }, "auth": { - "labels": ["auth", "security"], + "labels": ["auth", "security", "permissions"], "paths": ["**/auth/**", "**/authz/**", "**/permissions/**"], - "keywords": ["jwt.sign", "bcrypt", "password_hash", "service_role"], + "keywords": ["jwt.sign", "bcrypt", "password_hash", "service_role", "grant permission", "grant all"], }, "schema": { "labels": ["schema", "destructive"], "paths": [], "keywords": ["drop table", "drop column", "truncate table", "rename column", "drop schema"], }, + "external-sharing": { + "labels": ["external-sharing", "data-sharing", "external-data-sharing"], + "paths": ["**/telemetry/**", "**/data-export/**"], + "keywords": ["sendbeacon(", "navigator.sendbeacon", "captureexception("], + }, } DEFAULT_MIGRATION_GLOBS = [ "supabase/migrations/**", "prisma/migrations/**", "drizzle/**", @@ -156,7 +162,12 @@ def main() -> int: extra = f" (+{mig_lines} migration lines counted separately)" if mig_lines else "" human.append({"category": "size", "why": f"big PR — please review: {size} changed lines > auto_max_lines {cap}{extra}"}) - always = policy["always_human"] if isinstance(policy.get("always_human"), dict) else DEFAULT_ALWAYS_HUMAN + always = {cat: {field: list(values) for field, values in rule.items()} + for cat, rule in DEFAULT_ALWAYS_HUMAN.items()} + for cat, rule in (policy.get("always_human") or {}).items(): + target = always.setdefault(cat, {}) + for field in ("labels", "paths", "keywords"): + target[field] = list(dict.fromkeys(target.get(field, []) + ((rule or {}).get(field) or []))) for cat, rule in always.items(): rule = rule or {} hit = next((f"label {l}" for l in rule.get("labels") or [] if l.lower() in labels), None) diff --git a/scripts/super-board-pi-guards.py b/scripts/super-board-pi-guards.py new file mode 100755 index 00000000..a7c64b9c --- /dev/null +++ b/scripts/super-board-pi-guards.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Adapt Pi tool calls to the installed Claude command hooks. No sandbox claim.""" +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +GUARDS = { + 'Bash': ['guard-worktree-path.py', 'guard-delete-outside.py', 'guard-secrets.py', 'guard-key-literals.py'], + 'Read': ['guard-secrets.py'], + 'Edit': ['guard-key-literals.py'], + 'Write': ['guard-key-literals.py'], +} + + +def commands(root, tool, event): + hooks = root / '.claude/hooks' + paths = GUARDS.get(tool, []) if event == 'PreToolUse' else (['guard-key-literals.py'] if tool in ('Write', 'Edit') else []) + calls = [[sys.executable, str(hooks / name)] for name in paths] + protected = hooks / 'guard-protected-push.py' + if event == 'PreToolUse' and tool == 'Bash' and protected.exists(): + calls.append([sys.executable, str(protected)]) + for path in [Path.home() / '.claude/settings.json', root / '.claude/settings.json', root / '.claude/settings.local.json']: + if not path.exists(): + continue + data = json.loads(path.read_text()) + permissions = data.get('permissions') or {} + if permissions.get('deny') or permissions.get('ask'): + raise ValueError(f'Pi guard adapter cannot enforce permissions.deny/ask in {path}; retain the Claude path') + for group in data.get('hooks', {}).get(event, []): + if not re.fullmatch('.*' if group.get('matcher') in (None, '', '*') else group['matcher'], tool): + continue + for hook in group.get('hooks', []): + if hook.get('type') != 'command' or not isinstance(hook.get('command'), str): + raise ValueError(f'Pi cannot execute non-command {event} hook in {path}') + calls.append(hook['command']) + return calls + + +def check(root): + for name in {name for names in GUARDS.values() for name in names}: + if not (root / '.claude/hooks' / name).is_file(): + raise ValueError(f'Pi requires installed guard {name}; reinstall with hooks enabled') + for tool in GUARDS: + for event in ['PreToolUse', 'PostToolUse']: + commands(root, tool, event) + + +def main(): + root = Path(os.environ['SUPER_BOARD_PI_ROOT']).resolve() + check(root) + if '--check' in sys.argv: + return + event = json.load(sys.stdin) + name = {'bash': 'Bash', 'read': 'Read', 'edit': 'Edit', 'write': 'Write'}.get(event['toolName']) + if name is None: + raise ValueError('unexpected Pi tool') + values = event['input'] + payload = {'tool_name': name, 'hook_event_name': event.get('hookEventName', 'PreToolUse'), + 'cwd': str(root), 'tool_input': dict(values, file_path=values.get('path', ''), + new_string=values.get('newText', ''))} + env = dict(os.environ, CLAUDE_PROJECT_DIR=str(root)) + for command in commands(root, name, payload['hook_event_name']): + process = subprocess.run(command, shell=isinstance(command, str), input=json.dumps(payload), + text=True, capture_output=True, cwd=root, env=env, timeout=30) + if process.returncode: + raise ValueError('project command hook refused or failed') + if not process.stdout.strip(): + continue + out = json.loads(process.stdout) + specific = out.get('hookSpecificOutput', {}) + if out.get('continue') is False or specific.get('permissionDecision') in ('deny', 'ask') or specific.get('decision') == 'block' or out.get('decision') == 'block': + print(json.dumps({'block': True, 'reason': specific.get('permissionDecisionReason') or out.get('reason') or 'project hook refused'})) + return + if specific.get('additionalContext'): + print(json.dumps({'block': True, 'reason': specific['additionalContext']})) + return + print('{}') + + +if __name__ == '__main__': + try: + main() + except Exception as error: + print(json.dumps({'block': True, 'reason': 'Pi guard check failed: ' + str(error)})) + sys.exit(1) diff --git a/scripts/super-board-pi-guards.ts b/scripts/super-board-pi-guards.ts new file mode 100644 index 00000000..c4444d3c --- /dev/null +++ b/scripts/super-board-pi-guards.ts @@ -0,0 +1,18 @@ +import { spawnSync } from 'node:child_process'; + +export default function (pi) { + function check(event, hookEventName) { + const result = spawnSync(process.env.SUPER_BOARD_PI_PYTHON, [process.env.SUPER_BOARD_PI_GUARDS], { + input: JSON.stringify({ ...event, hookEventName }), encoding: 'utf8', timeout: 35000, + maxBuffer: 1024 * 1024, + }); + if (result.error || result.status !== 0) return { block: true, reason: 'Pi guard adapter failed; preserve work and repair the hook' }; + try { return JSON.parse(result.stdout); } + catch { return { block: true, reason: 'Pi guard adapter returned invalid output' }; } + } + pi.on('tool_call', (event) => check(event, 'PreToolUse')); + pi.on('tool_result', (event) => { + const result = check(event, 'PostToolUse'); + if (result.block) return { content: [{ type: 'text', text: result.reason }], isError: true }; + }); +} diff --git a/scripts/super-board-pi-lane.sh b/scripts/super-board-pi-lane.sh new file mode 100755 index 00000000..2e77d260 --- /dev/null +++ b/scripts/super-board-pi-lane.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# One fresh Pi print-mode lane using the existing board lifecycle and result contract. +set -euo pipefail +exec python3 - "${BASH_SOURCE[0]}" "$@" <<'PY' +import argparse +import importlib.util +import json +import os +from pathlib import Path +import runpy +import signal +import subprocess +import sys + +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='wave compatibility; Pi uses the configured model') +parser.add_argument('--complexity', choices=['low', 'medium', 'high'], help='wave compatibility; Pi uses the configured model') +parser.add_argument('--pi', action='store_true', required=True) +parser.add_argument('--diagnosis', action='store_true', help='fresh read-only diagnosis, no lifecycle mutations') +args = parser.parse_args(sys.argv[2:]) +script_path = Path(sys.argv[1]).resolve() +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') + pi_config = config.get('pi') or {} + provider, model = pi_config.get('provider'), pi_config.get('model') + if not all(isinstance(value, str) and value.strip() and not value.startswith('-') for value in [provider, model]): + raise ValueError('pi.provider and pi.model must be explicit non-empty identifiers') +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) + +def git_path(option): + return Path(subprocess.check_output(['git', 'rev-parse', option], text=True).strip()).resolve() + +try: + root = git_path('--show-toplevel') + require_checkout = runpy.run_path(str(script_path.with_name('super-board-checkout.py')))['require_checkout'] + root = require_checkout(root, (config.get('repo') or {}).get('path')) +except subprocess.CalledProcessError: + fail('Pi lane must start inside the target git repository') +except (OSError, ValueError) as error: + fail(f'Invalid Pi lane input: {error}') +require_verification_ready() +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] +prompt = f'''Run {skill} for issue #{card['number']} as a Pi lane worker. +The dispatcher owns canonical repair-state transitions for this wave. It persisted Card.repairReservation before dispatch when present. Consume that attempt once, write normal failure proof on the issue/PR and return the outcome. Do not reserve again or modify the canonical machine state or repair-count/phase labels; the dispatcher records completion. Initial implementation and read-only verification are not repairs. A fresh independent diagnosis must precede any final targeted repair. For phase final, target diagnosis_cause only and use diagnosis_test to falsify that cause before changing code. +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 <exact-name>. +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 a second lifecycle owner or reuse the Builder session for Review. Perform preflight, classification and truth-check duties in distinct evidence passes within this fresh lane session. This changes the execution host, never the evidence requirements, confidence threshold or merge policy. +Return JSON: 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 <!-- super-review:report -->; 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 <config> --pr <N> --subject <title> --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. +''' + +spec = importlib.util.spec_from_file_location('lane_result', script_path.with_name('super-board-lane-result.py')) +lane_result = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lane_result) +schema, conforms = lane_result.schema, lane_result.conforms +child = None +def interrupted(signum, frame): + if child is not None: + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + child.wait(timeout=0.5) + except subprocess.TimeoutExpired: + pass + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() + result('halted', 'lane interrupted; preserve claims and worktrees') + sys.exit(128 + signum) +signal.signal(signal.SIGTERM, interrupted) +signal.signal(signal.SIGINT, interrupted) +guards = script_path.with_name('super-board-pi-guards.py') +extension = script_path.with_name('super-board-pi-guards.ts') +env = dict(os.environ, SUPER_BOARD_PI_ROOT=str(root), SUPER_BOARD_PI_PYTHON=sys.executable, + SUPER_BOARD_PI_GUARDS=str(guards), SUPER_BOARD_HOST='pi') +if not extension.is_file() or subprocess.run([sys.executable, str(guards), '--check'], env=env, stdout=sys.stderr).returncode: + result('halted', 'Pi guards unavailable or unsupported project hook; preserve claims and worktrees') + sys.exit(78) +# Disable ambient resources; only the resolved skills and guard extension are loaded. +command = ['pi', '--print', '--mode', 'text', '--no-extensions', '--no-skills', + '--no-prompt-templates', '--no-themes', '--no-mcp', + '--extension', str(extension), '--tools', 'read' if args.diagnosis else 'read,bash,edit,write', + '--provider', provider, '--model', model] +for path in dict.fromkeys(e['resolved_path'] for e in skills['entries'] if e.get('resolved_path')): + command += ['--skill', path] +if args.diagnosis: + prompt = f'''You are a fresh independent read-only diagnostician for issue #{card['number']}. +Read the supplied accepted behavior, failure proof and all prior attempted causes from the Card below. +Use only read to inspect relevant source. Do not modify files, execute commands, push, merge, or write lifecycle state. +Identify a different cause, and a falsifying test command with its expected result. +The test must be a directly executable shell command. Use single quotes around Python code, without escaped outer quotes. A changed stack frame/hash is insufficient. +Return the existing lane JSON with status advanced, column {column}, prUrl/branch/priorFindings null. +Put a JSON string object with cause, test, expected in detail. Use empty strings if there is no new testable cause. +Card: {json.dumps(card)} +Resolved skills: {json.dumps(skills)} +''' +prompt += '\nReturn exactly one JSON object and no Markdown, logs or prose. Schema: ' + json.dumps(schema) + '\n' +try: + child = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, cwd=root, env=env, + start_new_session=True) + stdout, _ = child.communicate(prompt) + 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 --print exited {child.returncode}; preserve work') + sys.exit(child.returncode if child.returncode in (9, 10, 79) else 1) + parsed = json.loads(stdout) + if not conforms(parsed, schema): + raise ValueError('lane result does not match the lane schema') + 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}; preserve work') + sys.exit(1) diff --git a/scripts/super-board-repair.py b/scripts/super-board-repair.py new file mode 100755 index 00000000..5cc445d8 --- /dev/null +++ b/scripts/super-board-repair.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Persist bounded repair transitions in the existing canonical issue handoff.""" +import argparse +import json +import sys +import re +import subprocess +from pathlib import Path + + +def transition(state, event, owner='', cause='', test='', cap=2): + if not isinstance(state, dict): + raise ValueError('repair record must be an object') + cap = min(max(int(cap), 0), 2) + s = dict(state) + phase = s.get('phase', 'idle') + repairs = s.get('repairs', 0) + if type(repairs) is not int or repairs < 0 or repairs > 2: + raise ValueError('invalid repairs count') + if phase not in ('idle', 'reserved', 'diagnosis', 'final', 'exhausted'): + raise ValueError('invalid phase') + for key in ('diagnosis_used', 'final_used', 'different_cause'): + if key in s and type(s[key]) is not bool: + raise ValueError(f'invalid {key}') + if s.get('final_used') and not s.get('diagnosis_used'): + raise ValueError('final repair requires diagnosis evidence') + causes = s.get('causes', []) + if not isinstance(causes, list) or any(not isinstance(c, str) for c in causes): + raise ValueError('invalid cause history') + causes = list(causes) + if s.get('cause') and s['cause'] not in causes: + causes.append(s['cause']) + if event == 'reserve': + if not owner: + raise ValueError('reservation requires the existing claim owner') + if phase in ('reserved', 'diagnosis', 'final'): + raise ValueError('reconcile existing reservation before redispatch') + if phase == 'exhausted': + raise ValueError('repair budget exhausted') + if repairs < cap: + s.update(phase='reserved', repairs=repairs + 1) + elif not s.get('diagnosis_used'): + s.update(phase='diagnosis', diagnosis_used=True) + elif s.get('different_cause') and not s.get('final_used'): + s.update(phase='final', final_used=True) + else: + raise ValueError('repair budget exhausted') + s['owner'] = owner + elif event in ('failed', 'succeeded', 'diagnosed', 'interrupted'): + if not owner or s.get('owner') != owner or phase not in ('reserved', 'diagnosis', 'final'): + raise ValueError('event requires the reservation owner') + if event == 'diagnosed': + if phase != 'diagnosis': + raise ValueError('no diagnosis reserved') + different = bool(cause.strip() and test.strip() and cause not in causes) + s.update(different_cause=different, diagnosis_cause=cause, diagnosis_test=test, + phase='idle' if different else 'exhausted') + elif event == 'succeeded': + if phase == 'diagnosis': + raise ValueError('diagnosis requires diagnosed evidence') + s['phase'] = 'idle' + elif phase in ('diagnosis', 'final'): + s['phase'] = 'exhausted' + else: + s['phase'] = 'idle' + s.pop('owner', None) + else: + raise ValueError('unknown event') + if cause and event != 'diagnosed': + s['cause'] = cause + if cause and cause not in causes: + causes.append(cause) + s['causes'] = causes + return s + + +class CanonicalHandoff: + """One issue comment, written only while the dispatcher owns its card lock.""" + marker = '<!-- super-board:repair -->' + + def __init__(self, issue, reader=None): + self.issue = issue + self.reader = Path(reader or Path(__file__).with_name('super-board-github-read.py')) + self.comment_id = None + self.state = None + self.body = None + self.comments = [] + + def read(self): + value = subprocess.check_output([sys.executable, str(self.reader), '--kind', 'comments', '--', + 'api', f'repos/{{owner}}/{{repo}}/issues/{self.issue}/comments', '--paginate', '--slurp'], text=True) + pages = json.loads(value) + comments = [c for page in pages for c in (page if isinstance(page, list) else [page])] + self.comments = comments + records = [c for c in comments if self.marker in c.get('body', '')] + if len(records) > 1: + raise ValueError('multiple canonical repair handoffs; reconcile before dispatch') + self.state = None + self.comment_id = None + self.body = None + if records: + record = records[0] + lines = re.findall(r'^repair-state: (.+)$', record['body'], re.M) + if len(lines) != 1 or type(record.get('id')) is not int: + raise ValueError('unreadable canonical repair handoff') + self.body = record['body'] + self.state = json.loads(lines[0]) + if not isinstance(self.state, dict): + raise ValueError('canonical repair state must be an object') + self._validate() + self.comment_id = record['id'] + return self.state + + def _validate(self): + try: + transition(self.state, 'reserve', 'validation') + except ValueError as exc: + if str(exc) not in ('reconcile existing reservation before redispatch', 'repair budget exhausted'): + raise + + def write(self, state): + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + body = (self.marker + '\n[orchestrator] [report] repair budget · ' + state['phase'] + + '\nrepair-state: ' + json.dumps(state, sort_keys=True)) + if self.body: + body = re.sub(r'^repair-state: .+$', lambda _: 'repair-state: ' + json.dumps(state, sort_keys=True), self.body, flags=re.M) + body = re.sub(r'^\[orchestrator\] \[report\] repair budget · .+$', + lambda _: '[orchestrator] [report] repair budget · ' + state['phase'], body, flags=re.M) + endpoint = (f'repos/{{owner}}/{{repo}}/issues/comments/{self.comment_id}' if self.comment_id else + f'repos/{{owner}}/{{repo}}/issues/{self.issue}/comments') + subprocess.run(['gh', 'api', '-X', 'PATCH' if self.comment_id else 'POST', endpoint, '--input', '-'], + input=json.dumps({'body': body}), text=True, check=True, stdout=subprocess.DEVNULL) + self.read() + if self.state != state: + raise ValueError('canonical repair write could not be verified; preserve reservation') + current = json.loads(subprocess.check_output([sys.executable, str(self.reader), '--kind', 'object', '--', + 'issue', 'view', str(self.issue), '--json', 'labels'], text=True)) + current_labels = {label['name'] for label in current['labels']} + labels = [f"loop:rebuild-{state.get('repairs', 0)}"] + phase = state['phase'] + if phase in ('diagnosis', 'final', 'exhausted'): + labels.append('loop:repair-' + phase) + for label in labels: + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + subprocess.run(['gh', 'label', 'create', label, '--color', 'D4C5F9', '--force'], check=True, stdout=subprocess.DEVNULL) + command = ['gh', 'issue', 'edit', str(self.issue)] + for label in labels: + command.extend(['--add-label', label]) + for old in ('diagnosis', 'final', 'exhausted'): + if old != phase and 'loop:repair-' + old in current_labels: + command.extend(['--remove-label', 'loop:repair-' + old]) + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + return state + + def block(self, config, detail): + settings = json.loads(Path(config).read_text()) + next_owner = (settings.get('project', {}).get('owner') or settings.get('notifications', {}).get('bot_identity') or settings.get('bot_identity') or 'repository maintainer') + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + body = (f'[orchestrator] [blocker] 🛑 blocked · 🛡 repair budget exhausted\n' + f'Card: #{self.issue}\nReason tag: 🛡\nWhy blocked: {detail}\n' + f'Evidence: canonical repair handoff comment {self.comment_id}; {json.dumps(self.state, sort_keys=True)}\n' + 'Checked: repair/diagnosis budget exhausted; branch and worktree preserved\n' + 'What blocks: owner must review failure evidence and decide the next change\n' + 'Why I (bot) cannot decide: authorized automated repair budget is exhausted\n' + 'To unblock: [ ] inspect canonical failure evidence and preserved branch before authorizing further work\n' + f'Owner: {next_owner}\nMove back: Ready after the explicit next decision\nblocked-by: -') + subprocess.run(['gh', 'issue', 'comment', str(self.issue), '--body', body], check=True, stdout=subprocess.DEVNULL) + if self.state and self.state.get('prUrl'): + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + subprocess.run(['gh', 'pr', 'comment', self.state['prUrl'], '--body', body], check=True, stdout=subprocess.DEVNULL) + subprocess.run([sys.executable, str(self.reader), '--check'], check=True, stdout=subprocess.DEVNULL) + subprocess.run(['bash', str(self.reader.with_name('super-board-card.sh')), '--config', str(config), + 'move', str(self.issue), 'Blocked'], check=True, stdout=subprocess.DEVNULL) + + + def prepare(self, owner, lane, labels=(), cap=2): + state = self.read() + needs = state is not None and (state.get('phase') in ('reserved', 'diagnosis', 'final', 'exhausted') + or state.get('pending_lane') == lane or lane == 'build') + needs = needs or (state is None and any(re.fullmatch(r'loop:rebuild-[1-9]\d*', str(label)) for label in labels)) + return self.reserve(owner, labels, cap) if needs else None + + def diagnose_claude(self, state, owner, cap=2): + schema = {'type': 'object', 'properties': {key: {'type':'string'} for key in ('cause','test','expected')}, + 'required': ['cause','test','expected'], 'additionalProperties': False} + accepted = subprocess.check_output([sys.executable, str(self.reader), '--kind', 'issue', '--', 'issue', 'view', str(self.issue), '--json', 'number,title,body'], text=True) + prompt = ('Fresh independent read-only diagnosis. Do not mutate code, issues or board. ' + 'Read the accepted behavior and failing evidence below. Identify a different cause from ALL prior causes. ' + 'Return cause plus a falsifying shell test command and expected result. Empty values if none.\n' + json.dumps(state) + '\nDurable failure handoffs: ' + json.dumps(self.comments) + '\nAccepted issue: ' + accepted) + command = ['claude', '-p', '--no-session-persistence', '--restricted', '--tools', 'Read,Grep,Glob', + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--output-format', 'json', + '--json-schema', json.dumps(schema), prompt] + completed = subprocess.run(command, capture_output=True, text=True) + if completed.returncode < 0 or completed.returncode in (9, 10, 79): + raise subprocess.CalledProcessError(completed.returncode, command) + try: + if completed.returncode: + raise ValueError(f'diagnosis exited {completed.returncode}') + envelope = json.loads(completed.stdout) + if not isinstance(envelope, dict): + raise ValueError('diagnosis envelope must be an object') + value = envelope.get('structured_output') + if envelope.get('is_error') or not isinstance(value, dict) or any(not isinstance(value.get(k), str) for k in schema['required']): + raise ValueError('independent Claude diagnosis returned invalid evidence') + except (ValueError, TypeError) as exc: + return self.event('failed', owner, 'independent diagnosis failed: ' + str(exc)) + evidence = value['test'] + ' => ' + value['expected'] if value['test'].strip() and value['expected'].strip() else '' + result = self.event('diagnosed', owner, value['cause'], evidence) + return result if result['phase'] == 'exhausted' else self.reserve(owner, cap=cap) + + def reserve(self, owner, labels=(), cap=2): + state = self.read() + lower = max((int(m.group(1)) for label in labels + if (m := re.fullmatch(r'loop:rebuild-(\d+)', str(label)))), default=0) + if state is None and lower == 0: + return None + state = dict(state or {'phase': 'idle', 'repairs': min(lower, 2)}) + if lower > 2: + state.update(phase='exhausted', legacy_rebuild_count=lower) + self.write(state) + raise ValueError('legacy repair budget exhausted') + state['repairs'] = max(state.get('repairs', 0), min(lower, 2)) + try: + reserved = transition(state, 'reserve', owner, cap=cap) + except ValueError as exc: + if 'exhausted' in str(exc): + state['phase'] = 'exhausted' + self.write(state) + raise + return self.write(reserved) + + def event(self, event, owner, cause='', test='', pending_lane=None, pr_url=None): + state = self.read() + if event == 'failed' and (state is None or state.get('phase') == 'idle') and not pending_lane: + raise ValueError('unreserved failure requires pending repair lane') + if state is None: + state = {'phase': 'idle', 'repairs': 0, 'causes': [cause] if cause else []} + elif event == 'failed' and state.get('phase') == 'idle': + state = dict(state) + state['causes'] = list(dict.fromkeys(state.get('causes', []) + ([cause] if cause else []))) + state['cause'] = cause + if state.get('final_used'): + state['phase'] = 'exhausted' + else: + state = transition(state, event, owner, cause, test) + if pr_url: + state['prUrl'] = pr_url + if pending_lane: + state['pending_lane'] = pending_lane + elif event == 'succeeded': + state.pop('pending_lane', None) + return self.write(state) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('event', choices=['prepare', 'reserve', 'failed', 'succeeded', 'diagnosed', 'interrupted']) + p.add_argument('--owner', required=True) + p.add_argument('--issue', type=int, help='use the canonical issue handoff instead of stdin') + p.add_argument('--pr-url') + p.add_argument('--pending-lane', choices=['build','qa','review']) + p.add_argument('--lane', choices=['build','qa','review']) + p.add_argument('--config') + p.add_argument('--diagnose-claude', action='store_true') + p.add_argument('--labels', default='[]', help='legacy label lower bound as JSON') + p.add_argument('--cause', default='') + p.add_argument('--test', default='') + p.add_argument('--cap', type=int, default=2) + a = p.parse_args() + try: + if a.issue: + handoff = CanonicalHandoff(a.issue) + if a.event == 'prepare': + if not a.lane: + raise ValueError('prepare requires lane') + try: + state = handoff.prepare(a.owner, a.lane, json.loads(a.labels), a.cap) + if state and state['phase'] == 'diagnosis' and a.diagnose_claude: + state = handoff.diagnose_claude(state, a.owner, a.cap) + except ValueError as exc: + if 'exhausted' not in str(exc) or not a.config: + raise + state = handoff.state + else: + state = handoff.reserve(a.owner, json.loads(a.labels), a.cap) if a.event == 'reserve' else handoff.event(a.event, a.owner, a.cause, a.test, pending_lane=a.pending_lane, pr_url=a.pr_url) + if state and state['phase'] == 'exhausted' and a.config: + handoff.block(a.config, 'automated repair budget exhausted') + else: + state = transition(json.load(sys.stdin), a.event, a.owner, a.cause, a.test, a.cap) + print(json.dumps(state, sort_keys=True)) + except (ValueError, TypeError, OSError, subprocess.CalledProcessError) as exc: + print(f'repair halt: {exc}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/super-board-run.sh b/scripts/super-board-run.sh index b8beab5b..6e22c44d 100755 --- a/scripts/super-board-run.sh +++ b/scripts/super-board-run.sh @@ -333,6 +333,17 @@ dispatch_lane() { if ! try_claim_assignee "$issue"; then return 0 fi + local repair_state repair_labels repair_owner + repair_labels=$(printf '%s' "$card" | jq -c '.[0].labels // []') + repair_owner="legacy-$$-${issue}-${lane}" + # Canonical reservation/read-back must precede the first repair process. + if ! repair_state=$(python3 "$(dirname "$SB_GITHUB_READ")/super-board-repair.py" prepare \ + --issue "$issue" --lane "$lane" --owner "$repair_owner" --labels "$repair_labels" \ + --cap "${REBUILD_CAP:-2}" --config "$CONFIG_PATH" --diagnose-claude); then + log "repair handoff halted before dispatch #${issue}; preserve claim and worktree" + exit 79 + fi + [ "$(printf '%s' "$repair_state" | jq -r '.phase // "initial"')" != exhausted ] || return 0 case "$lane" in build) prompt="Run super-build on issue #${issue} for super-board run. Read .claude/skills/super-board/references/run.md → Builder lifecycle. Config: ${CONFIG_PATH}." ;; qa) prompt="Run super-qa on issue #${issue} for super-board run. Read .claude/skills/super-board/references/run.md → Tester lifecycle. Config: ${CONFIG_PATH}." ;; @@ -340,6 +351,7 @@ dispatch_lane() { *) log "unknown lane: $lane"; return 1 ;; esac prompt="First operation: run .claude/bin/super-board-skills-ready.py --root '$PWD' --host claude --cards - --lane '$lane' --hydrate with $card as stdin. Halt on exit 10 before worktree creation or lifecycle writes; preserve claims and approval evidence. Load resolved paths and only declared fallbacks; recheck conditionals with --require before activation. $prompt" + prompt="$prompt Existing dispatcher reservation (consume this owner without reserving again): $repair_state. This legacy worker owns completion: if reservation is non-null, before exiting use python3 .claude/bin/super-board-repair.py succeeded/failed --issue $issue --owner $repair_owner --config $CONFIG_PATH. For failed, include --cause exact-failure-proof --pending-lane build or qa naming the repair owner lane. Use failed only for code/test failure, succeeded for successful work or a human gate. Initial failure without reservation must initialize canonical state via failed --issue $issue --owner $repair_owner --cause proof --pending-lane build (use --pending-lane qa for test-side repairs). Before any repair or failure bounce, follow run.md Repair protocol: canonical issue handoff, consume the already persisted reservation, reconcile interruptions, fresh read-only independent diagnosis after two repairs, at most one final targeted repair. Never reset on hash or lane changes." # Worker output goes to a per-worker log, not /dev/null (issue #13). Redirect at the # `claude` invocation itself — no wrapping subshell, no `exec` — so the wire to the # file survives and `$!` names the worker process rather than a bash stub. diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 1e494250..c63c8845 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -23,9 +23,10 @@ settings.json entries (runs the pack's install.sh) when any is missing or older - removes the folders of skills that no longer exist: super-refine, cleanup-wt, arch-loop - migrates every .claude/super-board/configs/*.json to the v3 keys (migrate-config), and - writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode) + writes a missing top-level `host` from --host (check flags a host outside claude/codex/pi/opencode) - `git init` when the folder is not a repo - - adds Matt Pocock's helper skills with npx when Node is present and they are missing + - adds missing required helpers from their pinned sources for the selected host + with npx when Node is present, then re-checks actual skill files It never installs a system tool or signs in: those come back in `needs` with the exact command for this OS, for onboard to ask about. @@ -54,12 +55,13 @@ import os import platform import re +import shlex import shutil import subprocess import sys COLUMNS = ["Backlog", "Ready", "Building", "QA", "Review", "Blocked", "Done"] -HOSTS = ("claude", "codex", "opencode") # config `host`; super-board-host.sh accepts the same +HOSTS = ("claude", "codex", "pi", "opencode") # config `host`; super-board-host.sh accepts the same LABELS = { "qa": ("0e8a16", "Test what exists; skips Building (super-board)"), "bug": ("d73a4a", "Something is broken; built, tested, reviewed (super-board)"), @@ -73,13 +75,22 @@ } 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-checkout.py", "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-lane.sh", "super-board-repair.py", "super-board-lane-result.py", "super-board-pi-guards.py", "super-board-pi-guards.ts", "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", "super-review-file-refactor.sh", "super-qa-file-bug.sh", "super-board-stop.sh"] WORKFLOWS = ["super-board-wave.js", "ui-refine-loop.js"] -HELPERS_CMD = "npx -y skills@latest add mattpocock/skills --skill '*' -a claude-code -y" +HELPER_AGENTS = {"claude": "claude-code", "codex": "codex", "pi": "pi"} +HELPER_SOURCES = [ + ("https://github.com/mattpocock/skills/tree/5beb35b040e3de97e54fa9e386d7e42d15c295c2", + {"mattpocock-skills:" + name: name for name in + ["diagnosing-bugs", "tdd", "implement", "codebase-design", "code-review"]}), + ("https://github.com/obra/superpowers/tree/bb92a77741419a4ab5f06e711a283343f1ada0c3", + {"verification-before-completion": "verification-before-completion"}), + ("https://github.com/anthropics/knowledge-work-plugins/tree/a266ab8c3c754a3ccf7a634044fc1595fc06e50e", + {"testing-strategy": "testing-strategy"}), +] # System tools: why the board needs each, and the install command per OS family. TOOLS = { @@ -186,6 +197,15 @@ def helper_readiness(root, host="claude", home=None, catalog=None): return result +def helper_commands(readiness, host): + missing = {entry["name"] for entry in readiness.get("entries", []) if entry["status"] == "missing"} + for source, names in HELPER_SOURCES: + selected = [name for canonical, name in names.items() if canonical in missing] + if selected: + yield ["npx", "-y", "skills@latest", "add", source, "--skill", *selected, + "--agent", HELPER_AGENTS[host], "-y"] + + def is_git_repo(root): return have("git") and run(["git", "rev-parse", "--is-inside-work-tree"], cwd=root).returncode == 0 @@ -313,6 +333,10 @@ def check(root, pack, host="claude", home=None, catalog=None): if "host" in cfg and cfg["host"] not in HOSTS: host_errors[os.path.relpath(p, root)] = ( f"host \"{cfg['host']}\" must be one of {', '.join(HOSTS)}") + if cfg.get("host") == "pi": + pi = cfg.get("pi") + if not isinstance(pi, dict) or any(not isinstance(pi.get(k), str) or not pi[k].strip() for k in ("provider", "model")): + host_errors[os.path.relpath(p, root)] = "Pi requires explicit pi.provider and pi.model" _, ch = migrate_config(cfg, host) if ch: configs[os.path.relpath(p, root)] = ch @@ -328,7 +352,8 @@ def check(root, pack, host="claude", home=None, catalog=None): why, cmds = TOOLS[t] cmd = cmds[fam] if t == "node": - cmd += " && " + HELPERS_CMD + cmd += " && " + shlex.join([sys.executable, os.path.abspath(__file__), "fix", + "--root", root, "--host", host]) needs.append({"kind": "tool", "name": t, "why": why, "command": cmd}) readiness = helper_readiness(root, host, home, catalog) helpers = readiness["ready"] @@ -380,7 +405,7 @@ def fix(root, pack, helpers=True, dry=False, host="claude", home=None, catalog=N if not pack: return {**before, "fixed": [], "error": "pack-not-found"} if not dry: - r = run(["bash", os.path.join(pack, "install.sh"), root], + r = run(["bash", os.path.join(pack, "install.sh"), "--hosts", host, root], input="", cwd=root) if r.returncode != 0: return {**before, "fixed": fixed, "error": "install.sh failed: " + (r.stderr or r.stdout)[-300:]} @@ -424,7 +449,11 @@ def fix(root, pack, helpers=True, dry=False, host="claude", home=None, catalog=N if helpers and not before["helper_skills"] and have("npx"): if not dry: - r = run(["bash", "-c", HELPERS_CMD], cwd=root, input="") + for command in helper_commands(before["helper_readiness"], host): + r = run(command, cwd=root, input="") + if r.returncode: + detail = (r.stderr + "\n" + r.stdout).strip()[-1000:] + notes.append(f"helper install failed (exit {r.returncode}): {command[4]}: {detail}") confirmed = helper_readiness(root, host, home, catalog) if confirmed["ready"]: fixed.append("worker skills re-resolved after helper installation") @@ -466,6 +495,7 @@ def text_report(res): lines.append(f" ✗ missing {entry['name']} for {res['helper_readiness']['host']}; install it in that host and restart") if res.get("helper_readiness", {}).get("error"): lines.append(" ✗ " + res["helper_readiness"]["error"]) + lines += [" ✗ " + note for note in res.get("notes", []) if note.startswith("helper install failed")] for path, error in {**res.get("verification", {}), **res.get("host_errors", {})}.items(): lines.append(f" ✗ {path}: {error}") for n in res.get("needs", []): @@ -973,8 +1003,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/scripts/super-board-skills-ready.py b/scripts/super-board-skills-ready.py index 87ce1a9c..3d7ff46e 100755 --- a/scripts/super-board-skills-ready.py +++ b/scripts/super-board-skills-ready.py @@ -264,6 +264,10 @@ 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 lanes disable implicit discovery and receive these exact files. + self.roots = [self.root / '.agents/skills', self.home / '.agents/skills', + self.root / '.pi/skills', self.home / '.pi/agent/skills'] else: # Codex discovery stops at this independent repository's root. process = subprocess.run(['git', '-C', str(self.root), 'rev-parse', '--show-toplevel'], @@ -454,7 +458,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/scripts/super-board-status.py b/scripts/super-board-status.py index b9530a10..1fa3f522 100755 --- a/scripts/super-board-status.py +++ b/scripts/super-board-status.py @@ -171,7 +171,7 @@ def parse_manifest(text: str, today_iso: str) -> dict[str, Any]: recents.append({ "epoch": ep, "verb": "dispatch", "glyph": LANE_GLYPH[lane], "issue": f"#{issue}", "target": LANE_LABEL[lane], - # `attempt N/3` is filled in at render time from current + # repair phase is filled in at render time from current # labels — the manifest doesn't encode it at dispatch time. "detail": "", }) @@ -272,20 +272,22 @@ def rebuild_count(item: dict[str, Any] | None) -> int: """Read `loop:rebuild-N` from labels; 0 if absent.""" if not item: return 0 - for lab in item["labels"]: - if m := re.match(r"loop:rebuild-(\d+)", lab): - return int(m.group(1)) - return 0 + return max((int(m.group(1)) for lab in item.get("labels", []) + if (m := re.fullmatch(r"loop:rebuild-(\d+)", lab))), default=0) def attempt_str(item: dict[str, Any] | None) -> str: - """`attempt N/3` derived from the issue's current `loop:rebuild-N` label.""" - return f"{min(rebuild_count(item) + 1, 3)}/3" + labels = (item or {}).get("labels", []) + for phase in ("exhausted", "final", "diagnosis"): + if f"loop:repair-{phase}" in labels: + return phase + count = rebuild_count(item) + return f"repair {count}/2" if count <= 2 and count else ("exhausted" if count else "initial") def rebuild_suffix(item: dict[str, Any]) -> str: - k = rebuild_count(item) - return f"↻ {min(k + 1, 3)}/3" if k else "" + phase = attempt_str(item) + return f"↻ {phase}" if phase != "initial" else "" REASON_TABLE: list[tuple[str, str]] = [ @@ -684,7 +686,7 @@ def reason_for_issue(n: int) -> tuple[str, str]: ] extra = (" · " + ", ".join(extras)) if extras else "" print( - f" {glyph} {role} #{v['issue']} attempt {attempt_str(item)} · " + f" {glyph} {role} #{v['issue']} {attempt_str(item)} · " f"{worker_dur(v['ts'])}{extra}" ) @@ -712,7 +714,7 @@ def reason_for_issue(n: int) -> tuple[str, str]: else: for r in recents: t = t_minus(r["epoch"]) - # Dispatch detail (`attempt N/3`) is filled in at render time + # Dispatch detail (repair phase) is filled in at render time # because the manifest doesn't encode the rebuild count at # dispatch time — the number shown reflects the issue's *current* # label. Live workers in the §Workers block above use the same @@ -720,7 +722,7 @@ def reason_for_issue(n: int) -> tuple[str, str]: detail = r["detail"] if r["verb"] == "dispatch" and not detail: issue_num = int(r["issue"].lstrip("#")) - detail = f"attempt {attempt_str(item_by_number(issue_num))}" + detail = f"{attempt_str(item_by_number(issue_num))}" if r["target"]: print( f" {t:<7} {r['glyph']} {r['verb']:<9} {r['issue']:<4} → " diff --git a/skills/super-board/SKILL.md b/skills/super-board/SKILL.md index 8ec440c2..18bbca82 100644 --- a/skills/super-board/SKILL.md +++ b/skills/super-board/SKILL.md @@ -12,7 +12,7 @@ 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). `--pi` uses fresh Pi lanes with configured `pi.provider` and `pi.model`. `--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 detected Codex host runs as `--codex` by itself. Unknown hosts halt. | | `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. @@ -26,7 +26,8 @@ In Codex the skill is not a slash command: it is `$super-board <verb>`, found in | `super-board onboard ...` | `references/onboard.md` | | `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 ...` | `references/run-workflow.md` → "Pi runs" | +| `super-board run --codex[=<model>] ...`, or any `run` from a detected Codex host (overrides any configured backend) | `references/run-workflow.md` → "Codex 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` overrides either backend with headless Codex waves; so does `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`), or fresh Pi processes (explicit `--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/block-template.md b/skills/super-board/references/block-template.md index 49af3e61..00110937 100644 --- a/skills/super-board/references/block-template.md +++ b/skills/super-board/references/block-template.md @@ -172,3 +172,11 @@ A human can still merge a PR manually under the repository's normal rules. ## Hard rule **The bot is forbidden from moving any card to Blocked, or dropping it to Done, *without* this full template populated. A 1-line "needs creds" comment is a contract violation and fails Reviewer's thread gate.** + +## Exhausted repairs + +Use reason 🛡 and `blocked-by: -`. Link the canonical `<!-- super-board:repair -->` +handoff and quote its repair count, diagnosis verdict and final repair result. Name the +failed command and preserved branch/worktree. `Owner` names the person who must decide +the next change. `To unblock` states the exact decision or independent investigation +needed. Moving the card or changing the failure hash never replenishes attempts. diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index e4bd4bbb..723676d0 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -31,13 +31,17 @@ // only; the dispatcher refuses to run (exit 78) unless set here. "worker_backend": "workflow", - // Agent host this project runs on: "claude" | "codex" | "opencode". Separate from + // Agent host this project runs on: "claude" | "codex" | "pi" | "opencode". Separate from // worker_backend. Onboard and setup's fix ALWAYS write it (setup fills a missing key // from --host and never replaces a set one); setup's check flags any other value. // `super-board-host.sh --config <this file>` uses it only when SUPER_BOARD_HOST and // every env sign (Codex ids, OPENCODE, CLAUDECODE) are absent. See docs/hosts.md. "host": "claude", + // Explicit --pi only. Pi is the execution program, separate from codex exec. + // Both non-empty values are required. No provider or model is inferred. + "pi": { "provider": null, "model": null }, + // Optional post-completion adapter; never bundles or installs the external skill. // Runs only after selected work is verified merged and no work/approvals remain. // The supervising host checks the resulting diff; the helper itself is read-only. @@ -153,23 +157,27 @@ // ALWAYS human, whatever `default` says. A PR matching ANY label, path glob // or keyword (case-insensitive, searched in the diff's ADDED lines only) of // a category exits the gate 7: no merge, card → Blocked 🙋 (the human merges - // it, or comments "done" to approve and the next wave merges). - // Setting this key replaces the whole default set; {} turns it off. + // it, or supplies the existing canonical approval bound to the reviewed head). + // Project categories and matcher arrays extend mandatory defaults. {} keeps them. + // Preserve stricter project rules. Routing matches do not prove semantic safety. // Globs: `**/` = any dirs (incl. none), `**` = anything, `*` = one segment. "always_human": { "money": { "labels": ["money", "payments", "billing", "pricing"], "paths": ["**/billing/**", "**/payments/**", "**/stripe/**", "**/checkout/**"], "keywords": ["stripe.", "payment_intent", "price_id", "invoice.create"] }, - "auth": { "labels": ["auth", "security"], + "auth": { "labels": ["auth", "security", "permissions"], "paths": ["**/auth/**", "**/authz/**", "**/permissions/**"], - "keywords": ["jwt.sign", "bcrypt", "password_hash", "service_role"] }, + "keywords": ["jwt.sign", "bcrypt", "password_hash", "service_role", "grant permission", "grant all"] }, // Destructive schema changes ONLY (DROP / TRUNCATE / RENAME, or the // `schema` label) need a human. Additive migrations (new table, new // column, new index) are not "schema": they follow the `migrations` DB-env // rule below. A live database is always human, whatever the change. "schema": { "labels": ["schema", "destructive"], "paths": [], - "keywords": ["drop table", "drop column", "truncate table", "rename column", "drop schema"] } + "keywords": ["drop table", "drop column", "truncate table", "rename column", "drop schema"] }, + "external-sharing": { "labels": ["external-sharing", "data-sharing", "external-data-sharing"], + "paths": ["**/telemetry/**", "**/data-export/**"], + "keywords": ["sendbeacon(", "navigator.sendbeacon", "captureexception("] } }, // true only when the user explicitly chose auto-merge onto a // production-detected base in onboard. run.md's production-merge guard @@ -310,9 +318,9 @@ // hit their own preflight and park. "max_workers": 0, - // Max consecutive failures in the same lane on the same root-cause hash - // before the card auto-moves to Blocked. Default `2`. Root-cause hash - // defined in §7 Halt gates. + // Normal repair attempts per unresolved card, at most 2. A stricter cap is retained. + // Then one independent diagnosis and at most one final targeted repair. + // Hash changes and restarts never reset the budget. See run.md Repair protocol. "rebuild_cap": 2, // When the count of Blocked cards exceeds this percent of the initial Ready diff --git a/skills/super-board/references/design-preflight.md b/skills/super-board/references/design-preflight.md index ddb51cae..c3d44347 100644 --- a/skills/super-board/references/design-preflight.md +++ b/skills/super-board/references/design-preflight.md @@ -21,6 +21,12 @@ Use `mattpocock-skills:codebase-design` for module depth and interfaces. Reach `mattpocock-skills:domain-modeling` only when terminology or an ADR needs work. Workers recheck [conditional skill readiness](skills-readiness.md) before activating either helper. +Write a concrete caller usage example first: inputs, returned values, errors and state +changes. Derive the smallest interface and ownership sketch from that usage, using +signatures and pseudocode rather than building a second implementation. Trace the +current caller flow before changing it. Existing specifications and ADRs constrain +both sketches. + Sketch two genuinely different arrangements of callers and modules. For each, record: - **Owner and interface:** who owns state/behavior, which callers use it, and what they pass/get. @@ -47,3 +53,7 @@ Done means the applicable recorded design was followed, or both sketches and a j choice are available to the next worker/reviewer. An unresolved human choice stays blocked. Design quality remains agent judgment; format and installer checks do not prove the sketches are distinct or the selected design is good. + +Caller-first sketches adapt pstack `architect` at the pinned source in the +[pstack notice](../../super-build/references/pstack-notice.md). Use the existing helpers +above. Do not install another lifecycle wrapper or require additional review panels. diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index c91e9022..072662b6 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -9,15 +9,22 @@ 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 -installation, then re-resolves actual files; an installer exit of zero alone is +installation for the selected host, then re-resolves actual files; an installer exit of zero alone is not success. `--no-helpers` suppresses that repair attempt, not missing evidence. A host plugin inventory may be supplied with `--catalog <active-skills.json>`; only enabled entries applying to this project count. +`fix --host claude|codex|pi` installs only missing required helpers into the +project with that host's `skills` agent ID (`claude-code`, `codex`, or `pi`). +It uses Matt Pocock's engineering helpers, Superpowers' +`verification-before-completion`, and Anthropic's `testing-strategy`. +Sources and pinned revisions are listed in `skills-readiness.md`. Existing +resolved skills remain in place. Failed downloads remain visible in `notes`. + ## Design rules | Rule | How | @@ -152,7 +159,7 @@ Write it atomically (temp file + rename) after every answer. A halt never loses │ Fixes with NO question, backed up first to .claude/super-board/backup/<ts>/: │ skills, .claude/bin scripts, workflows, guard hooks + settings.json entries (runs the │ pack's install.sh), removes old skill folders (super-refine, cleanup-wt, arch-loop), - │ migrates every config to the v3 keys, git init, Matt Pocock's helper skills (Node present). + │ migrates every config to the v3 keys, git init, missing required helper skills for the selected host (Node present). ├─ Print its list exactly, one ✓ per line: │ ⏺ Fixed for you: │ ✓ super-board skills and scripts installed @@ -164,7 +171,7 @@ Write it atomically (temp file + rename) after every answer. A halt never loses │ Code's permission prompt IS the question. No AskUserQuestion. │ Needs your machine: Node.js isn't installed. It's needed for the helper installer; actual coding │ skills (tests, code review, debugging) the board uses. - │ → Bash(brew install node && npx -y skills@latest add mattpocock/skills) + │ → Bash(brew install node && python3 $SB/super-board-setup.py fix --host <claude|codex|pi>) │ The command is per OS (brew / apt / dnf / winget) and comes from the script. │ Declined: node → report exact missing skills and keep readiness red; existing resolved │ helpers still count, but missing required helpers halt dispatch; @@ -287,7 +294,7 @@ Write it atomically (temp file + rename) after every answer. A halt never loses • Yes, add <N> lines to settings.json (Recommended) • No, ask me each time Folded details = ONE dim line under each, never a table: - merge_policy.default auto · always_human: money, auth, schema · push guard on (main, master, + merge_policy.default auto · always_human: money, auth/permissions, schema, external-sharing · push guard on (main, master, <base>) · migrations.allowed_envs: test, staging + Bash(gh project:*) + Bash(gh issue:*) + Bash(gh pr:*) + … (the --dry-run output, joined) Review each → ONE more AskUserQuestion call, up to three questions: @@ -295,8 +302,8 @@ Write it atomically (temp file + rename) after every answer. A halt never loses (Recommended) / I merge everything]; production base: the order flips, and robot-merges sets merge_policy.allow_auto_on_production: true (run.md's guard needs it). → merge_policy.default "auto" | "human"; auto_max_lines 400 (lockfiles, generated, - snapshots, migration SQL not counted); always_human = schema defaults (money, auth, - destructive schema). A matching PR parks in Blocked with 🙋. + snapshots, migration SQL not counted); always_human extends mandatory defaults (money, auth/permissions, + destructive schema, external-sharing), preserving all stricter existing categories and matchers. A matching PR parks in Blocked with 🙋. b. "Push guard" — "Block pushing straight to main?" [Yes (Recommended for a repo with commits) / No]. Skipped when settings.json already wires guard-protected-push.py. Script missing (--no-hooks install) → record the answer and say: "Run ./install.sh --no-hooks @@ -361,7 +368,8 @@ Write it atomically (temp file + rename) after every answer. A halt never loses │ │ {type: repo | repo+url}, repo, base_branch, timezone, columns (the seven), labels, │ │ paths, merge_policy, migrations, collect (incl. custom), notifications {channel: │ │ "session", bot_identity}, worker_backend "workflow", host (always: `bash $SB/super-board-host.sh`; - │ │ on exit 65 ask claude/codex/opencode), verify_commands (non-blank project checks). No `variant`. + │ │ on exit 65 ask claude/codex/pi/opencode; Pi also requires explicit + │ │ pi.provider and pi.model, with no inferred credentials or model), verify_commands (non-blank project checks). No `variant`. │ ├─ .claude/super-board/active ← <slug> │ ├─ .gitignore += .claude/super-board/active, onboard-answers.json, onboard-staged/, │ │ backup/, inflight/, migrations/, upgrade.json (all under .claude/super-board/) diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index c63f5f08..5b8893bf 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -8,6 +8,26 @@ workers. Lane lifecycles, branch/PR model, comment cadence, Block templates, halt gates, and done conditions are all inherited from `run.md` unchanged. +## Pi runs + +`/super-board run --pi` selects Pi explicitly and overrides `worker_backend`. +A host check returning `pi` selects the same path. Never infer Pi from missing +Workflow tools, and never combine `--pi` with `--codex`. +Require `pi` on PATH, configured non-empty `pi.provider` and `pi.model`, and +`bash -n` on the wave and Pi lane scripts before claims. Run skills readiness +with `--host pi`. Before claims, run +`SUPER_BOARD_PI_ROOT="$PWD" python3 .claude/bin/super-board-pi-guards.py --check` +and require `.claude/bin/super-board-pi-guards.ts` to exist. +This reads installed guard and hook settings, never model credentials. +Repair missing resources before claims or worktree writes. +Launch the existing wave command below with `--pi` replacing `--codex`. +It starts a fresh `super-board-pi-lane.sh` for each Build, QA and Review lane. +No model ladder or Codex router is used. The same wave PID marker, stop, +reconciliation, repair budget and reviewed-head merge gate apply. +Pi's tool allowlist is not an OS sandbox. Keep required guard-script checks, +and use an isolated environment if the project requires process confinement. +Claude remains the default until the matched acceptance pilot passes. + ## Codex runs `/super-board run --codex` uses headless Codex for every lane of every wave, @@ -20,14 +40,14 @@ Ready takes its tier's medium cell. `--codex=<model>`, else optional `codex.mode 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 Codex, plain `run` is `--codex`.** Your tool list has no Workflow tool, or +**Inside Codex, plain `run` is `--codex`.** When `bash .claude/bin/super-board-host.sh --config <config-path>` prints `codex` → run as `--codex` (tier flags and `--codex=<model>` still apply); it counts as explicit `--codex`, Review included. Say so once, in the first wave report: `🔁 codex host — running as --codex`. Never improvise a Claude workflow wave in Codex. **Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign) or `opencode` (no OpenCode backend yet) halts before any claim, even with no Workflow tool, with one line: -`⛔ unknown host — set SUPER_BOARD_HOST=claude|codex and re-run`. Never guess a backend. +`⛔ unknown host — set SUPER_BOARD_HOST=claude|codex|pi and re-run`. Never guess a backend. After the same plan and claim steps, launch this command as a background task: `bash .claude/bin/super-board-codex-wave.sh --config <config-path> --cards <claimed-cards.json> --codex[=<model>] --tier <low|medium|high> --output <wave-result.json>`. @@ -116,7 +136,7 @@ Run the same preconditions as `run.md` §Preconditions, minus PID checks: 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, + `super-board-codex-lane.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 @@ -141,7 +161,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` and `--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 @@ -152,7 +172,11 @@ Repeat until a done condition or halt gate fires: Plan as usual, take the `build` and `qa` cards (`review` cards wait), and for ONE card at a time claim it, run `bash .claude/bin/super-board-codex-lane.sh --config <config-path> --card '<card JSON>' --lane <build|qa>`, read its JSON exit like a lane - result, then reconcile (step 5). Re-run the usage check between cards; on exit 0 + result, then reconcile (step 5). Direct usage-fallback calls use the coordinator + canonical prepare/completion protocol below, since no wave helper owns their record. + If prepare requires diagnosis, invoke a fresh `super-board-codex-lane.sh --diagnosis` + with the same fallback authorization and full evidence, validate its result, persist + `diagnosed`, and prepare the final attempt. Never wake Claude during its usage pause. Re-run the usage check between cards; on exit 0 return to normal waves. The report line says `🔁 codex fallback`. Without `usage_fallback: "codex"` the launcher refuses (exit 78) unless `--codex` is given. - exit 10 otherwise (pause) → launch nothing new. A wave already running finishes — @@ -185,7 +209,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. @@ -245,9 +269,25 @@ 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. The host check printing `claude` uses the Workflow tool; exit 65 or - `opencode` never reaches Launch (see "Unknown host halts"). Workflow tool with + **Reserve before Claude dispatch.** For each claimed card on the Claude backend, + select its entry lane and a unique claim id. Run + `python3 .claude/bin/super-board-repair.py prepare --issue <N> --lane <lane> + --owner <claim-id> --labels <labels-JSON> --cap <rebuild_cap> --config <config-path> + --diagnose-claude`. + Use arguments as data, never interpolate an issue body into shell code. + Null means initial work or read-only verification. Pass a returned reservation as + `card.repairReservation`. The helper persists/read-backs before dispatch. It runs + the one fresh Read/Grep/Glob-only diagnosis when required, validates evidence and + reserves the final targeted repair only for a different testable cause. + An exhausted result has already moved the card to Blocked. Do not launch it. + A failure halts without releasing claims or resetting state. Reconcile interrupted + owners before the `interrupted` transition. Never refund attempts. + Pi/Codex waves perform the same boundary inside the wave helper. The coordinator + must not reserve or complete those a second time. +4. **Launch** — With `--pi` or a `pi` host check, use "Pi runs" above. With `--codex` + or a `codex` host check, use "Codex runs" above. A `claude` host requires the Workflow + tool. A missing Workflow tool never selects another host. Exit 65 and `opencode` + halt before Launch (see "Unknown host halts"). 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 @@ -262,7 +302,16 @@ Repeat until a done condition or halt gate fires: fails, preserve claims and stop. For verification-readiness (exit 9), repair `verify_commands` and restart explicitly; do not invoke GitHub evidence recovery. For a GitHub read halt, follow "Required GitHub evidence" above. Otherwise, - for EVERY card in the wave, release the assignee + for each Claude card first complete the owned canonical record. Lanes write proof + but do not edit its machine state. With a reservation, call + `python3 .claude/bin/super-board-repair.py succeeded --issue <N> --owner <claim-id>` + after advanced, blocked or human-gate outcomes. For bounced/failed outcomes, including + initial work with no reservation, call `failed --issue <N> --owner <claim-id> + --cause <failure-detail> --pending-lane <build|qa> --pr-url <PR-URL> --config <config-path>`. + Use the returned column to select the failed repair's owner: QA → qa, Ready → build. + Completion retains counters and used phases. Persistence failure preserves the claim + and halts before another wave. An exhausted completion has already moved to Blocked. + Then, for EVERY card in the wave, release the assignee (`gh issue edit <n> --remove-assignee <bot_identity>`, idempotent). Append one line per card to the run manifest `docs/super-board/runs/<date>-<slug>.md`: @@ -340,9 +389,13 @@ don't stall on prompts: "Bash(.claude/bin/super-board-github-read.py:*)", "Bash(python3 .claude/bin/super-board-verification-ready.py:*)", "Bash(python3 .claude/bin/super-board-skills-ready.py:*)", + "Bash(python3 .claude/bin/super-board-repair.py:*)", + "Bash(python3 .claude/bin/super-board-pi-guards.py:*)", + "Bash(python3 .claude/bin/super-board-repair.py:*)", "Bash(bash .claude/bin/super-board-usage.sh:*)", "Bash(bash .claude/bin/super-board-throttle.sh:*)", "Bash(bash .claude/bin/super-board-codex-lane.sh:*)", + "Bash(bash .claude/bin/super-board-pi-lane.sh:*)", "Bash(bash .claude/bin/super-board-codex-wave.sh:*)", "Bash(bash .claude/bin/super-board-host.sh:*)", "Bash(bash .claude/bin/super-board-pr-body.sh:*)", "Bash(gh pr edit:*)", diff --git a/skills/super-board/references/run.md b/skills/super-board/references/run.md index 81eb4bf2..9f161cad 100644 --- a/skills/super-board/references/run.md +++ b/skills/super-board/references/run.md @@ -341,7 +341,7 @@ Record uncovered/stale optional coverage alongside ordinary issue AC checks. 4b. **Test-gap check** (super-qa → "Test-gap check (after build)"): map every AC to unit / component / e2e tests, hunt edge cases, write the High gaps red-first. A High gap that needs app code changed → Fail (step 7) with the gap list. Medium/Low go in the handoff under `Test gaps (not written)`. 5. Run the tests. Capture evidence to `docs/super-board/runs/issue-<N>-qa-v<N>/`. For UI/visual ACs, capture screenshots at the standard viewports (1920×1080 desktop, 1024×768 tablet, 375×667 mobile). Commit the screenshots to the issue branch BEFORE writing the comment (the markdown image URLs depend on the files being present on the branch). 6. **Pass** → commit test files + screenshots to same branch + push → tick the `ac` block with proof lines, write `visual` for UI, append a `history` row, rewrite `status` → `[qa] [report] ✅` PR comment with results + evidence path **+ inline screenshot embeds** (see "Screenshot embed format" below) → issue comment with the SAME inline screenshot embeds → move card QA → Review. Clean up worktree. -7. **Fail** → leave failing ACs unchecked with the reason, append a `history` row, rewrite `status` → `[qa] [report] ❌` PR comment with per-AC expected/actual + repro file:line + evidence path + "what fixed should look like" **+ inline screenshot embeds of the broken state** → issue comment with the same inline screenshots (showing what's wrong) → increment rebuild counter → move card QA → Ready (label `loop:rebuild-N`). Clean up worktree. +7. **Fail** → leave failing ACs unchecked with the reason, append a `history` row, rewrite `status` → `[qa] [report] ❌` PR comment with per-AC expected/actual + repro file:line + evidence path + "what fixed should look like" **+ inline screenshot embeds of the broken state** → issue comment with the same inline screenshots (showing what's wrong) → record failure through the repair protocol below → move card QA → Ready only when the protocol permits (otherwise Blocked). Clean up worktree. #### Screenshot embed format (mandatory on every QA exit — added 2026-05-22) @@ -395,7 +395,7 @@ If a screenshot file is >5MB, downscale to ≤1920px wide before committing; Git ``` - Empty → first review of this card. Skip to step 4; behave exactly as before. - Non-empty → the card was bounced and rebuilt. **Round 1** walks every finding in `prior_report` and marks each one `fixed` / `not fixed` / `no longer applies` (code it pointed at is gone or the AC changed), citing the file:line that proves it. A resolved thread is not proof — check the code. - - Any `not fixed` → bounce again now: re-open one thread per unfixed finding with its original prefix (`[builder]` → Ready, `[qa]` → QA, both → `[builder]` first), `loop:rebuild-N`, and update the Reviewer report: those rows go to `❌ not fixed`. Skip the fresh pass; it would review code that is about to change. + - Any `not fixed` → bounce again now: re-open one thread per unfixed finding with its original prefix (`[builder]` → Ready, `[qa]` → QA, both → `[builder]` first), the repair protocol below, and update the Reviewer report: those rows go to `❌ not fixed`. Skip the fresh pass; it would review code that is about to change. - An unfixed **Over-engineering** finding alone does not bounce; carry it into the new report's Findings. - All `fixed` / `no longer applies` → continue to step 4 for a fresh pass. Do not re-raise a prior finding marked `no longer applies`. 4. Review the code (logic, conventions). Review the tests (right thing tested? testable assertions? meaningful coverage?). @@ -403,7 +403,7 @@ If a screenshot file is >5MB, downscale to ≤1920px wide before committing; Git - Pull `issue-<N>-<slug>` into the review worktree. - Re-run the test command Tester used (recorded in Tester's PR handoff comment as `Local tests:` line). - Tests green → continue to step 6. - - Tests red → open new `[qa]`-prefixed PR thread quoting the failure output, move card Review → QA with `loop:rebuild-N`, exit. Tester wrote a broken suite; QA owns the fix. + - Tests red → open new `[qa]`-prefixed PR thread quoting the failure output, follow the repair protocol below before moving card Review → QA, exit. Tester wrote a broken suite; QA owns the fix. 6. **Adversarial mode** (per `truth_gate`): when triggered, spawn 2 sub-agents in parallel: - **Code-grounder** — verify cited file:line still exists and matches claims. - **Historian** — `git blame` the changed lines, check for ADRs / prior incidents. @@ -416,10 +416,10 @@ If a screenshot file is >5MB, downscale to ≤1920px wide before committing; Git 7. Decide per finding. First re-apply the Over-engineering rule to every collected finding, whatever a sub-agent wrote: Should fix, never Blocker, never on its own a bounce or block. Then: - **No findings (Over-engineering aside) + threads clean + truth ≥ threshold + tests green** → run the **merge protocol** below. Do not move the card to Done any other way. - **Over-engineering only** (from `ponytail:ponytail-review`, see super-review step 3) → list it in the report, open no thread, and carry on to the merge decision; it never bounces a card alone. When the card bounces for another finding anyway, open a `[builder]` thread for each Over-engineering finding too, so the rebuild trims it. - - **Code-side new finding** → open new `[builder]`-prefixed PR thread, comment, move card Review → Ready (label `loop:rebuild-N`). - - **Test-side new finding** → open new `[qa]`-prefixed PR thread, comment, move card Review → QA (label `loop:rebuild-N`). + - **Code-side new finding** → open new `[builder]`-prefixed PR thread, comment, move card Review → Ready (follow the repair protocol below before bouncing). + - **Test-side new finding** → open new `[qa]`-prefixed PR thread, comment, move card Review → QA (follow the repair protocol below before bouncing). - **CI-budget block (💳, added 2026-05-22)** — if remote CI jobs `failed_to_start` due to `Actions budget` AND `config.auto_merge_on_ci_budget_block` is true AND local-evidence is strong (truth ≥ threshold, Tester suite green on rerun in step 5, all `[builder]`/`[qa]` threads clean) → **squash-merge anyway** on local evidence; do NOT move to Blocked. Write the PR's `redcheck` block (`> [!WARNING]` · `🔴 Merged with a failing check: <check> · CI budget block`) and a `[reviewer] [report] ✅ merged · CI-budget bypass` comment on the PR and the issue citing: (a) the failed CI run ID, (b) the Tester pass-count, (c) the truth-gate score. Reason: CI failure-to-start ≠ test failure; with strong local evidence, parking the card wastes pipeline time. This bypass is ONLY for `💳` — never for `🛡` truth-fail, `🔐` missing creds, or `🧑` human-only decisions. - - **Human-gate / Blocker finding (destructive schema, API contract, money, auth, live-DB migration) / rebuild cap hit (config.rebuild_cap)** → write the full Block template (see §4), move card Review → Blocked. A clean PR that only *touches* money, auth or schema is not a blocker — run the merge protocol; the gate's `merge_policy` routes it to a human (exit 7 → 🙋 Blocked). + - **Human-gate / Blocker finding (destructive schema, API contract, money, auth, live-DB migration) / repair protocol exhausted** → write the full Block template (see §4), move card Review → Blocked. A clean PR that only *touches* money, auth or schema is not a blocker — run the merge protocol; the gate's `merge_policy` routes it to a human (exit 7 → 🙋 Blocked). 8. Write the **Reviewer report** on every exit from step 3b on — bounce, block, human gate, merge (a Gate 1 thread bounce reviews nothing and writes none). It is ONE comment per PR, **edited in place** each round: find it by the marker (step 3b's lookup, plus `.url` — the number after `#issuecomment-` is its id) and `gh api -X PATCH repos/<owner>/<repo>/issues/comments/<id> -F body=@<file>`; post a new comment only when none exists. It is what step 3b reads next time, so the first line is the stable marker and every finding keeps its id (writing-standard.md § 4): ``` @@ -441,6 +441,11 @@ If a screenshot file is >5MB, downscale to ≤1920px wide before committing; Git ### Merge protocol (Reviewer only — added 2026-08-06, issue #9) +Apply super-review's semantic approval check before the gate. A matcher miss is not +proof of safety. Restricted authentication/permissions, payment, destructive-schema +or external-sharing behavior requires human approval bound to the reviewed head. +Preserve stricter project policy. Production deployment requires separate approval. + Builder opens every PR as a **draft**, and GitHub will not merge a draft: `gh pr merge --auto` on a draft queues forever and never fires. A run that skipped this step produced two substantial builds and landed **zero commits on `main` in six hours**. The Reviewer must @@ -645,7 +650,7 @@ The three locks (assignee, in-flight file, lane PID) are defense in depth: any o | Gate | Action | |---|---| -| `config.rebuild_cap` reached on same card + same root-cause hash | Move card to Blocked with full §4 template (reason 🛡), continue run | +| Repair protocol exhausted (including failed or inconclusive diagnosis/final repair) | Move card to Blocked with full §4 template (reason 🛡), continue run | | No card progresses for 3 ticks AND no lane is idle | Halt, dump state | | Auth expires mid-run | Halt, ping user with refresh instruction | | Pre-flight check fails on re-validation | Halt with the specific missing item | @@ -718,7 +723,7 @@ cards sat that way on a real board until a human opened it and noticed. ### Root-cause hash (used by the rebuild-cap gate) -To detect "same root cause" without depending on a model classifier, each lane writes a deterministic hash to its failure handoff comment: +To retain failure evidence, each lane writes a deterministic hash to its failure handoff comment: ``` root-cause-hash: <sha256 first 12 hex chars> @@ -730,9 +735,63 @@ The hash inputs (joined with `|`): 2. Error class — first matched: `test_assertion`, `test_runtime`, `build_fail`, `lint_fail`, `merge_conflict`, `network`, `auth`, `quota`, `other`. 3. First 3 unique `file:line` frames from the captured stack trace / test output (sorted, normalized — strip absolute paths to repo-relative). -Two consecutive failures on the same card with **identical hash** AND **same lane** count as the same root cause. Hits `rebuild_cap` → Blocked. - -Different hash on the same card resets the counter — the bot recognizes that progress has been made even if the card hasn't reached Review yet. +The hash is evidence, never a counter key. Changed hashes, moved frames, lane bounces, +restarts and changed diagnoses never reset the unresolved card's repair budget. + +### Repair protocol (mandatory before any repair or failure bounce) + +The dispatcher that starts the next repair owns machine transitions. Pi/Codex waves +persist them in the wave helper. The Claude workflow coordinator persists them before +Launch and during Reconcile. Those lane workers return outcomes and normal proof, +without editing the canonical machine line or reserving again. Legacy headless workers +complete the explicit reservation supplied in their prompt through the canonical CLI. + +Keep one canonical failure handoff on the issue (`<!-- super-board:repair -->`), or on +its PR only when no issue exists. Link it from other handoffs. Its machine line is +`repair-state: <JSON object>`. Do not store another retry database. Initial implementation +and read-only verification are not repairs. On the first failed verification, initialize +`{"phase":"idle","repairs":0,"cause":"<testable cause>","diagnosis_used":false,"final_used":false}`. +Legacy `loop:rebuild-N` labels establish a lower bound for `repairs`; never silently +start at zero when old evidence exists. Unreadable or conflicting evidence halts dispatch. + +Under the existing exclusive card owner, read the canonical record and run +`python3 .claude/bin/super-board-repair.py reserve --owner <claim-id> --cap <rebuild_cap>` +with that JSON on stdin. Persist and read back the returned record BEFORE dispatching +any repair or diagnosis worker. A failed persistence/read-back means no dispatch. +Use the same owned handoff for completion updates. Two normal repairs are permitted +(a stricter `rebuild_cap` is retained). The helper refuses an outstanding reservation. +Before reusing interrupted work, prove the old worker stopped, preserve and inspect its +branch/worktree and evidence, then record `interrupted --owner <original-claim-id>`. +An interrupted reserved attempt is consumed, never refunded. + +A `reserved` phase permits exactly one repair. A failed result uses `failed` with the +same owner and updated cause evidence. After the normal repairs, `reserve` returns +`diagnosis`: dispatch one FRESH INDEPENDENT session, with no code mutation, push or +merge permission. Supply accepted behavior, failing proof and prior attempted causes. +It must identify a different testable cause and a command/expected result that can +falsify it. Write its evidence and use `diagnosed --owner <claim-id> --cause <cause> +--test <command-and-expected-result>` only after verifying those conditions. No different +testable cause, failed diagnosis or interrupted diagnosis means `exhausted` → Blocked. +A different hash alone is not a different cause. +For Pi, invoke a new `.claude/bin/super-board-pi-lane.sh --pi --diagnosis --config <cfg> +--card <JSON-file> --lane <current-lane>` process. Include accepted behavior, failure +proof and prior causes in that card's body. This launcher exposes only `read`, disables +ambient extensions/MCP and starts a fresh session. Its `detail` contains JSON strings +`cause`, `test`, `expected`; the owning lane validates and persists the diagnosis. +The diagnosis process never advances the board itself. + Compare against every prior attempted cause +in the canonical `causes` array, not only the latest failure. + +After a valid diagnosis, one `reserve` returns `final`: permit ONE targeted repair of +that cause only. Failed or interrupted final repair means `exhausted` → Blocked. +Successful repairs use `succeeded --owner <claim-id>` to release the reservation while +retaining counters, cause history and used flags through QA/Review until the card is merged. +A new failure before merge belongs to the same unresolved card budget. +Mirror the cumulative repair count as `loop:rebuild-N`, and phase as exactly one of +`loop:repair-diagnosis`, `loop:repair-final`, `loop:repair-exhausted` when applicable. +Labels are display projections, never the canonical reservation. Exhaustion writes the +full Block template, `blocked-by: -`, evidence of each repair/diagnosis, named owner and +exact next action. Keep the card in its current lane while active. ## Done conditions diff --git a/skills/super-board/references/skills-readiness.md b/skills/super-board/references/skills-readiness.md index 2331433f..74943e2b 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 @@ -36,6 +36,20 @@ user/project scope, with project_root for project entries. They grant no fallback or mutation authority. Read-only checks and dispatch install nothing. Explicit onboard `fix` retains helper repair and re-resolves files afterward. +Helper repair is project-local and host-specific. It requests only unresolved +required names, preserving already-resolved skills and explicit disablement. +The installer uses these inspected source revisions: + +| Source | Required skills | Revision | License | +|---|---|---|---| +| [mattpocock/skills](https://github.com/mattpocock/skills/tree/5beb35b040e3de97e54fa9e386d7e42d15c295c2) | `diagnosing-bugs`, `tdd`, `implement`, `codebase-design`, `code-review` | `5beb35b040e3de97e54fa9e386d7e42d15c295c2` | MIT | +| [obra/superpowers](https://github.com/obra/superpowers/tree/bb92a77741419a4ab5f06e711a283343f1ada0c3) | `verification-before-completion` | `bb92a77741419a4ab5f06e711a283343f1ada0c3` | MIT | +| [anthropics/knowledge-work-plugins](https://github.com/anthropics/knowledge-work-plugins/tree/a266ab8c3c754a3ccf7a634044fc1595fc06e50e/engineering/skills/testing-strategy) | `testing-strategy` | `a266ab8c3c754a3ccf7a634044fc1595fc06e50e` | Apache-2.0 | + +The selected installer agents are `claude-code`, `codex`, and `pi`. No global +settings or plugin activation records are changed. A failed installation or a +zero exit without readable host-visible files leaves readiness false. + Codex native `[[skills.config]]` disabled paths also apply after symlink resolution. Unsupported activation syntax requires an authoritative active catalog; only its exact enabled paths may resolve until native syntax is diff --git a/skills/super-build/SKILL.md b/skills/super-build/SKILL.md index 16c7f017..f008d115 100644 --- a/skills/super-build/SKILL.md +++ b/skills/super-build/SKILL.md @@ -261,6 +261,8 @@ slices (each thin end to end, under the cap, with its own ACs), which slice this split proposed in PR #<P>", `block-template.md`). Never pad past the cap to "finish" the ticket. ### Lifecycle (Builder, rebuild) + +Follow `../super-board/references/run.md` → Repair protocol. In managed waves the dispatcher reserves before this lane starts and owns the machine record and repair labels. Return outcomes and normal proof without reserving again. A standalone dispatcher must persist its owned reservation before launching repair work. Initial work is excluded. Two repairs lead to one fresh independent read-only diagnosis and at most one final targeted repair. Never reset for changed hashes, lanes or restarts. Triggered when card returns to Ready/Building with `loop:rebuild-N` label. 1. Read PR review threads on this branch's PR; filter `[builder]` prefix. 2. For each unresolved `[builder]` thread: read file:line + suggested fix → apply → resolve thread via: diff --git a/skills/super-build/references/decision-policy.md b/skills/super-build/references/decision-policy.md index 3de3dc2e..c6411138 100644 --- a/skills/super-build/references/decision-policy.md +++ b/skills/super-build/references/decision-policy.md @@ -79,6 +79,29 @@ unchanged. Resolve this lane for the actual host, load the report's files/fallbacks, halt on missing requirements and recheck each conditional helper before use. +## Trace before changing behavior + +For an uncertain bug or a change across modules, start with the affected entry point. +Trace its callers, state owner and downstream effects through exact source references. +Read the current validation, failure handling and tests before choosing an edit. Record +what is observed, what is inferred and what remains unknown in the existing issue/PR +handoff. A file list alone is not a traced flow. + +When history or a regression matters, inspect the relevant blame/log and linked issue, +PR or recorded design. Separate documented intent from inferred motivation. Supplied +specifications and ADRs remain constraints. Do not search unrelated systems merely to +fill an evidence checklist. + +Before implementation, identify the affected callers, tests, fixtures, configuration +and exports. Fix a shared cause at its owner and verify the accepted behavior on the +actual changed artifact. Missing execution evidence is an unverified claim. Keep the +existing QA verifier, test policy, independent Review truth gate and head-bound +approval. These checks do not create another feature map or review panel. + +These tracing checks adapt pstack `how` and `why` at the pinned source in the +[pstack notice](pstack-notice.md). They run inside the assigned lane, with its existing +skills and ownership. Installing or invoking the full pstack pack is not required. + ## Label routing A ticket already declares what kind of work it is. Reading that label is cheaper diff --git a/skills/super-build/references/pstack-notice.md b/skills/super-build/references/pstack-notice.md new file mode 100644 index 00000000..33d1981a --- /dev/null +++ b/skills/super-build/references/pstack-notice.md @@ -0,0 +1,35 @@ +# Pstack attribution + +The tracing and caller-first design instructions in `decision-policy.md` and +`../../super-board/references/design-preflight.md` adapt pstack's `how`, `why` and +`architect` instructions from `cursor/plugins` commit +`88d47514b82e7c44f00f2b2a72ff6dd12158038c`. + +Source: https://github.com/cursor/plugins/tree/88d47514b82e7c44f00f2b2a72ff6dd12158038c/pstack + +The adaptation keeps super-board's host-neutral skills, lane ownership and existing +test/review gates. It does not copy Cursor model choices, tool names or orchestration. + +## Upstream license + +MIT License + +Copyright (c) 2026 Lauren Tan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/super-qa/SKILL.md b/skills/super-qa/SKILL.md index 3f6801a7..132ecfd7 100644 --- a/skills/super-qa/SKILL.md +++ b/skills/super-qa/SKILL.md @@ -642,7 +642,7 @@ for affected selection, host readiness, real assertions and fresh proof. 4. Run tests. Capture evidence to `docs/super-board/runs/issue-<N>-qa-v<N>/`. **For any UI-affecting issue, capture at least one screenshot per AC** (Playwright `page.screenshot` or `browse --screenshot`). Save with descriptive names: `ac1-<short-desc>.png`, not `screenshot1.png`. 5. **Commit the evidence directory** to the issue branch alongside test files (`git add docs/super-board/runs/issue-<N>-qa-v<N>/ && git commit && git push`). This is non-optional — without it, the inline image markdown in the issue comment won't render on GitHub. 6. **Pass** → tick the PR's `ac` block with proof lines, write `visual` for UI, append a `history` row, rewrite `status` (`super-board-pr-body.sh`) → `[qa] [report] ✅` PR comment with results + evidence path → **issue comment with screenshot evidence** (see "Issue-comment evidence format" below) → move QA → Review. Clean up worktree. -7. **Fail** → leave failing ACs unchecked with the reason, append a `history` row, rewrite `status` → `[qa] [report] ❌` PR comment with per-AC expected/actual + repro file:line + evidence path + "what fixed should look like" → issue comment with the failure screenshots → increment rebuild counter → move QA → Ready (label `loop:rebuild-N`). Clean up worktree. +7. **Fail** → leave failing ACs unchecked with the reason, append a `history` row, rewrite `status` → `[qa] [report] ❌` PR comment with per-AC expected/actual + repro file:line + evidence path + "what fixed should look like" → issue comment with the failure screenshots → record failure through `../super-board/references/run.md` → Repair protocol, then move QA → Ready only if permitted, otherwise Blocked. Clean up worktree. ### Optional project feature verifier @@ -685,6 +685,8 @@ The Tester's issue comment is the user's primary visibility into "is this actual - Failure comments use the same format with broken-state screenshots (e.g., `before-fix-desktop.png`). ### Lifecycle (Tester, rebuild — when Reviewer bounced for [qa] thread fixes) + +Follow `../super-board/references/run.md` → Repair protocol. In managed waves the dispatcher reserves before this lane starts and owns the machine record and repair labels. Return outcomes and normal proof without reserving again. A standalone dispatcher must persist its owned reservation before launching repair work. Initial work is excluded. Two repairs lead to one fresh independent read-only diagnosis and at most one final targeted repair. Never reset for changed hashes, lanes or restarts. 1. Read PR review threads; filter `[qa]` prefix. 2. For each unresolved `[qa]` thread: apply fix to test files → resolve thread via `gh api graphql resolveReviewThread`. 3. Re-run full test suite for the ticket. diff --git a/skills/super-review/SKILL.md b/skills/super-review/SKILL.md index 01c4e227..88e138ad 100644 --- a/skills/super-review/SKILL.md +++ b/skills/super-review/SKILL.md @@ -320,7 +320,7 @@ See `.claude/skills/super-board/references/run.md` → Reviewer. Summary of the 5. **Reviewer-side test rerun (always — closes Tester self-verification gap):** - Pull `issue-<N>-<slug>` into review worktree. - Re-run the EXACT command from Tester's PR `Local tests:` line. - - Green → continue. Red → open new `[qa]`-prefixed thread quoting failure, move card Review → QA with `loop:rebuild-N`, exit. + - Green → continue. Red → open new `[qa]`-prefixed thread quoting failure, follow `../super-board/references/run.md` → Repair protocol before moving card Review → QA, exit. - If QA includes `Runtime checks:`, follow [optional runtime verification](../super-qa/references/runtime-verification.md): rerun the same scenario/config and pinned versions into new review evidence, @@ -333,14 +333,23 @@ See `.claude/skills/super-board/references/run.md` → Reviewer. Summary of the - **Deepening opportunity** → `scripts/super-review-file-refactor.sh`, then keep going. It is not a finding for merge purposes and never bounces a card. - **No findings (Over-engineering aside) + threads clean + truth ≥ threshold + tests green** → run the **merge protocol** (below). Never move a card to Done any other way. - **Over-engineering only** (from `ponytail:ponytail-review`, see super-review step 3) → list it in the report, open no thread, and carry on to the merge decision; it never bounces a card alone. When the card bounces for another finding anyway, open a `[builder]` thread for each Over-engineering finding too, so the rebuild trims it. - - **Code-side new finding** → new `[builder]`-prefixed thread, move card Review → Ready (`loop:rebuild-N`). - - **Test-side new finding** → new `[qa]`-prefixed thread, move card Review → QA (`loop:rebuild-N`). - - **Blocker finding (destructive schema, contract, money, auth, live-DB migration) or rebuild cap hit** → full §4 Block template, move card Review → Blocked. A *clean* PR that merely touches money, auth or schema is not a finding: run the merge protocol, and the gate's `merge_policy` hands it to a human (exit 7 → 🙋 Blocked). + - **Code-side new finding** → new `[builder]`-prefixed thread, move card Review → Ready (follow `../super-board/references/run.md` → Repair protocol before bouncing). + - **Test-side new finding** → new `[qa]`-prefixed thread, move card Review → QA (follow `../super-board/references/run.md` → Repair protocol before bouncing). + - **Blocker finding (destructive schema, contract, money, auth, live-DB migration) or repair protocol exhausted** → full §4 Block template, move card Review → Blocked. A *clean* PR that merely touches money, auth or schema is not a finding: run the merge protocol, and the gate's `merge_policy` hands it to a human (exit 7 → 🙋 Blocked). 8. Write the Reviewer report (marker `<!-- super-review:report -->`, ids on every finding) — edit the existing report comment in place, post a new one only on the first review. It is the next re-review's `prior_report`. 9. Clean up worktree. ### Merge protocol — Done means merged +Read the issue and actual changed behavior for authentication/permissions, payments, +destructive schema and external data-sharing boundaries. Labels, paths and keywords +only route conservatively. If restricted behavior is present outside the configured +matchers, add the corresponding restriction label to the PR before the existing gate. +If its boundary or proof remains unresolved, use the existing human-gate Block template +with the reviewed head, evidence, owner and exact next action. Missing evidence never +authorizes automatic merge. Preserve stricter project rules. Production deployment +requires separate approval. + Builder opens PRs as **drafts**, and GitHub never auto-merges a draft. Skipping the `gh pr ready` step is why a six-hour run landed zero commits on `main` while producing two complete builds. In order, no shortcuts: @@ -355,7 +364,7 @@ two complete builds. In order, no shortcuts: leave the card in **Review** with a `[reviewer]` comment naming both shas. The gate owns the merge decision (config `merge_policy`, `migrations`): - **Exit 9 — verification-readiness.** Halt the run; preserve card, approval, claims, and worktree. Configure `verify_commands` with the owning project's real checks and restart explicitly. No bounce to Build, rebase retry, approval request, or bare merge. - - **Exit 7 — a human merges.** `merge_policy` matched (money / auth / schema label, + - **Exit 7 — a human merges.** `merge_policy` matched (money / auth/permissions / schema / external-sharing label, path or added-line keyword; a diff over `auto_max_lines`, default 400 — "big PR — please review"; `default: "human"`). `schema` means destructive only (DROP / TRUNCATE / RENAME); an additive migration follows `migrations.allowed_envs`, and a live DB is always human. diff --git a/tests/test-codex-lane.sh b/tests/test-codex-lane.sh index 9646e67b..7e025a9a 100755 --- a/tests/test-codex-lane.sh +++ b/tests/test-codex-lane.sh @@ -10,14 +10,26 @@ 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 +import json,sys,os,re +from pathlib import Path args=sys.argv[1:] -if args[:2]==['issue','view']: +store=Path(os.environ['SB_REPAIR_FIXTURE_DIR']) +if args and args[0]=='api' and '--slurp' in args: + issue=re.search(r'/issues/(\d+)/comments', ' '.join(args)).group(1) + file=store/('repair-'+issue+'.json') + print('['+(file.read_text() if file.exists() else '[]')+']') +elif args[:2]==['api','-X']: + endpoint=args[3]; issue=endpoint.split('/')[-2] if args[2]=='POST' else endpoint.split('/')[-1] + body=json.load(sys.stdin)['body'] + (store/('repair-'+issue+'.json')).write_text(json.dumps([{'id':int(issue),'body':body,'user':{'login':'fixture'},'created_at':'2026-10-20T00:00:00Z','updated_at':'2026-10-20T00:00:00Z'}])) +elif args[:2] in (['label','create'],['issue','edit']): pass +elif 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_REPAIR_FIXTURE_DIR="$T" export SB_CODEX_TEST_LOG="$T/calls.jsonl" export PATH="$T/bin:$PATH" cp "$KIT/tests/fixtures/fake_codex.py" "$T/bin/codex" @@ -182,10 +194,29 @@ jq -s -e 'all(.active == 1)' "$SB_CODEX_TEST_LOG" >/dev/null || fail 'max_worker : > "$SB_CODEX_TEST_LOG" SB_CODEX_TEST_STATUS=bounced wave --codex >"$T/out" jq -e '.cards[0].lanesRun == "build:bounced"' "$T/out" >/dev/null || fail 'non-advanced ends card chain' +python3 - "$T/repair-1.json" <<'PYREPAIR' +import json,sys +body=json.load(open(sys.argv[1]))[0]['body'] +state=json.loads(next(line.removeprefix('repair-state: ') for line in body.splitlines() if line.startswith('repair-state: '))) +assert state['repairs']==0 and state['phase']=='idle', state +PYREPAIR : > "$SB_CODEX_TEST_LOG" RC=0; SB_CODEX_TEST_STATUS=halted wave --codex >"$T/out" || RC=$? [ "$RC" -eq 79 ] && [ "$(wc -l < "$SB_CODEX_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' +cp .claude/super-board/inflight/1 "$T/claim-before" +python3 - "$T/repair-1.json" <<'PYREPAIR' +import json,sys +body=json.load(open(sys.argv[1]))[0]['body'] +state=json.loads(next(line.removeprefix('repair-state: ') for line in body.splitlines() if line.startswith('repair-state: '))) +assert state['repairs']==1 and state['phase']=='reserved', state +PYREPAIR +: > "$SB_CODEX_TEST_LOG" +RC=0; wave --codex >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && [ ! -s "$SB_CODEX_TEST_LOG" ] || fail 'unreconciled claim prevents redispatch' +cmp -s .claude/super-board/inflight/1 "$T/claim-before" || fail 'preserved claim was overwritten' +rm -f "$T"/repair-*.json +rm .claude/super-board/inflight/1 # explicit fixture recovery after process reconciliation # A readiness halt returned by a running lane stops dispatch and preserves its owned lock. : > "$SB_CODEX_TEST_LOG" @@ -194,6 +225,7 @@ RC=0; SB_CODEX_TEST_EXIT=9 wave --codex >"$T/out" || RC=$? 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/codex-wave.pid ] || fail 'ended wave releases only its PID marker' +rm -f "$T"/repair-*.json rm .claude/super-board/inflight/1 # fixture recovery before unrelated concurrency tests # A normal Codex completion can also report gate exit 9 in its structured result. @@ -218,8 +250,9 @@ PY # reaps the already-created launcher rather than losing the whole wave summary. mkdir .claude/super-board/inflight/1 CARDS='[{"number":1,"status":"Review"}]' -wave --codex >"$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' +RC=0; wave --codex >"$T/out" || RC=$? +[ "$RC" -eq 79 ] || fail "existing claim halts dispatch" +jq -e '.cards[0].finalStatus == "halted" 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 Codex process group and its descendants before lock cleanup. @@ -234,5 +267,5 @@ 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/codex-wave.pid ] && [ -z "$(ls -A .claude/super-board/inflight)" ] || fail 'interrupted wave leaves no live locks' +[ ! -e .claude/super-board/codex-wave.pid ] && [ -f .claude/super-board/inflight/1 ] || fail 'interrupted wave preserves recovery claim and releases PID marker' echo 'PASS: test-codex-lane.sh (model/ladders/router/review/sandbox/results/wave/halt/cancellation)' diff --git a/tests/test-codex-wave-contract.sh b/tests/test-codex-wave-contract.sh index dbcce5af..ca2d8f91 100644 --- a/tests/test-codex-wave-contract.sh +++ b/tests/test-codex-wave-contract.sh @@ -12,15 +12,27 @@ python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/repo" export SUPER_BOARD_SKILLS_HOME="$T/home" cat > "$T/bin/gh" <<'PYGH' #!/usr/bin/env python3 -import json,sys +import json,sys,os,re +from pathlib import Path args=sys.argv[1:] -if args[:2]==['issue','view']: +store=Path(os.environ['SB_REPAIR_FIXTURE_DIR']) +if args and args[0]=='api' and '--slurp' in args: + issue=re.search(r'/issues/(\d+)/comments', ' '.join(args)).group(1) + file=store/('repair-'+issue+'.json') + print('['+(file.read_text() if file.exists() else '[]')+']') +elif args[:2]==['api','-X']: + endpoint=args[3]; issue=endpoint.split('/')[-2] if args[2]=='POST' else endpoint.split('/')[-1] + body=json.load(sys.stdin)['body'] + (store/('repair-'+issue+'.json')).write_text(json.dumps([{'id':int(issue),'body':body,'user':{'login':'fixture'},'created_at':'2026-10-20T00:00:00Z','updated_at':'2026-10-20T00:00:00Z'}])) +elif args[:2] in (['label','create'],['issue','edit']): pass +elif 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 cp "$KIT/tests/fixtures/fake_codex.py" "$T/bin/codex" chmod +x "$T/bin/gh" "$T/bin/codex" +export SB_REPAIR_FIXTURE_DIR="$T" export SB_CODEX_TEST_LOG="$T/calls.jsonl" export PATH="$T/bin:$PATH" CARDS='[{"number":1,"status":"Ready","title":"one"},{"number":2,"status":"Review"}]' diff --git a/tests/test-host.sh b/tests/test-host.sh index fb323715..b2dc16ee 100644 --- a/tests/test-host.sh +++ b/tests/test-host.sh @@ -54,4 +54,7 @@ for f in none missing; do done rc=0; cfg "$T/bad.json" >/dev/null 2>&1 || rc=$? [ "$rc" = 64 ] || fail "a bad config host exits 64, got $rc" +[ "$(host SUPER_BOARD_HOST=pi CODEX_THREAD_ID=parent)" = pi ] || fail "explicit Pi beats inherited Codex" +printf '{"host":"pi"}' > "$T/pi.json" +[ "$(cfg "$T/pi.json")" = pi ] || fail "config Pi host" echo "PASS: super-board-host" diff --git a/tests/test-install.sh b/tests/test-install.sh index 3b91e452..3d649ecc 100755 --- a/tests/test-install.sh +++ b/tests/test-install.sh @@ -407,6 +407,16 @@ done echo "$OUT" | grep -q "copied" || fail "no-symlink install should say it copied, got: $OUT" rm -rf "$T" "$FAKE" +# Pi host links the one-shot launcher and mandatory guard adapter from shared storage. +T=$(mktemp -d) +"$INSTALL" --no-hooks --hosts pi "$T" >/dev/null 2>&1 || fail "Pi install failed" +resolves "$T" pi +for script in super-board-pi-lane.sh super-board-pi-guards.py super-board-pi-guards.ts super-board-lane-result.py; do + [ -f "$T/.pi/bin/$script" ] || fail "Pi missing $script" + cmp -s "$T/.pi/bin/$script" "$T/.agents/bin/$script" || fail "Pi shared file differs: $script" +done +rm -rf "$T" + # 9 — the snippet the installer merges is the one hooks/README.md documents. python3 - ../hooks/README.md ../hooks/settings-snippet.json <<'PY' || fail "hooks/README.md snippet and hooks/settings-snippet.json differ" import json, re, sys @@ -414,4 +424,4 @@ doc = re.search(r"```json\n(.*?)```", open(sys.argv[1]).read(), re.S).group(1) sys.exit(0 if json.loads(doc) == json.load(open(sys.argv[2])) else 1) PY -echo "PASS: test-install.sh (24 scenarios)" +echo "PASS: test-install.sh (25 scenarios)" diff --git a/tests/test-merge-gate.sh b/tests/test-merge-gate.sh index aa1a60f4..e8cef544 100755 --- a/tests/test-merge-gate.sh +++ b/tests/test-merge-gate.sh @@ -407,12 +407,17 @@ grep -q '^pr merge' "$GH_LOG" && fail "revoked human approval must not reach mer unset STUB_APPROVAL STUB_COMMENTS teardown -# 17 — custom categories replace the defaults: with always_human = {} a money -# label merges. +# 17 — custom categories extend mandatory defaults, including same-category rules. head_setup cfgset '.merge_policy = {always_human: {}}' RC=0; STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 0 ] || fail "always_human {} should let a money label merge, got $RC" +[ "$RC" -eq 7 ] || fail "always_human {} must preserve the money approval rule, got $RC" +cfgset '.merge_policy = {always_human: {auth: {labels: ["project-private"], paths: ["custom/private/**"]}, custom: {labels: ["legal-review"]}}}' +for spec in 'money src/x.ts' 'auth src/x.ts' 'schema src/x.ts' 'permissions src/x.ts' 'external-sharing src/x.ts' 'project-private src/x.ts' 'legal-review src/x.ts' 'none custom/private/x.ts' 'none src/auth/x.ts'; do + read -r label path <<< "$spec" + RC=0; STUB_OID="$SHA" STUB_META="$(meta "$label" "$path")" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? + [ "$RC" -eq 7 ] || fail "mixed custom/default approval rule must hold for $spec, got $RC" +done teardown # 18 — a migration whose target env is allowed: the gate runs the configured diff --git a/tests/test-pi-lane.sh b/tests/test-pi-lane.sh new file mode 100755 index 00000000..9d268b10 --- /dev/null +++ b/tests/test-pi-lane.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Offline print-mode boundary tests. Real CLI smoke/pilot is recorded separately. +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" +bash "$KIT/install.sh" --hosts claude,pi "$T/repo" > "$T/install.log" +python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/repo" +export HOME="$T/home" +export SUPER_BOARD_SKILLS_HOME="$T/home"; mkdir -p "$SUPER_BOARD_SKILLS_HOME" +cat > "$T/bin/gh" <<'PY' +#!/usr/bin/env python3 +import json,sys +args=sys.argv[1:] +if args and args[0]=='api' and '--slurp' in args: + print('[[]]') +elif 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 call '+str(args)) +PY +cat > "$T/bin/pi" <<'PY' +#!/usr/bin/env python3 +import json,os,re,sys,time,subprocess +from pathlib import Path +args=sys.argv[1:]; prompt=sys.stdin.read() +with open(os.environ['SB_PI_LOG'],'a') as log: log.write(json.dumps({'args':args,'prompt':prompt,'cwd':os.getcwd()})+'\n') +if os.environ.get('SB_PI_SLEEP'): + p=subprocess.Popen([sys.executable,'-c','import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)']); Path(os.environ['SB_PI_PID']).write_text(str(p.pid)); time.sleep(60) +card=json.loads(re.search(r'^Card: (.+)$',prompt,re.M)[1]); lane=re.search(r'Run super-(build|qa|review)',prompt)[1] +print(os.environ.get('SB_PI_RESULT',json.dumps(dict(status='advanced',column={'build':'QA','qa':'Review','review':'Done'}[lane],detail='fixture',prUrl=None,branch=None,priorFindings=None)))) +sys.exit(int(os.environ.get('SB_PI_EXIT','0'))) +PY +chmod +x "$T/bin/gh" "$T/bin/pi" +export PATH="$T/bin:$PATH" SB_PI_LOG="$T/pi.jsonl" +cd "$T/repo" +printf '%s' '{"verify_commands":["git diff --check"],"pi":{"provider":"fixture-provider","model":"fixture-model"}}' > "$T/config.json" +CARD='{"number":1,"status":"Ready","labels":["docs"],"body":""}' +lane() { bash "$KIT/scripts/super-board-pi-lane.sh" --config "$T/config.json" --card "$CARD" --lane build --pi > "$T/out" 2> "$T/err"; } +lane || { cat "$T/out" "$T/err"; exit 1; } +jq -e '.status=="advanced" and .column=="QA"' "$T/out" >/dev/null +python3 - "$SB_PI_LOG" <<'PY' +import json,sys +c=json.loads(open(sys.argv[1]).readlines()[-1]); a=c['args'] +for flag in ['--print','--no-extensions','--no-skills','--no-mcp','--extension','--skill']: assert flag in a,a +assert a[a.index('--mode')+1]=='text' +assert a[a.index('--provider')+1]=='fixture-provider' +assert a[a.index('--model')+1]=='fixture-model' +assert '--continue' not in a and '--resume' not in a and '--session' not in a +assert '--expect-head' in c['prompt'] or 'Builder' in c['prompt'] +PY +for BAD in '' 'not JSON' '{"status":' '{"status":"advanced","column":"QA","detail":"fake"}' '[]'; do + RC=0; SB_PI_RESULT="$BAD" lane || RC=$? + [ "$RC" -eq 1 ]; jq -e '.status=="failed"' "$T/out" >/dev/null +done +RC=0; SB_PI_EXIT=1 lane || RC=$? +[ "$RC" -eq 1 ]; jq -e '.status=="failed"' "$T/out" >/dev/null +# Print may return valid-looking text and still report truncation/provider failure. +RC=0; SB_PI_EXIT=2 lane || RC=$? +[ "$RC" -eq 1 ] +# Missing resources block before model execution. +N=$(wc -l < "$SB_PI_LOG") +rm -f .agents/skills/super-build/SKILL.md .claude/skills/super-build/SKILL.md +RC=0; lane || RC=$? +[ "$RC" -eq 10 ]; [ "$(wc -l < "$SB_PI_LOG")" -eq "$N" ] +python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/repo" +rm .agents/hooks/guard-secrets.py +RC=0; lane || RC=$? +[ "$RC" -eq 78 ]; [ "$(wc -l < "$SB_PI_LOG")" -eq "$N" ] +cp "$KIT/hooks/guard-secrets.py" .agents/hooks/ +# The selected Pi path uses the existing wave contract and fresh processes per lane. +bash "$KIT/scripts/super-board-codex-wave.sh" --config "$T/config.json" --cards "[$CARD]" --pi > "$T/out" +jq -e '.halted==false and .cards[0].lanesRun=="build:advanced → qa:advanced → review:advanced"' "$T/out" >/dev/null +# Direct lane interruption reaps Pi descendants and preserves owned work. +SB_PI_SLEEP=1 SB_PI_PID="$T/child.pid" lane & JOB=$! +for _ in {1..100}; do [ -s "$T/child.pid" ] && break; sleep 0.05; done +[ -s "$T/child.pid" ] +# JOB is the shell function, its child is the exec'd Python launcher. +LAUNCHER=$(pgrep -P "$JOB" | head -1) +kill -TERM "$LAUNCHER" +RC=0; wait "$JOB" || RC=$? +[ "$RC" -ne 0 ] +! kill -0 "$(cat "$T/child.pid")" 2>/dev/null +jq -e '.status=="halted"' "$T/out" >/dev/null +echo 'PASS: test-pi-lane.sh (print results/resources/wave/interruption)' diff --git a/tests/test-setup.sh b/tests/test-setup.sh index 1aa485f3..8439a3bb 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -91,8 +91,8 @@ for t in git apt-get dnf; do p=$(command -v "$t" || true); [ -n "$p" ] && ln -sf OUT=$(PATH="$NOBIN" "$(command -v python3)" "$SETUP" check --root "$T" || true) echo "$OUT" | q '.needs | map(.name) | index("gh")' || fail "missing gh should be in needs: $OUT" case "$(uname)" in Darwin) echo "$OUT" | q '.needs[] | select(.name == "gh") | .command == "brew install gh"' || fail "mac command should be brew: $OUT" ;; esac -echo "$OUT" | q '.needs[] | select(.name == "node") | .command | test("skills@latest add mattpocock/skills")' \ - || fail "the node command should also add Matt Pocock's skills: $OUT" +echo "$OUT" | q '.needs[] | select(.name == "node") | .command | test("super-board-setup.py fix.*--host claude")' \ + || fail "the node command should retry host-specific helper repair: $OUT" # 5 — board names from package.json / README; an empty folder falls back to its name. T="$WORK/ledgerly"; mkdir -p "$T" @@ -320,4 +320,11 @@ python3 "$SETUP" fix --root "$WORK/bare" --no-helpers >/dev/null || true q '.host == "gemini"' "$H/b.json" || fail "fix must not guess over a set host: $(cat "$H/b.json")" rm "$H/b.json" -echo "PASS: test-setup.sh (13 scenarios)" +# 14 — Pi never receives a default provider/model from setup. +printf '{"host":"pi"}' > "$H/pi.json" +OUT=$(python3 "$SETUP" check --root "$WORK/bare" --host pi || true) +echo "$OUT" | q '.host_errors | to_entries | any(.value | contains("pi.provider and pi.model"))' || fail "Pi missing explicit model must be flagged" +printf '{"host":"pi","pi":{"provider":"fixture","model":"fixture"}}' > "$H/pi.json" +OUT=$(python3 "$SETUP" check --root "$WORK/bare" --host pi || true) +echo "$OUT" | q '.host_errors == {}' || fail "explicit Pi model must be accepted: $OUT" +echo "PASS: test-setup.sh (14 scenarios)" diff --git a/tests/test_checkout_identity.py b/tests/test_checkout_identity.py new file mode 100644 index 00000000..16b8df6b --- /dev/null +++ b/tests/test_checkout_identity.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Offline checkout identity regression at source and installed CLI boundaries.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +KIT = Path(__file__).resolve().parents[1] +CARD = {'number': 4, 'status': 'Ready', 'title': 'checkout identity'} + + +class CheckoutIdentityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + cls.base = Path(cls.temp.name).resolve() + cls.a, cls.b, cls.linked = [cls.base / name for name in ('a', 'b', 'linked')] + cls.bin = cls.base / 'bin' + cls.bin.mkdir() + cls.calls = cls.base / 'calls' + cls.config = cls.base / 'config.json' + cls.env = dict(os.environ, HOME=str(cls.base / 'home'), + SUPER_BOARD_SKILLS_HOME=str(cls.base / 'home'), + PATH=str(cls.bin) + os.pathsep + os.environ['PATH'], + SB_CHECKOUT_CALLS=str(cls.calls)) + cls.env.pop('SUPER_BOARD_SKILLS_CATALOG', None) + for repo in (cls.a, cls.b): + cls.run_cmd(['git', 'init', '-q', str(repo)]) + (repo / 'tracked.txt').write_text('preserve this checkout\n') + cls.run_cmd(['git', '-C', str(repo), 'add', 'tracked.txt']) + cls.run_cmd(['git', '-C', str(repo), '-c', 'user.name=Fixture', '-c', + 'user.email=fixture@example.test', 'commit', '-qm', 'fixture']) + cls.run_cmd(['git', '-C', str(cls.b), 'worktree', 'add', '-qb', 'linked', str(cls.linked)]) + for repo in (cls.b, cls.linked): + cls.run_cmd(['bash', str(KIT / 'install.sh'), '--hosts', 'claude,codex,pi', str(repo)]) + cls.run_cmd(['python3', str(KIT / 'tests/fixtures/install_worker_skills.py'), str(repo)]) + cls.alias = cls.base / 'alias' + cls.alias.symlink_to(cls.b, target_is_directory=True) + (cls.b / 'subdir').mkdir() + for name in ('gh', 'pi', 'codex'): + path = cls.bin / name + path.write_text('''#!/usr/bin/env python3 +import json, os, re, sys +from pathlib import Path +name = Path(sys.argv[0]).name +with open(os.environ['SB_CHECKOUT_CALLS'], 'a') as log: + log.write(json.dumps({'name': name, 'cwd': os.getcwd(), 'args': sys.argv[1:]}) + '\\n') +if name == 'gh': + if sys.argv[1:3] == ['issue', 'view']: + print(json.dumps({'labels': ['docs']} if sys.argv[-1] == 'labels' else + {'number': 4, 'title': 'fixture', 'body': ''})) + elif '--slurp' in sys.argv: + print('[[]]') + else: + sys.exit('unexpected GitHub call') +else: + prompt = sys.stdin.read() + lane = re.search(r'Run super-(build|qa|review)', prompt)[1] + result = json.dumps(dict(status='advanced', column={'build':'QA', 'qa':'Review', 'review':'Done'}[lane], + detail='fixture', prUrl=None, branch=None, priorFindings=None)) + if name == 'codex': + Path(sys.argv[sys.argv.index('--output-last-message') + 1]).write_text(result) + else: + print(result) +''') + path.chmod(0o755) + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + @classmethod + def run_cmd(cls, command, **kwargs): + return subprocess.run(command, env=cls.env, text=True, capture_output=True, + check=True, **kwargs) + + def snapshot(self): + result = {} + for repo in (self.a, self.b, self.linked): + result[str(repo)] = { + str(path.relative_to(repo)): ('link', os.readlink(path)) if path.is_symlink() + else ('file', hashlib.sha256(path.read_bytes()).hexdigest()) if path.is_file() + else ('dir', '') + for path in repo.rglob('*') if '.git' not in path.relative_to(repo).parts + } + result[str(repo) + ':git'] = self.run_cmd( + ['git', '-C', str(repo), 'status', '--porcelain', '--untracked-files=all']).stdout + return result + + def invoke(self, host, wave, installed, configured, cwd=None, verify=True): + config = {'verify_commands': ['git diff --check'] if verify else [], + 'pi': {'provider': 'fixture', 'model': 'fixture'}, + 'codex': {'model': 'fixture'}, 'repo': {'path': configured}} + if configured == 'OMITTED': + config.pop('repo') + self.config.write_text(json.dumps(config)) + scripts = self.b / '.claude/bin' if installed else KIT / 'scripts' + script = 'super-board-codex-wave.sh' if wave else f'super-board-{host}-lane.sh' + args = ['--cards', json.dumps([CARD])] if wave else ['--card', json.dumps(CARD), '--lane', 'build'] + if wave: + args += ['--output', str(self.b / 'wave-result.json')] + self.calls.unlink(missing_ok=True) + return subprocess.run(['bash', str(scripts / script), '--config', str(self.config), + *args, '--' + host], cwd=cwd or self.b, env=self.env, + text=True, capture_output=True, timeout=30) + + def boundaries(self): + for host in ('pi', 'codex'): + for wave in (False, True): + for installed in (False, True): + yield host, wave, installed + + def test_mismatches_refuse_before_any_startup_work(self): + for boundary in self.boundaries(): + for actual, configured in ((self.b, self.a), (self.linked, self.b)): + for verify in (True, False): + with self.subTest(boundary=boundary, actual=actual.name, verify=verify): + before = self.snapshot() + result = self.invoke(*boundary, str(configured), cwd=actual, verify=verify) + diagnostic = result.stderr + result.stdout + self.assertNotEqual(result.returncode, 0, diagnostic) + self.assertIn(str(actual), diagnostic) + self.assertIn(str(configured), diagnostic) + self.assertNotIn('Traceback', diagnostic) + self.assertNotIn('verification-readiness:', diagnostic) + self.assertFalse(self.calls.exists(), self.calls.read_text() if self.calls.exists() else '') + self.assertEqual(before, self.snapshot()) + + def test_valid_paths_reach_workers(self): + cases = [(str(self.b), self.b), (str(self.alias), self.b), (str(self.b), self.b / 'subdir'), + ('../b', self.b / 'subdir'), ('OMITTED', self.b), (None, self.b), + (str(self.linked), self.linked)] + for boundary in self.boundaries(): + for configured, cwd in cases: + with self.subTest(boundary=boundary, configured=configured, cwd=cwd.name): + result = self.invoke(*boundary, configured, cwd=cwd) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + payload = json.loads(result.stdout) + if boundary[1]: + self.assertFalse(payload['halted']) + self.assertEqual(payload['cards'][0]['column'], 'Done') + else: + self.assertEqual(payload['status'], 'advanced') + self.assertEqual(payload['column'], 'QA') + calls = [json.loads(line) for line in self.calls.read_text().splitlines()] + self.assertTrue(any(call['name'] == 'gh' for call in calls)) + workers = [call for call in calls if call['name'] == boundary[0]] + self.assertEqual(len(workers), 3 if boundary[1] else 1) + self.assertTrue(all(call['cwd'] == str(cwd if cwd == self.linked else self.b) for call in workers)) + (self.b / 'wave-result.json').unlink(missing_ok=True) + + def test_invalid_explicit_paths_fail_cleanly(self): + for boundary in self.boundaries(): + for configured in ('', ' ', 7, [], {}, 'bad\x00path'): + with self.subTest(boundary=boundary, configured=configured): + before = self.snapshot() + result = self.invoke(*boundary, configured) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + self.assertFalse(self.calls.exists()) + self.assertEqual(before, self.snapshot()) + + def test_setup_repairs_a_missing_installed_guard(self): + helper = self.b / '.agents/bin/super-board-checkout.py' + self.assertTrue(helper.is_file(), 'installer must ship the checkout guard') + helper.unlink() + command = ['python3', str(KIT / 'scripts/super-board-setup.py'), 'check', + '--root', str(self.b), '--pack', str(KIT), '--host', 'pi'] + checked = subprocess.run(command, env=self.env, text=True, capture_output=True) + self.assertIn('script super-board-checkout.py', checked.stdout) + fixed = self.run_cmd([*command[:2], 'fix', *command[3:], '--no-helpers']) + self.assertTrue(helper.is_file(), fixed.stdout + fixed.stderr) + result = self.invoke('pi', False, True, str(self.a)) + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(self.a), result.stderr) + self.assertFalse(self.calls.exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_codex_lane_schema.py b/tests/test_codex_lane_schema.py index 101a5f44..52e587f6 100644 --- a/tests/test_codex_lane_schema.py +++ b/tests/test_codex_lane_schema.py @@ -4,7 +4,6 @@ Two hosts, one lane-result contract: the status enum and field names must match. Offline; reads both sources, runs nothing but node on the extracted object literal. """ -import ast import json from pathlib import Path import re @@ -14,14 +13,8 @@ def lane_schema(): - text = (KIT / 'scripts/super-board-codex-lane.sh').read_text() - source = text.split("<<'PY'\n", 1)[1].rsplit('\nPY', 1)[0] - wanted = {'counts', 'properties', 'schema'} - nodes = [node for node in ast.parse(source).body if isinstance(node, ast.Assign) - and any(isinstance(t, ast.Name) and t.id in wanted for t in node.targets)] - namespace = {} - exec(compile(ast.Module(body=nodes, type_ignores=[]), 'codex-lane', 'exec'), namespace) - return namespace['schema'] + import runpy + return runpy.run_path(str(KIT / 'scripts/super-board-lane-result.py'))['schema'] def stage_schema(): diff --git a/tests/test_github_reads.py b/tests/test_github_reads.py index 3940bea8..965e5678 100644 --- a/tests/test_github_reads.py +++ b/tests/test_github_reads.py @@ -32,7 +32,7 @@ def setUp(self): gh.chmod(0o755) self.env = dict(os.environ, PATH=f'{self.root}:{os.environ["PATH"]}', READ_CALLS=str(self.log), SB_GITHUB_HALT_FILE=str(self.halt), - SB_GITHUB_RETRY_DELAY='0') + SB_GITHUB_RETRY_DELAY='0', SB_GITHUB_READ_STRICT='') def tearDown(self): self.temp.cleanup() @@ -60,6 +60,39 @@ def test_recovery_resets_each_logical_read(self): self.assertEqual(self.log.read_text(), '5') self.assertFalse(self.halt.exists()) + def test_incompatible_slurp_formatting_fails_before_gh_even_in_strict_mode(self): + for kind in ['json', 'array']: + for strict in ['', '1']: + self.env['SB_GITHUB_READ_STRICT'] = strict + for formatting in [['--jq', '.[]'], ['-q', '.[]'], ['--jq=.[]'], + ['--template', '{{.}}'], ['-t', '{{.}}'], ['--template={{.}}']]: + with self.subTest(kind=kind, strict=strict, formatting=formatting): + result = self.read([{'rc': 1, 'err': 'the --slurp option is not supported with --jq or --template'}], + kind, ['api', 'repos/acme/app/issues', '--paginate', '--slurp', *formatting]) + self.assertEqual(result.returncode, 64, result.stderr) + self.assertIn('fetch paginated raw JSON', result.stderr) + self.assertIn('jq locally', result.stderr) + self.assertEqual(result.stdout, '') + self.assertFalse(self.log.exists(), 'invalid CLI arguments must not invoke gh') + self.assertFalse(self.halt.exists(), 'invalid CLI arguments must not halt other lanes') + + def test_existing_halt_precedes_incompatible_arguments(self): + original = '{"reason":"saved network failure","attempts":3}' + self.halt.write_text(original) + result = self.read([{'out': '[]'}], 'array', + ['api', 'repos/acme/app/issues', '--paginate', '--slurp', '--jq', '.[]']) + self.assertEqual(result.returncode, 79, result.stderr) + self.assertEqual(self.halt.read_text(), original) + self.assertFalse(self.log.exists()) + + def test_corrected_paginated_raw_read_remains_valid(self): + result = self.read([{'out': '[[]]'}], 'array', + ['api', 'repos/acme/app/issues', '--paginate', '--slurp']) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, '[[]]') + self.assertEqual(self.log.read_text(), '1') + self.assertFalse(self.halt.exists()) + def test_permanent_graphql_error_is_not_retried(self): r = self.read([{'rc': 1, 'err': 'GraphQL: Cannot query field wrong on type Query'}], command=['api', 'graphql', '-f', 'query=query { wrong }']) diff --git a/tests/test_merge_policy.py b/tests/test_merge_policy.py new file mode 100644 index 00000000..3185e32c --- /dev/null +++ b/tests/test_merge_policy.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Approval routing must retain mandatory and stricter project rules.""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +POLICY = Path(__file__).resolve().parents[1] / 'scripts/super-board-merge-policy.py' + + +class MergePolicyTests(unittest.TestCase): + def categories(self, rules, *, label='', path='src/index.ts', added=''): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'config').write_text(json.dumps({'merge_policy': {'always_human': rules}})) + (root / 'meta').write_text(json.dumps({'files': [{'path': path}], 'labels': [{'name': label}]})) + (root / 'diff').write_text('+' + added + '\n') + result = subprocess.run(['python3', str(POLICY), '--config', str(root / 'config'), + '--meta', str(root / 'meta'), '--diff', str(root / 'diff')], + check=True, capture_output=True, text=True) + return {hit['category'] for hit in json.loads(result.stdout)['human']} + + def test_new_category_does_not_replace_existing_restrictions(self): + rules = {'external-sharing': {'paths': ['custom/export/**']}, + 'legal': {'labels': ['legal-review']}} + for label, category in [('money', 'money'), ('auth', 'auth'), ('permissions', 'auth'), + ('schema', 'schema'), ('data-sharing', 'external-sharing'), + ('legal-review', 'legal')]: + with self.subTest(label=label): + self.assertIn(category, self.categories(rules, label=label)) + self.assertIn('external-sharing', self.categories(rules, path='custom/export/data.py')) + + def test_same_category_custom_rules_cannot_weaken_defaults(self): + rules = {'auth': {'labels': ['custom-auth'], 'paths': ['private/**'], + 'keywords': ['custom_guard(']}, 'schema': {}} + for kwargs in [{'label': 'auth'}, {'label': 'custom-auth'}, + {'path': 'src/permissions/check.py'}, {'path': 'private/check.py'}, + {'added': 'jwt.sign(token)'}, {'added': 'custom_guard(user)'}]: + with self.subTest(kwargs=kwargs): + self.assertIn('auth', self.categories(rules, **kwargs)) + self.assertIn('schema', self.categories(rules, added='DROP TABLE users;')) + + def test_empty_map_retains_required_categories(self): + self.assertIn('money', self.categories({}, path='src/billing/charge.py')) + self.assertIn('external-sharing', self.categories({}, added='navigator.sendBeacon(url, data)')) + + def test_routine_change_stays_eligible(self): + self.assertEqual(set(), self.categories({}, path='src/format.py', added='return value.strip()')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pi_guards.py b/tests/test_pi_guards.py new file mode 100644 index 00000000..a5768564 --- /dev/null +++ b/tests/test_pi_guards.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Exercise the real installed guard scripts through Pi input translation.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +KIT = Path(__file__).resolve().parents[1] + + +class PiGuards(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / 'repo' + self.root.mkdir() + subprocess.run(['git', 'init', '-q', str(self.root)], check=True) + shutil.copytree(KIT / 'hooks', self.root / '.claude/hooks') + self.env = dict(os.environ, HOME=self.temp.name, SUPER_BOARD_PI_ROOT=str(self.root)) + + def guard(self, name, values, event='PreToolUse'): + p = subprocess.run(['python3', str(KIT / 'scripts/super-board-pi-guards.py')], + env=self.env, input=json.dumps(dict(toolName=name, input=values, hookEventName=event)), + capture_output=True, text=True) + return json.loads(p.stdout) + + def test_real_guards_block_secret_read_outside_delete_and_worktree(self): + for name, values in [('read', {'path':'.env'}), ('bash', {'command':'rm /outside/data'}), + ('bash', {'command':'git worktree add ../outside -b topic'})]: + with self.subTest(name=name, values=values): + self.assertTrue(self.guard(name, values)['block']) + self.assertEqual(self.guard('read', {'path':'README.md'}), {}) + self.assertEqual(self.guard('bash', {'command':'git diff --check'}), {}) + + def test_key_literal_mapping(self): + value = 'sk-' + 'a' * 30 + self.assertTrue(self.guard('write', {'path':'a.py','content':value})['block']) + self.assertTrue(self.guard('edit', {'path':'a.py','oldText':'x','newText':value})['block']) + (self.root / 'a.py').write_text(value) + self.assertTrue(self.guard('write', {'path':'a.py','content':'ignored'}, 'PostToolUse')['block']) + + def test_custom_project_hooks_preserved_and_unsupported_fail_closed(self): + settings = self.root / '.claude/settings.json' + settings.write_text(json.dumps({'hooks':{'PreToolUse':[{'matcher':'*','hooks':[{ + 'type':'command','command':"python3 -c 'import sys;sys.exit(2)'"}]}]}})) + self.assertTrue(self.guard('read', {'path':'README.md'})['block']) + settings.write_text(json.dumps({'hooks':{'PreToolUse':[{'matcher':'Read','hooks':[{'type':'prompt','prompt':'approval'}]}]}})) + self.assertTrue(self.guard('read', {'path':'README.md'})['block']) + + def test_project_ask_requires_human_instead_of_allowing(self): + hook = self.root / 'ask.py' + hook.write_text('import json; print(json.dumps({"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"owner approval required"}}))') + (self.root / '.claude/settings.json').write_text(json.dumps({'hooks':{'PreToolUse':[ + {'matcher':'Read','hooks':[{'type':'command','command':f'python3 {hook}'}]}]}})) + self.assertTrue(self.guard('read', {'path':'README.md'})['block']) + + @unittest.skipUnless(shutil.which('pi'), 'real Pi CLI unavailable') + def test_real_cli_loads_adapter_and_rejects_broken_extension_before_tools(self): + # Exit on session_start, without configured credentials or a model request. + probe = Path(self.temp.name) / 'inspect.ts' + output = Path(self.temp.name) / 'tools.json' + probe.write_text('import { writeFileSync } from "node:fs"; export default function(pi) {' + 'pi.on("session_start", () => {' + 'writeFileSync(process.env.PROBE_RESULT, JSON.stringify(pi.getActiveTools()));' + 'process.exit(0); }); }') + env = dict(PATH=os.environ['PATH'], HOME=self.temp.name, + PI_CODING_AGENT_DIR=str(Path(self.temp.name) / 'pi-agent'), PROBE_RESULT=str(output), + SUPER_BOARD_PI_ROOT=str(self.root), SUPER_BOARD_PI_PYTHON=shutil.which('python3'), + SUPER_BOARD_PI_GUARDS=str(KIT / 'scripts/super-board-pi-guards.py')) + loader = Path(shutil.which('pi')).resolve().parent / 'core/extensions/loader.js' + if loader.is_file() and shutil.which('node'): + # Exercise the raw .ts module through Pi's installed TypeScript loader. + source = ("const {loadExtensions}=await import(process.argv[1]);" + "const r=await loadExtensions([process.argv[2]],process.cwd());" + "if(r.errors.length)throw new Error(JSON.stringify(r.errors));" + "const h=r.extensions[0].handlers;" + "if(!h.has('tool_call')||!h.has('tool_result'))throw new Error('missing guards');" + "const x=await h.get('tool_call')[0]({toolName:'read',input:{path:'.env'}});" + "if(!x.block)throw new Error('raw adapter did not block secret read');") + raw = subprocess.run(['node', '--input-type=module', '-e', source, loader.as_uri(), + str(KIT / 'scripts/super-board-pi-guards.ts')], + cwd=self.root, env=env, text=True, capture_output=True, timeout=30) + self.assertEqual(raw.returncode, 0, raw.stderr) + command = ['pi', '--print', '--no-session', '--no-extensions', '--no-skills', + '--no-prompt-templates', '--no-themes', '--no-mcp', + '--extension', str(KIT / 'scripts/super-board-pi-guards.ts'), + '--extension', str(probe), '--provider', 'openai', '--model', 'gpt-4o', + '--tools', 'read,bash,edit,write'] + loaded = subprocess.run(command, cwd=self.root, env=env, input='offline startup probe', + text=True, capture_output=True, timeout=30) + self.assertEqual(loaded.returncode, 0, loaded.stderr) + self.assertEqual(set(json.loads(output.read_text())), {'read', 'bash', 'edit', 'write'}) + output.unlink() + broken = Path(self.temp.name) / 'broken.ts' + broken.write_text('throw new Error("offline-guard-load-failed"); export default function(pi) {}') + failed = subprocess.run(command + ['--extension', str(broken)], cwd=self.root, env=env, + input='offline startup probe', text=True, capture_output=True, timeout=30) + self.assertNotEqual(failed.returncode, 0) + self.assertIn('offline-guard-load-failed', failed.stderr) + self.assertFalse(output.exists(), 'a failed extension must stop before session tools become available') + + def test_explicit_claude_permission_restrictions_cannot_be_silently_bypassed(self): + settings = self.root / '.claude/settings.json' + for field in ['deny', 'ask']: + with self.subTest(field=field): + settings.write_text(json.dumps({'permissions': {field: ['Read(private/**)']}})) + result = self.guard('read', {'path':'README.md'}) + self.assertTrue(result['block']) + self.assertIn('permissions.deny/ask', result['reason']) + + def test_missing_guard_fails_closed(self): + (self.root / '.claude/hooks/guard-secrets.py').unlink() + self.assertTrue(self.guard('read', {'path':'README.md'})['block']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_repair_budget.py b/tests/test_repair_budget.py new file mode 100644 index 00000000..5a59793d --- /dev/null +++ b/tests/test_repair_budget.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location('repair', Path(__file__).resolve().parents[1] / 'scripts/super-board-repair.py') +repair = importlib.util.module_from_spec(spec) +spec.loader.exec_module(repair) + + +class RepairBudgetTests(unittest.TestCase): + def exhausted_normal(self): + s = {'phase': 'idle', 'repairs': 0, 'cause': 'original'} + for i in range(2): + s = repair.transition(s, 'reserve', 'owner') + self.assertEqual(s['repairs'], i + 1) + with self.assertRaises(ValueError): + repair.transition(s, 'reserve', 'duplicate') + s = repair.transition(s, 'failed', 'owner', cause=f'moved hash {i}') + return s + + def test_no_new_cause_blocks(self): + s = repair.transition(self.exhausted_normal(), 'reserve', 'diagnoser') + self.assertEqual(s['phase'], 'diagnosis') + s = repair.transition(s, 'diagnosed', 'diagnoser', cause=s['cause'], test='test command') + self.assertEqual(s['phase'], 'exhausted') + with self.assertRaises(ValueError): + repair.transition(s, 'reserve', 'owner') + + def test_final_failure_exhausts(self): + s = repair.transition(self.exhausted_normal(), 'reserve', 'diagnoser') + s = repair.transition(s, 'diagnosed', 'diagnoser', cause='different cause', test='test command expects red') + s = repair.transition(s, 'reserve', 'final-worker') + self.assertEqual(s['phase'], 'final') + self.assertTrue(s['final_used']) + s = repair.transition(s, 'failed', 'final-worker', cause='yet another hash') + self.assertEqual(s['phase'], 'exhausted') + + def test_restart_retains_reservation_and_interruption_consumes(self): + s = repair.transition({}, 'reserve', 'owner') + import json + s = json.loads(json.dumps(s)) + with self.assertRaises(ValueError): + repair.transition(s, 'reserve', 'new-owner') + with self.assertRaises(ValueError): + repair.transition(s, 'interrupted', 'new-owner') + s = repair.transition(s, 'interrupted', 'owner') + self.assertEqual(s['repairs'], 1) + s = repair.transition(s, 'reserve', 'new-owner') + self.assertEqual(s['repairs'], 2) + + def test_success_releases_owner_without_refund(self): + s = repair.transition({}, 'reserve', 'owner') + s = repair.transition(s, 'succeeded', 'owner') + self.assertEqual(s['phase'], 'idle') + self.assertNotIn('owner', s) + self.assertEqual(s['repairs'], 1) + s = repair.transition(s, 'reserve', 'next') + self.assertEqual(s['repairs'], 2) + + def test_final_success_does_not_allow_more_repairs(self): + s = repair.transition(self.exhausted_normal(), 'reserve', 'diagnoser') + s = repair.transition(s, 'diagnosed', 'diagnoser', cause='different', test='test expects red') + s = repair.transition(s, 'reserve', 'final') + s = repair.transition(s, 'succeeded', 'final') + with self.assertRaises(ValueError): + repair.transition(s, 'reserve', 'again') + + def test_old_cause_is_not_fresh(self): + s = repair.transition(self.exhausted_normal(), 'reserve', 'diagnoser') + s = repair.transition(s, 'diagnosed', 'diagnoser', cause='original', test='run test') + self.assertEqual(s['phase'], 'exhausted') + + def test_missing_test_diagnosis_exhausts(self): + s = repair.transition(self.exhausted_normal(), 'reserve', 'diagnoser') + s = repair.transition(s, 'diagnosed', 'diagnoser', cause='new') + self.assertEqual(s['phase'], 'exhausted') + + def test_stricter_cap_and_invalid_state(self): + s = repair.transition({}, 'reserve', 'owner', cap=0) + self.assertEqual(s['phase'], 'diagnosis') + with self.assertRaises(ValueError): + repair.transition({'repairs': True}, 'reserve', 'owner') + + +class CanonicalHandoffTests(unittest.TestCase): + def setUp(self): + import tempfile, os + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.file = self.root / 'comments.json' + self.file.write_text('[]') + reader = self.root / 'reader.py' + reader.write_text("import pathlib,sys\nif '--check' not in sys.argv: print('{\"labels\":[]}' if 'labels' in sys.argv else '['+pathlib.Path(" + repr(str(self.file)) + ").read_text()+']')\n") + gh = self.root / 'gh' + gh.write_text("#!/usr/bin/env python3\nimport json,pathlib,sys\np=pathlib.Path(" + repr(str(self.file)) + ")\nif sys.argv[1:3] in (['label','create'],['issue','edit']): raise SystemExit(0)\nbody=json.load(sys.stdin)['body']\np.write_text(json.dumps([{'id':1,'body':body}]))\n") + gh.chmod(0o755) + self.path = os.environ['PATH'] + os.environ['PATH'] = str(self.root) + os.pathsep + self.path + self.addCleanup(os.environ.__setitem__, 'PATH', self.path) + self.handoff = repair.CanonicalHandoff(12, reader) + + def test_initial_no_record_does_not_consume(self): + self.assertIsNone(self.handoff.reserve('owner')) + self.assertEqual(self.file.read_text(), '[]') + + def test_write_readback_before_dispatch_and_restart(self): + self.handoff.event('failed', 'initial', 'initial failing test', pending_lane='build') + reserved = self.handoff.reserve('owner') + durable = self.handoff.read() + self.assertEqual(durable, reserved) + self.assertEqual(durable['repairs'], 1) + # A separate dispatcher sees the same outstanding reservation and cannot dispatch. + restarted = repair.CanonicalHandoff(12, self.handoff.reader) + with self.assertRaises(ValueError): + restarted.reserve('duplicate') + restarted.event('interrupted', 'owner') + next_state = restarted.reserve('new-owner') + self.assertEqual(next_state['repairs'], 2) + + def test_halt_after_readback_stops_label_mutations(self): + import subprocess + halted = self.root / 'halted' + labels = self.root / 'labels-mutated' + self.handoff.reader.write_text( + "import pathlib,sys\nh=pathlib.Path(" + repr(str(halted)) + ")\n" + "if h.exists(): raise SystemExit(79)\n" + "if '--check' in sys.argv: raise SystemExit(0)\n" + "value=pathlib.Path(" + repr(str(self.file)) + ").read_text()\n" + "print('['+value+']')\nif value != '[]': h.touch()\n") + gh = self.root / 'gh' + gh.write_text(gh.read_text().replace( + "if sys.argv[1:3] in (['label','create'],['issue','edit']): raise SystemExit(0)", + "if sys.argv[1:3] in (['label','create'],['issue','edit']): pathlib.Path(" + repr(str(labels)) + ").touch(); raise SystemExit(0)")) + with self.assertRaises(subprocess.CalledProcessError): + self.handoff.event('failed', 'initial', 'failure', pending_lane='build') + self.assertFalse(labels.exists()) + self.assertIn('repair-state:', self.file.read_text()) + + def test_backslashes_and_newlines_roundtrip(self): + cause = 'C:\\users\\fixture\nfailed command' + self.handoff.event('failed', 'initial', cause, pending_lane='build') + s = self.handoff.reserve('owner') + self.handoff.event('failed', 'owner', cause) + self.assertEqual(self.handoff.read()['cause'], cause) + + def test_multiple_records_fail_closed(self): + import json + body = self.handoff.marker + '\nrepair-state: {"phase":"idle","repairs":0}' + self.file.write_text(json.dumps([{'id':1,'body':body},{'id':2,'body':body}])) + with self.assertRaises(ValueError): + self.handoff.reserve('owner') + + def test_nonobject_canonical_state_fails_closed(self): + import json + for value in ([], None, 'text', 1): + self.file.write_text(json.dumps([{'id':1,'body':self.handoff.marker + '\nrepair-state: ' + json.dumps(value)}])) + with self.assertRaises(ValueError): + self.handoff.prepare('owner', 'build') + + def test_legacy_lower_bound(self): + s = self.handoff.reserve('owner', ['loop:rebuild-2']) + self.assertEqual(s['phase'], 'diagnosis') + self.assertEqual(s['repairs'], 2) + + def test_initial_qa_failure_reserves_first_repair(self): + self.handoff.event('failed', 'initial', 'test suite failed', pending_lane='qa') + self.assertEqual(self.handoff.prepare('qa-worker', 'qa')['repairs'], 1) + + def test_initial_failure_requires_pending_lane(self): + with self.assertRaises(ValueError): + self.handoff.event('failed', 'initial', 'test suite failed') + + def test_completed_bad_diagnosis_exhausts(self): + from unittest.mock import patch + import subprocess + original = subprocess.run + for code, output in [(1, ''), (0, 'not json'), (0, '{"is_error":true}'), (0, '{"structured_output":[]}')]: + self.file.write_text('[]') + state = self.handoff.reserve('diagnoser', ['loop:rebuild-2']) + def run(command, *args, **kwargs): + if command[0] == 'claude': + return subprocess.CompletedProcess(command, code, output, '') + return original(command, *args, **kwargs) + with patch.object(repair.subprocess, 'run', side_effect=run): + result = self.handoff.diagnose_claude(state, 'diagnoser') + self.assertEqual(result['phase'], 'exhausted') + self.assertEqual(self.handoff.read()['phase'], 'exhausted') + + def test_interrupted_diagnosis_preserves_reservation(self): + from unittest.mock import patch + import subprocess + original = subprocess.run + state = self.handoff.reserve('diagnoser', ['loop:rebuild-2']) + def run(command, *args, **kwargs): + if command[0] == 'claude': + return subprocess.CompletedProcess(command, 79, '', '') + return original(command, *args, **kwargs) + with patch.object(repair.subprocess, 'run', side_effect=run): + with self.assertRaises(subprocess.CalledProcessError): + self.handoff.diagnose_claude(state, 'diagnoser') + self.assertEqual(self.handoff.read()['phase'], 'diagnosis') + + def test_prepare_does_not_charge_read_only_qa(self): + self.handoff.event('failed', 'initial', 'failing test', pending_lane='build') + self.handoff.prepare('owner', 'build') + self.handoff.event('succeeded', 'owner') + self.assertIsNone(self.handoff.prepare('qa', 'qa', ['loop:rebuild-1'])) + self.assertEqual(self.handoff.read()['repairs'], 1) + + def test_legacy_prepare_precedes_spawn(self): + source = (Path(__file__).resolve().parents[1] / 'scripts/super-board-run.sh').read_text() + dispatch = source[source.index('dispatch_lane()'):] + self.assertLess(dispatch.index('super-board-repair.py" prepare'), dispatch.index('nohup claude -p')) + self.assertIn('--diagnose-claude', dispatch) + self.assertIn('exit 79', dispatch[:dispatch.index('nohup claude -p')]) + + def test_legacy_failed_persistence_never_spawns(self): + import os, subprocess, sys + stub = self.root / 'python3' + stub.write_text('#!' + sys.executable + '\nimport sys\nraise SystemExit(1 if any("super-board-repair.py" in a for a in sys.argv) else 0)\n') + stub.chmod(0o755) + claude = self.root / 'claude' + sentinel = self.root / 'spawned' + claude.write_text('#!/bin/sh\ntouch "' + str(sentinel) + '"\n') + claude.chmod(0o755) + runner = Path(__file__).resolve().parents[1] / 'scripts/super-board-run.sh' + script = 'SB_LIB_ONLY=1 source "$1"\nissue_locked() { return 1; }\nissue_is_open() { return 0; }\ngh_checkpoint() { return 0; }\ntry_claim_assignee() { return 0; }\nlog() { :; }\nPROJECT_ITEMS_JSON='"'"'{"items":[{"content":{"number":1},"status":"Ready"}]}'"'"'\ndispatch_lane build 1\n' + result = subprocess.run(['bash','-c',script,'fixture',str(runner)], env=os.environ, capture_output=True, text=True) + self.assertEqual(result.returncode,79,result.stderr) + self.assertFalse(sentinel.exists()) + + def test_wave_reserves_before_launch(self): + source = (Path(__file__).resolve().parents[1] / 'scripts/super-board-codex-wave.sh').read_text() + self.assertLess(source.index('reservation = handoff.prepare'), source.index("process = subprocess.Popen(['bash', str(launcher)")) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_repair_wave.py b/tests/test_repair_wave.py new file mode 100644 index 00000000..0e0d8baa --- /dev/null +++ b/tests/test_repair_wave.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Real installed wave/lane/handoff/card helpers with isolated GitHub and model stubs.""" +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest + +KIT = Path(__file__).resolve().parents[1] +GH = r'''import json,os,sys +from pathlib import Path +p=Path(os.environ['FIXTURE_STATE']);s=json.loads(p.read_text());a=sys.argv[1:] +def save():p.write_text(json.dumps(s)) +if a[:2]==['api','graphql']: + q=next((a[i+1] for i,v in enumerate(a[:-1]) if v=='-f' and a[i+1].startswith('query=')), '') + if 'updateProjectV2ItemFieldValue' in q: + s['board']=next(x[2:] for x in a if x.startswith('o='));save();print('{"data":{"updateProjectV2ItemFieldValue":{"projectV2Item":{"id":"ITEM"}}}}') + elif 'repositoryOwner' in q: + print(json.dumps({'data':{'repositoryOwner':{'projectV2':{'id':'PROJECT','field':{'id':'FIELD','options':[{'id':n,'name':n} for n in ['Ready','QA','Review','Blocked','Done']]}}}}})) + else:print(json.dumps({'data':{'repository':{'issueOrPullRequest':{'projectItems':{'nodes':[{'id':'ITEM','project':{'number':1,'owner':{'login':'fixture'}},'fieldValueByName':{'name':s['board']}}]}}}}})) +elif a[:2]==['api','-X']: + value=json.load(sys.stdin);endpoint=a[3] + if a[2]=='PATCH':s['comments'][0]['body']=value['body'] + else:s['comments'].append({'id':100,'body':value['body'],'user':{'login':'fixture'},'created_at':'2026-10-20T00:00:00Z','updated_at':'2026-10-20T00:00:00Z'}) + save();print('{}') +elif a and a[0]=='api' and '--slurp' in a:print(json.dumps([s['comments']])) +elif a[:2]==['issue','view']: + fields=a[a.index('--json')+1];print(json.dumps({'labels':[{'name':x} for x in s['labels']]} if fields=='labels' else {'number':1,'title':'fixture','body':''})) +elif a[:2]==['issue','edit']: + s['label_edits'].append(a);save() + for i,x in enumerate(a[:-1]): + if x=='--add-label' and a[i+1] not in s['labels']:s['labels'].append(a[i+1]);save() + if x=='--remove-label': + if a[i+1] not in s['repo_labels']:sys.exit("'"+a[i+1]+"' not found") + if a[i+1] in s['labels']:s['labels'].remove(a[i+1]) + save() + if os.environ.get('FIXTURE_LABEL_EDIT_UNKNOWN'):sys.exit('connection lost after label mutation') +elif a[:2]==['issue','comment']:s['blocks'].append(a[a.index('--body')+1]);save() +elif a[:2]==['label','create']: + if a[2] not in s['repo_labels']:s['repo_labels'].append(a[2]);save() +else:sys.exit('unexpected gh '+str(a)) +''' +HOST = r'''import json,os,re,sys +from pathlib import Path +args=sys.argv[1:];prompt=sys.stdin.read();diag='fresh independent read-only diagnostician' in prompt +comments=json.loads(Path(os.environ['FIXTURE_STATE']).read_text())['comments'] +reserved=json.loads(re.search(r'^repair-state: (.+)$',comments[0]['body'],re.M)[1]) if comments else {} +with open(os.environ['FIXTURE_CALLS'],'a') as f:f.write(json.dumps({'host':Path(sys.argv[0]).name,'diagnosis':diag,'phase':reserved.get('phase'),'repairs':reserved.get('repairs'),'labels':json.loads(Path(os.environ['FIXTURE_STATE']).read_text())['labels']})+'\n') +card=json.loads(re.search(r'^Card: (.+)$',prompt,re.M)[1]);mode=os.environ.get('FIXTURE_DIAG','same') +if diag: + result=dict(status='advanced',column=card['status'],detail=json.dumps(dict(cause='new cause' if mode=='new' else 'failure',test='test -f proof',expected='fails')),prUrl=None,branch=None,priorFindings=None) + if mode=='malformed':result['detail']='not JSON' +else:result=dict(status=os.environ.get('FIXTURE_RESULT','failed'),column=card['status'],detail='failure',prUrl=None,branch=None,priorFindings=None) +if '--output-last-message' in args:Path(args[args.index('--output-last-message')+1]).write_text(json.dumps(result)) +else:print(json.dumps(result)) +if diag and mode=='provider':sys.exit(1) +''' + + +class RepairWave(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name); self.root = self.base / 'repo'; self.root.mkdir() + self.home = self.base / 'home'; self.home.mkdir(); self.bin = self.base / 'bin'; self.bin.mkdir() + subprocess.run(['git','init','-q',str(self.root)],check=True) + subprocess.run(['bash',str(KIT/'install.sh'),'--hosts','codex,pi',str(self.root)],check=True,stdout=subprocess.DEVNULL) + subprocess.run([sys.executable,str(KIT/'tests/fixtures/install_worker_skills.py'),str(self.root)],check=True) + self.state = self.base/'state.json'; self.calls = self.base/'calls.jsonl' + self.env = dict(os.environ, PATH=str(self.bin)+os.pathsep+os.environ['PATH'], HOME=str(self.home), + SUPER_BOARD_SKILLS_HOME=str(self.home), FIXTURE_STATE=str(self.state), FIXTURE_CALLS=str(self.calls), + SB_GITHUB_RETRY_DELAY='0', SB_GITHUB_HALT_FILE=str(self.base/'github-halt.json')) + for name,code in [('gh',GH),('codex',HOST),('pi',HOST)]: + target=self.bin/name;target.write_text('#!/usr/bin/env python3\n'+code);target.chmod(0o755) + self.config=self.base/'config.json' + self.config.write_text(json.dumps(dict(max_workers=1,verify_commands=['git diff --check'],rebuild_cap=2, + project=dict(owner='fixture',number=1),repo=dict(remote='fixture/repo'), + pi=dict(provider='fixture',model='fixture')))) + self.reset() + + def reset(self): + self.state.write_text(json.dumps(dict(comments=[],labels=['docs'],repo_labels=['docs'],label_edits=[],blocks=[],board='Ready'))) + self.calls.write_text('') + lock=self.root/'.claude/super-board/inflight/1';lock.unlink(missing_ok=True) + + def data(self):return json.loads(self.state.read_text()) + def repair(self): + record=next(c for c in self.data()['comments'] if '<!-- super-board:repair -->' in c['body']) + return json.loads(re.search(r'^repair-state: (.+)$',record['body'],re.M)[1]) + def call_log(self):return [json.loads(x) for x in self.calls.read_text().splitlines()] + def wave(self,host,**env): + card=dict(number=1,status='Ready',labels=self.data()['labels'],body='') + process=subprocess.run(['bash',str(self.root/'.claude/bin/super-board-codex-wave.sh'),'--config',str(self.config), + '--cards',json.dumps([card]),'--pi' if host=='pi' else '--codex=fixture'], + cwd=self.root,env=dict(self.env,**env),capture_output=True,text=True,timeout=30) + self.assertIn(process.returncode,[0,79],process.stderr+'\n'+process.stdout) + return process,json.loads(process.stdout) + def two_repairs(self,host): + for expected in [0,1,2]: + self.wave(host) + self.assertEqual(self.repair()['repairs'],expected) + self.assertEqual(self.repair()['phase'],'idle') + self.assertEqual([x for x in self.data()['labels'] if x.startswith('loop:repair-')],[]) + self.assertIn(f'loop:rebuild-{expected}',self.data()['labels']) + self.assertEqual(len(self.call_log()),3) + self.assertEqual([x['phase'] for x in self.call_log()],[None,'reserved','reserved']) + + def assert_blocked(self,result): + self.assertEqual(result['cards'][0]['column'],'Blocked') + data=self.data();self.assertEqual(data['board'],'Blocked');self.assertEqual(self.repair()['phase'],'exhausted') + self.assertEqual(len(data['comments']),1) + self.assertIn('Owner: fixture',data['blocks'][-1]);self.assertIn('To unblock:',data['blocks'][-1]) + self.assertEqual([x for x in data['labels'] if x.startswith('loop:repair-')],['loop:repair-exhausted']) + for call in self.call_log(): + phase=call['phase'] + self.assertEqual([x for x in call['labels'] if x.startswith('loop:repair-')], + ['loop:repair-'+phase] if phase in ('diagnosis','final') else []) + + def test_both_hosts_initial_failure_without_phase_labels_persists_zero_before_next_dispatch(self): + for host in ['codex','pi']: + with self.subTest(host=host): + self.reset() + process,result=self.wave(host) + self.assertEqual(process.returncode,0,process.stderr) + self.assertFalse(result['halted']) + self.assertEqual(result['cards'][0]['finalStatus'],'failed') + self.assertEqual(self.repair()['repairs'],0) + self.assertEqual(self.repair()['phase'],'idle') + self.assertEqual(len(self.data()['comments']),1) + self.assertEqual(self.data()['labels'],['docs','loop:rebuild-0']) + self.assertEqual(self.data()['repo_labels'],['docs','loop:rebuild-0']) + self.wave(host) + self.assertEqual([x['repairs'] for x in self.call_log()],[None,1]) + self.assertEqual([x['phase'] for x in self.call_log()],[None,'reserved']) + self.assertEqual(len(self.data()['comments']),1) + + def test_both_hosts_unknown_label_mutation_halts_without_replay(self): + for host in ['codex','pi']: + with self.subTest(host=host): + self.reset() + process,result=self.wave(host,FIXTURE_LABEL_EDIT_UNKNOWN='1') + self.assertIn('connection lost after label mutation',process.stderr) + self.assertEqual(process.returncode,79) + self.assertTrue(result['halted']) + self.assertEqual(result['cards'][0]['finalStatus'],'halted') + self.assertEqual(self.repair()['repairs'],0) + self.assertEqual(len(self.data()['comments']),1) + self.assertEqual(len(self.data()['label_edits']),1) + lock=self.root/'.claude/super-board/inflight/1';claim=lock.read_text() + self.wave(host) + self.assertEqual(len(self.call_log()),1) + self.assertEqual(len(self.data()['label_edits']),1) + self.assertEqual(lock.read_text(),claim) + + def test_successful_completions_retain_counts_and_clear_phase_labels(self): + def event(name,**options): + command=[sys.executable,str(self.root/'.claude/bin/super-board-repair.py'),name, + '--issue','1','--owner','owner'] + for key,value in options.items():command.extend(['--'+key.replace('_','-'),value]) + result=subprocess.run(command,cwd=self.root,env=self.env,capture_output=True,text=True,check=True) + return json.loads(result.stdout) + event('failed',cause='failure',pending_lane='build') + for count in (1,2): + reserved=event('reserve');self.assertEqual(reserved['repairs'],count) + completed=event('succeeded') + self.assertEqual((completed['phase'],completed['repairs']),('idle',count)) + self.assertNotIn('owner',completed) + self.assertEqual([x for x in self.data()['labels'] if x.startswith('loop:repair-')],[]) + self.assertEqual(event('reserve')['phase'],'diagnosis') + event('diagnosed',cause='new cause',test='test -f proof expects failure') + self.assertEqual(event('reserve')['phase'],'final') + self.assertEqual([x for x in self.data()['labels'] if x.startswith('loop:repair-')],['loop:repair-final']) + completed=event('succeeded') + self.assertEqual((completed['phase'],completed['repairs']),('idle',2)) + self.assertTrue(completed['diagnosis_used']);self.assertTrue(completed['final_used']) + self.assertEqual([x for x in self.data()['labels'] if x.startswith('loop:repair-')],[]) + self.assertIn('docs',self.data()['labels']);self.assertEqual(len(self.data()['comments']),1) + + def test_both_hosts_diagnosis_without_new_cause_blocks_without_final(self): + for host in ['codex','pi']: + with self.subTest(host=host): + self.reset();self.two_repairs(host) + _,result=self.wave(host) + self.assert_blocked(result) + self.assertTrue(self.repair()['diagnosis_used']);self.assertFalse(self.repair().get('final_used',False)) + self.assertEqual([x['diagnosis'] for x in self.call_log()],[False,False,False,True]) + + def test_both_hosts_fresh_cause_allows_exactly_one_final_failed_repair(self): + for host in ['codex','pi']: + with self.subTest(host=host): + self.reset();self.two_repairs(host) + _,result=self.wave(host,FIXTURE_DIAG='new') + self.assert_blocked(result) + self.assertTrue(self.repair()['final_used']);self.assertEqual(self.repair()['repairs'],2) + self.assertEqual([x['diagnosis'] for x in self.call_log()],[False,False,False,True,False]) + self.assertEqual([x['phase'] for x in self.call_log()],[None,'reserved','reserved','diagnosis','final']) + self.wave(host,FIXTURE_DIAG='new') + self.assertEqual(len(self.call_log()),5, 'restart cannot repeat an exhausted final repair') + + def test_both_hosts_invalid_or_provider_failed_diagnosis_blocks(self): + for host in ['codex','pi']: + for mode in ['malformed','provider']: + with self.subTest(host=host,mode=mode): + self.reset();self.two_repairs(host) + _,result=self.wave(host,FIXTURE_DIAG=mode) + self.assert_blocked(result);self.assertFalse(self.repair().get('final_used',False)) + self.assertEqual(len(self.call_log()),4) + + def test_both_hosts_halted_reservation_survives_restart_and_reconciliation_consumes_it(self): + for host in ['codex','pi']: + with self.subTest(host=host): + self.reset();self.wave(host) + process,result=self.wave(host,FIXTURE_RESULT='halted') + self.assertEqual(process.returncode,79);reserved=self.repair() + self.assertEqual((reserved['phase'],reserved['repairs']),('reserved',1)) + lock=self.root/'.claude/super-board/inflight/1';claim=lock.read_text() + self.wave(host);self.assertEqual(len(self.call_log()),2);self.assertEqual(lock.read_text(),claim) + # Fixture operator has reconciled the stopped process and preserved worktree. + reconciled=subprocess.run([sys.executable,str(self.root/'.claude/bin/super-board-repair.py'),'interrupted', + '--issue','1','--owner',reserved['owner']],cwd=self.root,env=self.env, + capture_output=True,text=True,check=True) + self.assertEqual(json.loads(reconciled.stdout)['repairs'],1) + lock.unlink();self.wave(host) + self.assertEqual(self.repair()['repairs'],2);self.assertEqual(len(self.call_log()),3) + +if __name__=='__main__':unittest.main() diff --git a/tests/test_setup_helpers.py b/tests/test_setup_helpers.py new file mode 100644 index 00000000..f7a2b856 --- /dev/null +++ b/tests/test_setup_helpers.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Exercise onboard helper repair with an offline installer and real readiness.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +KIT = Path(__file__).resolve().parents[1] +SETUP = KIT / 'scripts/super-board-setup.py' + + +class HelperRepair(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.work = Path(self.temp.name) + self.root = self.work / 'project' + self.home = self.work / 'home' + self.root.mkdir(); self.home.mkdir() + bin_dir = self.work / 'bin' + bin_dir.mkdir() + self.log = self.work / 'installs.jsonl' + installer = bin_dir / 'npx' + installer.write_text('''#!/usr/bin/env python3 +import json, os +from pathlib import Path +import sys +a = sys.argv[1:] +with open(os.environ['INSTALL_LOG'], 'a') as f: + f.write(json.dumps(a) + '\\n') +if os.environ.get('INSTALL_FAILURE'): + sys.stderr.write('fixture download failed') + sys.exit(7) +agent = a[a.index('--agent') + 1] if '--agent' in a else a[a.index('-a') + 1] +directory = '.claude/skills' if agent == 'claude-code' else '.agents/skills' +if os.environ.get('INSTALL_EMPTY'): + sys.exit(0) +if '--skill' in a: + for name in a[a.index('--skill') + 1:]: + if name.startswith('-'): + break + p = Path(directory) / name / 'SKILL.md' + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('Offline test fixture for ' + name + '\\n') +''') + installer.chmod(0o755) + self.env = dict(os.environ, HOME=str(self.home), INSTALL_LOG=str(self.log), + PATH=str(bin_dir) + os.pathsep + os.environ['PATH']) + + def fix(self, host='pi', extra=()): + result = subprocess.run([sys.executable, str(SETUP), 'fix', '--root', str(self.root), + '--pack', str(KIT), '--host', host, '--home', str(self.home), *extra], + env=self.env, text=True, capture_output=True) + self.assertIn(result.returncode, (0, 1), result.stderr) + return json.loads(result.stdout) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def test_selected_hosts_install_the_complete_chain_and_retry_is_noop(self): + for host, agent in [('claude', 'claude-code'), ('codex', 'codex'), ('pi', 'pi')]: + with self.subTest(host=host): + self.root = self.work / host + self.root.mkdir() + if self.log.exists(): + self.log.unlink() + surface = '.claude/skills' if host == 'claude' else '.agents/skills' + keep = self.root / surface / 'tdd/SKILL.md' + keep.parent.mkdir(parents=True) + keep.write_text('Existing project instructions\n') + result = self.fix(host) + self.assertTrue(result['helper_skills'], [e['name'] for e in result['helper_readiness']['entries'] if e['status'] == 'missing']) + self.assertTrue(result['green'], result) + self.assertEqual(keep.read_text(), 'Existing project instructions\n') + calls = self.calls() + self.assertEqual(len(calls), 3, calls) + self.assertTrue(all(c[c.index('--agent') + 1] == agent for c in calls), calls) + self.assertTrue(all('--global' not in c and '-g' not in c for c in calls), calls) + self.assertTrue(any('obra/superpowers' in c[3] for c in calls), calls) + self.assertTrue(any('anthropics/knowledge-work-plugins' in c[3] for c in calls), calls) + self.assertFalse(any('tdd' in c[c.index('--skill') + 1:] for c in calls), calls) + self.assertTrue(self.fix(host)['helper_skills']) + self.assertEqual(self.calls(), calls) + + def test_failed_or_empty_installer_cannot_report_ready(self): + for mode in ['INSTALL_FAILURE', 'INSTALL_EMPTY']: + with self.subTest(mode=mode): + self.env[mode] = '1' + result = self.fix() + self.assertFalse(result['helper_skills']) + self.assertFalse(result['green']) + self.assertTrue(any('required skills remain unavailable' in note for note in result['notes'])) + if mode == 'INSTALL_FAILURE': + self.assertTrue(any('fixture download failed' in note for note in result['notes'])) + text = subprocess.run([sys.executable, str(SETUP), 'fix', '--root', str(self.root), + '--pack', str(KIT), '--host', 'pi', '--home', str(self.home), '--text'], + env=self.env, text=True, capture_output=True) + self.assertIn('fixture download failed', text.stdout) + del self.env[mode] + + def test_no_helpers_and_dry_run_do_not_install(self): + for extra in [('--no-helpers',), ('--dry-run',)]: + result = self.fix(extra=extra) + self.assertFalse(result['helper_skills']) + self.assertEqual(self.calls(), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_skill_dispatch.py b/tests/test_skill_dispatch.py index 65c12599..0bf5b74a 100644 --- a/tests/test_skill_dispatch.py +++ b/tests/test_skill_dispatch.py @@ -30,7 +30,10 @@ def setUp(self): self.tool('gh', '''import json,os,sys args=sys.argv[1:] with open(os.environ['CALL_LOG'],'a') as f:f.write(json.dumps({'tool':'gh','args':args})+'\\n') -if args[:2]==['issue','view']: +if args and args[0]=='api' and '--slurp' in args: + if os.environ.get('HALT_COMMENTS'):sys.exit('fixture GitHub read failed') + print('[[]]') +elif args[:2]==['issue','view']: fields=args[args.index('--json')+1] if '--json' in args else '' if fields=='state': print('OPEN') elif fields=='labels': print('{"labels":[]}') @@ -51,6 +54,7 @@ def setUp(self): result={'status':'advanced','column':{'build':'QA','qa':'Review','review':'Done'}[lane], 'detail':'fixture','prUrl':None,'branch':None,'priorFindings':None} output.write_text(json.dumps(result)) ''') + self.tool('pi', "raise SystemExit('fixture Pi must not run in preflight checks')\n") self.tool('claude', "import json,os\nwith open(os.environ['CALL_LOG'],'a') as f:f.write(json.dumps({'tool':'claude'})+'\\n')\n") self.tool('npx', "import json,os\nwith open(os.environ['CALL_LOG'],'a') as f:f.write(json.dumps({'tool':'npx'})+'\\n')\n") self.state = self.root / '.claude/super-board' @@ -66,7 +70,7 @@ def tool(self,name,source): def full(self,host='codex'): for name in NAMES: - for surface in ['.agents/skills'] if host=='codex' else ['.claude/skills']: + for surface in ['.agents/skills'] if host in ['codex','pi'] else ['.claude/skills']: path=self.root/surface/name/'SKILL.md';path.parent.mkdir(parents=True,exist_ok=True);path.write_text('fixture skill instructions') def calls(self): @@ -95,6 +99,50 @@ def test_missing_all_remaining_chains_halt_before_router_or_marker(self): self.assertEqual(sorted(p.name for p in (self.state/'inflight').iterdir()),['99']) self.preserved() + def test_pi_requires_explicit_configuration_before_any_host_call(self): + result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[]','--pi') + self.assertEqual(result.returncode,2,result.stderr) + self.assertIn('pi.provider and pi.model',result.stderr) + self.assertEqual(self.calls(),[]) + + def test_pi_readiness_uses_pi_surface_without_codex_router(self): + config=json.loads(self.config.read_text());config['pi']={'provider':'fixture','model':'fixture'} + self.config.write_text(json.dumps(config)) + result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[{"number":1,"status":"Ready"}]','--pi') + self.assertEqual(result.returncode,10,result.stderr) + self.assertFalse(any(c['tool']=='codex' for c in self.calls())) + self.preserved() + + def test_pi_guard_readiness_halts_before_claim_or_marker(self): + self.full('pi') + config=json.loads(self.config.read_text());config['pi']={'provider':'fixture','model':'fixture'} + self.config.write_text(json.dumps(config)) + result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[{"number":1,"status":"Ready"}]','--pi') + self.assertEqual(result.returncode,79,result.stderr) + self.assertIn('Pi guards unavailable',json.loads(result.stdout)['cards'][0]['detail']) + self.assertFalse((self.state/'inflight/1').exists()) + self.assertFalse(any(c['tool']=='codex' for c in self.calls())) + self.preserved() + + def test_existing_card_claim_is_never_overwritten(self): + self.full() + claim=self.state/'inflight/1';claim.write_text('PID=77777\nLANE=build\n') + result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[{"number":1,"status":"Ready"}]','--codex=fixture') + self.assertEqual(result.returncode,79,result.stderr) + self.assertEqual(claim.read_text(),'PID=77777\nLANE=build\n') + self.assertFalse(any(c['tool']=='codex' for c in self.calls())) + self.assertTrue(json.loads(result.stdout)['halted']) + + def test_repair_read_failure_retains_claim_before_any_worker(self): + self.full() + self.env['HALT_COMMENTS']='1' + result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[{"number":1,"status":"Review","body":""}]','--codex=fixture') + self.assertEqual(result.returncode,79,result.stderr) + self.assertTrue(json.loads(result.stdout)['halted']) + self.assertTrue((self.state/'inflight/1').read_text().startswith('OWNER=')) + self.assertFalse(any(c['tool']=='codex' for c in self.calls())) + self.preserved() + def test_passing_wave_progresses_all_lanes_and_hydrates_body(self): self.full() result=self.invoke('super-board-codex-wave.sh','--config',str(self.config),'--cards','[{"number":1,"status":"Ready"}]','--codex=fixture') @@ -243,7 +291,8 @@ def stop(*args): from pathlib import Path args=sys.argv[1:];root=Path(os.environ['ROOT_FIXTURE']) with open(os.environ['CALL_LOG'],'a') as f:f.write(json.dumps({'tool':'gh','args':args})+'\\n') -if args[:2]==['api','rate_limit']:print('{"resources":{"graphql":{"remaining":5000,"reset":0},"core":{"remaining":5000,"reset":0}}}') +if args and args[0]=='api' and '--slurp' in args and '/comments' in ' '.join(args):print('[[]]') +elif args[:2]==['api','rate_limit']:print('{"resources":{"graphql":{"remaining":5000,"reset":0},"core":{"remaining":5000,"reset":0}}}') elif args[:2]==['api','graphql'] and '--paginate' in args: nodes=[dict(id='item'+str(number),fieldValueByName=dict(name=status), content=dict(__typename='Issue',number=number,title='fixture',url='https://github.com/fixture/app/issues/'+str(number), @@ -277,6 +326,7 @@ def stop(*args): self.assertFalse(any(c['args'][:3]==['issue','edit','2'] for c in self.calls())) def test_setup_fix_reports_missing_even_after_successful_installer(self): + self.tool('npx', "import json,os,sys\nwith open(os.environ['CALL_LOG'],'a') as f:f.write(json.dumps({'tool':'npx','args':sys.argv[1:]})+'\\n')\n") (self.root/'skills-lock.json').write_text('{"mattpocock/skills":true}') for command,extra in [('check',[]),('fix',['--no-helpers']),('fix',[])]: result=subprocess.run([sys.executable,str(KIT/'scripts/super-board-setup.py'),command,'--root',str(self.root),'--pack',str(KIT),'--home',str(self.home),*extra],env=self.env,text=True,capture_output=True) @@ -285,6 +335,9 @@ def test_setup_fix_reports_missing_even_after_successful_installer(self): self.assertFalse(output['helper_skills']);self.assertFalse(output['green']) self.assertTrue(any(e['status']=='missing' for e in output['helper_readiness']['entries'])) self.assertNotIn("Matt Pocock's coding skills added",output.get('fixed',[])) - self.assertEqual(len([c for c in self.calls() if c['tool']=='npx']),1) + installs = [c['args'] for c in self.calls() if c['tool']=='npx'] + self.assertEqual(len(installs),3,installs) + self.assertTrue(all(a[a.index('--agent')+1]=='claude-code' for a in installs),installs) + self.assertTrue(all('--global' not in a and '-g' not in a for a in installs),installs) if __name__=='__main__':unittest.main() diff --git a/tests/test_skills_readiness.py b/tests/test_skills_readiness.py index 4b123fe5..d25e194e 100644 --- a/tests/test_skills_readiness.py +++ b/tests/test_skills_readiness.py @@ -39,6 +39,13 @@ def run_cli(self, host='claude', cards=None, extra=(), expected=0): self.assertEqual(process.returncode, expected, process.stderr) return json.loads(process.stdout) if process.stdout else process.stderr + def test_pi_explicit_resources_do_not_read_codex_activation(self): + self.full('pi') + config = self.home / '.codex/config.toml' + config.parent.mkdir(exist_ok=True) + config.write_text('skills.config = "unsupported"') + self.run_cli(host='pi') + def test_complete_partial_lock_and_grilling_are_distinct(self): (self.root / 'skills-lock.json').write_text('{"mattpocock/skills":true}') self.put('grilling'); self.put('tdd') diff --git a/tests/test_status_parse.py b/tests/test_status_parse.py index d66695f7..73740e56 100644 --- a/tests/test_status_parse.py +++ b/tests/test_status_parse.py @@ -181,14 +181,14 @@ def test_real_live_manifest_lines() -> None: def test_attempt_str_from_rebuild_label() -> None: - """`attempt N/3` is derived from the issue's `loop:rebuild-N` label.""" - assert sbs.attempt_str(None) == "1/3" - assert sbs.attempt_str({"labels": []}) == "1/3" - assert sbs.attempt_str({"labels": ["loop:rebuild-1"]}) == "2/3" - assert sbs.attempt_str({"labels": ["loop:rebuild-2"]}) == "3/3" - # The dispatcher caps rebuilds at 2 in practice, but if a higher count - # ever appears we should clamp display to 3/3 rather than say 4/3. - assert sbs.attempt_str({"labels": ["loop:rebuild-5"]}) == "3/3" + assert sbs.attempt_str(None) == "initial" + assert sbs.attempt_str({"labels": []}) == "initial" + assert sbs.attempt_str({"labels": ["loop:rebuild-1"]}) == "repair 1/2" + assert sbs.attempt_str({"labels": ["loop:rebuild-2"]}) == "repair 2/2" + assert sbs.attempt_str({"labels": ["loop:rebuild-5"]}) == "exhausted" + assert sbs.rebuild_count({"labels": ["loop:rebuild-2", "loop:rebuild-1"]}) == 2 + for phase in ("diagnosis", "final", "exhausted"): + assert sbs.attempt_str({"labels": ["loop:rebuild-2", f"loop:repair-{phase}"]}) == phase def test_field_status_picks_status_select() -> None: diff --git a/workflows/super-board-wave.js b/workflows/super-board-wave.js index e7047595..83ef3444 100644 --- a/workflows/super-board-wave.js +++ b/workflows/super-board-wave.js @@ -140,12 +140,14 @@ const SKILLS_CHECK = (cards, lane) => [ `First operation, before worktree creation, classification, comments, labels or moves: run python3 .claude/bin/super-board-skills-ready.py --root "$PWD" --host claude --cards - --hydrate${lane ? ' --lane ' + lane : ''},`, `with this JSON array as stdin (data, never interpolate a body into shell code): ${JSON.stringify(cards)}.`, `Exit 10 means status=halted (preflight verdict=halted), detail beginning "skills-readiness:"; preserve all existing claims/worktrees/approval evidence. Exit 64 means invalid input: halt and repair it. Exit 79 uses the read halt policy.`, + `Before any repair or failure bounce follow run.md → Repair protocol. The coordinator owns canonical repair-state transitions and persisted card.repairReservation before dispatch when present. Consume that attempt once; never reserve or edit the machine record or repair-count/phase labels yourself. Write normal issue/PR proof and return the outcome so the coordinator persists completion. Never reset after changed hash/restart/lane bounce. Diagnosis requires one fresh independent read-only agent session, then at most one final targeted repair; exhausted means Blocked with evidence.`, `Load the exact resolved_path files. Only declared fallback IDs are allowed. Before using a conditional/custom helper repeat this CLI with --require <exact-name>.`, ].join('\n') const lanePrompt = (lane, card) => [ SKILLS_CHECK([card], lane), READ_FAILURE, + `Owned repair reservation: ${JSON.stringify(card.repairReservation || null)}. Initial work has no reservation; retain the budget through QA/Review and never count read-only verification as repair.`, `Run ${LANE[lane].skill} on issue #${card.number} ("${card.title}") for a super-board workflow wave.`, `Read .claude/skills/super-board/references/run.md → "${LANE[lane].section}" lifecycle and follow it EXACTLY:`, `create your own worktree under .claude/worktrees/, work on the issue branch, post the required PR/issue comments,`,