diff --git a/.agents/skills/be-review/SKILL.md b/.agents/skills/be-review/SKILL.md new file mode 100644 index 000000000..7115e0047 --- /dev/null +++ b/.agents/skills/be-review/SKILL.md @@ -0,0 +1,265 @@ +--- +name: be-review +description: Run /be's review gauntlet SERIALLY — /lens-debate (lowy ⇄ hickey), then /codex-debate, then /simplify, then code-police, each editing and committing on the live branch in turn. Use from /be §4, or when the user asks to "run the review gauntlet". Requires Claude Code's Skill tool. +argument-hint: "[--base ] [--rationale ] [--context ] [--tracks lens,codex,simplify,police]" +--- + +# Review gauntlet (serial) + +Run four reviewers **one after another** on the live branch, each the **sole +editor while it runs**. Collisions are an *edit* problem: two reviewers writing +the same worktree at once see torn, half-edited state. Running serially makes +that impossible without any snapshot machinery — when a step starts, the previous +step has already committed, so every reviewer reads a clean, settled tree and +applies its own fixes directly: + +1. **`/lens-debate`** — lowy + hickey debate boundaries/simplicity to consensus, + then **apply** the agreed fixes (each its own commit). Pass the change + **`rationale`** so the lenses don't flag deliberate decisions. +2. **`/codex-debate`** — codex (`xhigh`) ⇄ claude author, debating to consensus. + Its author rounds edit and each round auto-commits `fix(…)` on the branch. +3. **`/simplify`** — the self-applying reuse / simplification / efficiency pass + over the changed code. Now that nothing runs concurrently, it runs as itself + (it could not against the old read-only snapshot). +4. **code-police** — its rule-checklist and fact-check passes, applying their + fixes. Run with `--no-elegance` so its elegance pass is skipped: that pass + re-invokes `/simplify`, which step 3 already ran over this same tree. + +Each step runs to completion before the next begins. Wall-clock is +`lens + codex + simplify + police` — slower than the old parallel form, but with +no snapshot, no change-request handoff, and no separate apply pass: every step is +its own editor and commits its own work. + +**PR comments come after the push, never before.** Each step commits locally but +be-review pushes only once, after all selected steps finish. A comment that names +a commit SHA must never be posted while that SHA is local-only — if a later step +failed or the run were interrupted, the PR would advertise commits that were +never pushed. So the debate skills run with their self-commenting **suppressed** +(`--no-comment`); be-review captures each comment body (the lens skill returns one +ready; the codex body it assembles from `commentHeader` + the section files — +step 2), pushes once at the end, and only then posts the lens comment, the codex +comment, and its own police summary. No PR comment can reference a local-only +commit. + +## Preflight + +- **Non-empty diff.** `git diff --stat ` (default: the repo default via + `git symbolic-ref --short refs/remotes/origin/HEAD`). If empty, stop. +- **Commit first.** Reviewers review *committed* code — commit/stash any + outstanding work before starting (in `/be` this is automatic: §2/§3 commit and + push before §4). +- **Resolve the scope once.** `git fetch origin`, then + `MB=$(git merge-base HEAD)` and `START=$(git rev-parse HEAD)`. Pass `MB` + as the `base` to every step (their own merge-base resolution is idempotent on a + SHA) so each reviews the change against the identical fork point. Note that each + step sees the *commits the previous step added* as part of the diff — that is + intended: a later reviewer reviews the earlier reviewer's fixes too. Run every + `git` here with `git -C "$repoPath"` (below) so a cross-repo run resolves the + *target* repo's base, not the cwd's. +- **Pin `repoPath` — the repo under review may NOT be the cwd.** A `/be` run can + carry the work in a *companion repo* (e.g. the drishti PR a `@kolu/surface` + change requires per `/be` §5) while the session is rooted in a kolu worktree. + Set `repoPath` to that target repo's absolute path (default: the cwd worktree + root) and thread it into **every** step. Pass `args` as a real object — + `Workflow({ scriptPath, args: { repoPath, base: MB, … } })`. **Note the harness + JSON-ENCODES `args` before the workflow script sees it, so `args` arrives as a + *string* regardless of what you pass.** The debate scripts now parse a stringified + `args` defensively (`const a = typeof args === 'string' ? JSON.parse(args) : args`), + so `repoPath`/`base`/`rationale`/`context` thread through correctly and malformed + `args` throws *loudly* instead of degrading. This fixed a real cross-repo failure: an + earlier run's scripts did the bare `const a = args || {}`, so the stringified `args` + had no `.repoPath`, `repoPath` silently degraded to `.`, and a cross-repo lens-debate + re-reviewed the **cwd** repo and committed five fixes onto the wrong repo (same-repo + runs only "worked" by cwd coincidence). If a cross-repo step still returns `clean` + with `rounds: 0` against a non-empty *target* diff, suspect the `repoPath` didn't + take effect before trusting it. +- **codex login** (unless `--tracks` excludes it): `codex login status`. If not + logged in, tell the user to run `codex login` (suggest the `!` prefix) and + continue with the remaining steps. + +## Run the steps in order + +`--tracks lens,codex,simplify,police` selects which steps run (default all four), +in the listed order. Run each to completion, then move to the next. Preflight +already ran `git fetch origin` and resolved the base, so pass `MB` straight into +each step and **skip the per-skill step-1 fetch / base resolution** — don't redo +it once per step. + +**How to "wait for the Workflow" — let its own settle notification resume you.** +The debate skills run as a backgrounded `Workflow` ("launched in background; Task +ID: …"); a debate can legitimately take 20–30 min. When it settles it fires its +own task-notification that resumes this run automatically — that is the wait. So +after dispatching a step, go to rest and let that notification wake you; **do not +schedule redundant `ScheduleWakeup` polls** and there is nothing to babysit. (A +prior run scheduled 4-min wakeups *and* the user wired a 5-min `/loop` to nudge a +gauntlet that was simply mid-debate — both were unnecessary churn.) Only act when +the workflow's notification arrives or it has provably errored. + +1. **lens** — follow `/lens-debate` (Skill tool). `repoPath` = the live worktree, + `base` = `MB`, **apply mode** (the default — do *not* pass `--no-apply`), + **`--no-comment`** (so it doesn't advertise its local-only commits before + be-review pushes — defer the comment until after the push), and thread the + `rationale` through. It applies the agreed fixes as commits and **returns** its + rendered comment body for be-review to post after the push. Wait for its + `Workflow` to finish before starting the codex step. + + `/lens-debate` returns a `status` of `clean`, `consensus`, + `apply-incomplete`, `unresolved`, or `merge-base-error`: + - `clean` / `consensus` — the lenses agreed per-finding and applied the fixes. + - `apply-incomplete` — the lenses agreed, but the Apply phase didn't land every + fix cleanly (see `applyGaps`: a fix was missing from the apply output or + changed-but-uncommitted). **Reconcile before moving on:** for each gap, apply + or commit the outstanding fix yourself (staging only its files), then fold the + reconciliation into the deferred lens comment. Never report "lens consensus" + for an `apply-incomplete` run. + - `unresolved` — the debate hit its round backstop with findings still + contested. `/be` §4 requires you to **adjudicate every unresolved lens + finding yourself before moving on**: surface them in the report, decide drop + or apply for each, and apply the survivors before continuing. Fold your + adjudication into the deferred lens comment you post after the push (the lens + skill ran `--no-comment`, so there is no self-posted comment to follow up + on). Never report "lens consensus" for an `unresolved` run. + - `merge-base-error` — the scope couldn't be trusted; report it and move on. + +2. **codex** — follow `/codex-debate` (Skill tool). `repoPath` = the live + worktree, `base` = `MB`, **`--no-comment`** (so it doesn't advertise its + local-only round commits before be-review pushes), and thread both `context` + (the task / main-agent context, so the codex **author inherits what you know — + not just the diff** — every round) and `rationale` (so codex doesn't flag + deliberate decisions at the source) straight through. **When the diff makes an + API-facing change to the shared surface stack** (`packages/surface{,-app,-nix-host}` + per `.claude/rules/surface.md`), it trips the drishti companion-repo gate, which is + satisfiable **only against the *final* post-gauntlet kolu HEAD** — never mid-review, + by construction. Seed that into the `rationale` explicitly (e.g. *"the surface.md + drishti ship-gate is deferred to §ship; it is not a blocking code finding"*) so codex + defers it **from round 1**. `/codex-debate` also defers such a gate reactively once + the author flags it mid-round, but the up-front rationale is what converges the debate + *fast*: on this skill's originating `@kolu/surface` run, the debate without it spun 32 + rounds to a weekly-usage-limit kill (131 agents, 2.69M tokens); the very next surface + debate, with it, converged in 2 rounds (8 agents). Its step-2 `Workflow` runs + in the background; **wait for it to finish** before starting the simplify step. + It commits its rounds and returns a `commentHeader` plus the per-round section + files under `workDir` (it no longer returns a single pre-rendered comment string). + **Assemble the comment body now and hold it** to post after the final push — + capture it immediately so a later step can't disturb the scratch: + + ```bash + { + printf '%s\n' "$commentHeader" + for f in "$workDir"/section-*.md; do printf '\n'; cat "$f"; printf '\n'; done + } > "$workDir/comment.md" # hold this path for the post-after-push step + ``` + + This **freezes** the body now, so any reconciliation a later branch performs + (a `commit-incomplete` or `section-incomplete` fix-up below) is **not** in this + file yet — **append** that note to `$workDir/comment.md` after you reconcile, or + it won't reach the posted comment. + + (On `merge-base-error` the workflow aborted before any debate ran, so there is + **no** `commentHeader`/`workDir`/`section-*.md` to assemble — do **not** run the + block above. Per `/codex-debate`, report the scope failure from the return's + `note`, fix the base ref (e.g. `git fetch`), and re-run; there's nothing to post. + On persistent `reviewer-error` there is likewise **no body to post** — an + unresolved reviewer error is not a consensus to report; skip the codex comment in + that case.) + + **Retry codex on `reviewer-error` (up to 3 attempts).** `/codex-debate` ends + in `consensus`, `commit-incomplete` / `section-incomplete` (see below), + `reviewer-error`, or `merge-base-error` — `reviewer-error` meaning codex never + produced a structured verdict even after `codex-review.sh`'s built-in + per-`codex exec` retries. That is an *infrastructure hiccup, not a debate + outcome*: re-launch it immediately with the same args. Stop the moment an + attempt reaches `consensus`. Only if **all 3** come back `reviewer-error` do + you give up on codex — report the persistent reviewer-error honestly (no false + consensus comment) and move on to the simplify step. + + **On `commit-incomplete`,** the debate converged but a round's author left its + edits uncommitted (round numbers in `commitGaps`). The edits are still in the + tree, but the per-round commit didn't land — **commit the outstanding tree + yourself** (staging only the files that round changed, message + `fix: codex review — debate round N`) before the simplify step, then **append** + the reconciliation note to the already-frozen `$workDir/comment.md` (the body was + captured above *before* this fix-up, so editing the section files wouldn't reach + it). Don't report it as a clean consensus. + + **On `section-incomplete`,** the debate converged but a round's author **skipped + or under-filled its disposition section file** (missing, empty, or omitting a + marker for an open finding; round numbers in `sectionGaps`), so the per-round + trail — and thus `$workDir/comment.md` — has a gap for that round. The tree edits + and commits are intact; the missing piece is the record. **Append** a note to the + already-frozen `$workDir/comment.md` naming the round(s) whose disposition record + is missing, and report it as **converged-but-not-clean** in your gauntlet summary. + Don't report it as a clean consensus. + +3. **simplify** — invoke `/simplify` (Skill tool), scoped to the change vs `MB`. + It applies its fixes to the working tree. When it finishes, **commit** what it + changed (`refactor: simplify `, staging only the files it touched). If it + changed nothing, note that and move on. + +4. **police** — invoke `/code-police` (Skill tool), passing **`--no-elegance` + whenever the simplify track (step 3) ran this gauntlet**. That flag skips + Pass 3 (elegance), which would otherwise re-invoke `/simplify` over the tree + step 3 already simplified — a full skill invocation to re-derive a + near-guaranteed no-op. Pass 1 (rules) and Pass 2 (fact-check) still run. + _Only omit the flag when `--tracks` excluded `simplify`_ — then no standalone + simplify ran, and the elegance pass is the run's one simplify, not redundant. + Its embedded pass prompts diff against + `origin/HEAD...HEAD` by default, which is *wrong* whenever `--base` isn't the + repo default: before invoking, **tell the police passes to scope to `MB`** — + pass the merge-base explicitly so every pass runs `git diff ...HEAD`, not + the default ref. **Apply** the fixes it surfaces, committing each + `fix(police): ` with the finding in the message (stage only the files + changed). + +## Push, then comment + +First settle whether there is anything to push: `git log --oneline $START..HEAD` +(`$START` was captured in Preflight). Then: + +- **New commits exist** and **a PR exists for this branch** + (`gh pr view --json number -q .number`) → **`git push`**. **Only after the push + succeeds** do you post the deferred comments — the lens and codex bodies from + steps 1–2 are now safe to publish because the SHAs they name are on the remote. +- **No new commits** (every step was clean or applied nothing) but **a PR + exists** → there is nothing to push, and HEAD is already remote-visible, so + post the deferred comments **immediately**. The local-only-SHA invariant is + about never advertising an *unpushed* commit; with no new commit there is no + such risk. +- **No PR** → there is nothing to push to and nothing to comment on. Skip both; + the local commits (if any) and their findings live in chat and the local log + for the human. +- **A required push fails** → do **not** post the comments (the SHAs are still + local-only); report the push failure instead. + +**Never merge** — pushing updates the open PR; the human reviews the commits and +merges when satisfied. + +When you do post, post **one comment per track that produced a body** — skip any +track `--tracks` excluded, and skip a track that ran but yielded no postable +comment (lens on `merge-base-error`, codex on persistent `reviewer-error`): the +lens body and the codex body verbatim (`gh pr comment -F` — the codex body is the +`$workDir/comment.md` you assembled in step 2 from `commentHeader` + the section +files), and the police summary (the +`## [👮 Code-police](https://agency.srid.ca/)` comment described in Report). + +## Report + +Summarize in chat — reporting **only the selected tracks**, and naming any track +`--tracks` **skipped** so the absence is explicit, not silent: + +- **lens** — status (**consensus** + fixes applied, or **unresolved** + how many + findings still need human adjudication and how you adjudicated each, or + `merge-base-error`); its PR comment landed (posted after the push) — except on + `merge-base-error`, which has no comment body to post. +- **codex** — consensus / reviewer-error (note how many attempts if retried); on + consensus its PR comment landed (posted after the push, per "Push, then + comment") — on persistent reviewer-error there is no comment to post. +- **simplify** — whether it changed anything and what it committed. +- **police** — findings and how each was actioned; the + `## [👮 Code-police](https://agency.srid.ca/)` summary comment landed (posted + after the push, alongside the lens and codex comments). +- whether the fixes were pushed; +- `git log --oneline <base>..HEAD` + `git diff --stat <base>` so the combined + result is visible. + +ARGUMENTS: diff --git a/.agents/skills/be/SKILL.md b/.agents/skills/be/SKILL.md new file mode 100644 index 000000000..dd6af4040 --- /dev/null +++ b/.agents/skills/be/SKILL.md @@ -0,0 +1,169 @@ +--- +name: be +description: Modern, interactive alternative to `/do` — clarify intent up front, then take a task end-to-end with a serial AI review gauntlet (lens debate (lowy ⇄ hickey) → codex debate → simplify → code-police, each editing the branch in turn) → CI → evidence. ONLY invoke when the user explicitly types `/be` or `$be`; never auto-select from a natural-language request. +argument-hint: "<issue-url | prompt>" +--- + +# Be + +Take a task to a shipped, reviewed PR. Unlike `/do` (autonomous start to finish), `/be` **opens with a short interview** — and is then **fully autonomous**, exactly like `/do`, from §1 onward. The interview is the *only* place `/be` asks the user anything; after it, make sensible defaults and keep moving — no further `AskUserQuestion`, no stopping between steps. The single exception is the optional plan-review pause in §1, and only when "plan first" was chosen. Concise by design — defer mechanics to the skills it calls. + +**Autonomy doesn't inherit — propagate it to every subagent you delegate to.** When you hand work to a fresh subagent (a §2 package build, a §5 "finish the ship" CI+gate+cleanup pass), its prompt must say *execute now; do not wait for confirmation, do not ask me to "say go"* — a subagent starts without your interview's "no stopping between steps" contract, so a prompt that merely lays out a plan gets a plan **back** (zero tool uses) instead of done work, and you're the one who has to type "go." Bake the directive into the delegation, and if a subagent still returns a plan-and-waits with no tool uses, resume it with "execute now" rather than surfacing the stall to the user. + +**Requires Claude Code's `Skill` tool** (the debate reviewers it calls are `Workflow`-backed). + +## 0. Interview (the differentiator) + +Before any work, ask the user via **`AskUserQuestion`** (one call, batched): + +- **Plan first?** — write the plan as an **Atlas note** (`docs/atlas/src/content/atlas/<slug>.mdx`) for review *before* implementing, or implement straight. Default: straight, unless the task is large/ambiguous. *(If the prompt already points at an existing Atlas note or legacy `docs/plans/*.html`, skip this question — that file is the plan of record; reuse it.)* +- **Task kind** — bug fix · feature/new behavior · refactor/chore. This sets the test strategy (see §2). +- **Ultracode?** — include this question *only when no system-reminder says ultracode is on*. Remind the user that `/be` runs richer with ultracode (deeper review fan-out, adversarial verification of each finding) and ask whether to proceed on the standard pass or pause so they can enable it. Options: *Proceed (standard pass)* / *I'll enable ultracode first*. If they pick the latter, stop and let them turn it on, then re-run. + +Add a question only when something material is genuinely unclear — don't pad. Honor anything the user already pinned in the prompt instead of re-asking. **This single `AskUserQuestion` call is your one and only chance to ask** — surface every clarification you need now (including the ultracode check above), because everything after this is autonomous. + +## 1. Set up + +- `git fetch origin`; branch off `origin/<default>` (`git symbolic-ref --short refs/remotes/origin/HEAD`). Feature branches only — never commit to master. +- Read `.agency/do.md` for the project's **check / fmt / test / ci** commands and its **`## PR evidence`** section. Reuse them throughout. +- **If "plan first" (or working off an existing plan):** the plan of record is an **Atlas note** (`docs/atlas/src/content/atlas/<slug>.mdx`). **Load `/atlas` (Skill tool)** for the note mechanics — frontmatter, the component kit, `just atlas::build` + staging `dist/`, and the Code-tab + htmlpreview share links. Set `kind:` to match the §0 task (`bug`/`feature`; else `analysis`/`reference`) and `status: proposed`. The plan itself must: **(a)** stay **high-level** — user- and architecture-focused (what changes + the *shape*: seam, data flow, trade-offs and alternatives), with **no implementation dump** (no line-level code, file-by-file lists, or signatures; the *how* is §2's job); **(b)** carry a **UI prototype** (`<AtlasMockup>` or inline JSX) if the change has any on-screen surface, so the user judges look-and-feel before code; **(c)** **ground every load-bearing low-level fact against the installed code before asserting it** — staying high-level (a) does not license *guessing*. A pinned **dependency version** (read the lockfile, not the `^range`), a third-party library's **emitted markup / attribute / API shape**, a **test-environment strategy** (a unit env or a needed dep), a **framework runtime behavior** (e.g. *does a coarse SolidJS store reader coalesce same-shape deltas, or does Solid flush every write?* — a load-bearing reactivity/coalescing fact you **reproduce empirically against the installed source**, never deduce from first principles) — each is a fact the *how* in §2 will be built on, so verify the few the plan leans on the same way §2 gets ground truth (read the lockfile / the package's `vitest.config.ts` / the actual emitted DOM / a throwaway repro of the reactive path), don't recall it from training. A plan that asserts `marked-footnote@1.2.4 emits class="footnote-ref", test it under happy-dom` when the lockfile says `1.4.0`, the marker is a bare `data-footnote-ref`, and the package keeps a deliberate node-only env with no happy-dom is *wrong*, not merely detailed — it forces an implementation-time reconciliation and ships a false published note. **Self-check before presenting** — rework until all hold; don't make the user be the linter: high-level ✓, prototype-if-visual ✓, facts-grounded ✓, renders clean ✓. Then **push the branch** and **hand it over** for review via the Code tab *and* the htmlpreview link — do *not* use plan mode; wait for the user's reply, incorporate feedback (rebuild + push each round), and resume only on their go. This is the one sanctioned pause. **The plan ships in the PR.** *(A legacy `docs/plans/*.html` plan stays HTML — edit it in place.)* + +## 2. Implement + +**Honor the design philosophy first.** Before writing code, re-read `.claude/rules/conventions.md` → **Design philosophy** (fail-fast / no-fallbacks · electricity boundaries · reuse the existing source of truth) and state in the plan or PR body how this change honors each. A fallback path, a new override knob, a domain-agnostic helper folded into an app module, or a hand-rolled mechanism that duplicates an existing one (`.gitignore`, an extension/MIME table, a library) is a defect to fix now — not a follow-up the review gauntlet should have to catch. + +- **Bug:** reproduce *before* you theorize or fix — start from facts, not a story about the bug. **Where it runs: pu box, not locally** — building, running the repro (`just test-quick`/`just dev-auto`/a scripted repro), and any "let me SEE it" check are **heavy work**, and reproduction is the §5 venue gate fired early. Whenever `systemctl --user is-active kolu` is `active` (the normal case) that work belongs on an ephemeral pu box, never on the user's machine: a pile-up of local builds + e2e runs OOM-killed production `kolu.service` once, and a broad `pkill -f <substring>` to clean up OOM'd processes killed it again — its nix-store process matched the substring. **Load `/dev-server` §0 before launching/building/repro-ing anything**, and never `pkill -f` by any command substring — resolve PIDs by remembered port, or just let the pu box go. **(1)** Get ground truth from the running system; observe the real symptom, don't trust a description of it. **(2)** Pin the one hard, observable fact the bug produces — a wrong value, an error, a state that can't legally happen (e.g. "the client SHA stays `7deb397` across reloads"). **(3)** Build a reproduction that exhibits *that exact fact* and is **red on the current code** — a **failing e2e test** via the `/test` harness when it can express the bug, otherwise a scripted repro. A repro that *passes / converges / "works"* is **not** a reproduction: if it doesn't show the symptom the **repro** is wrong — fix the repro, never conclude "no bug" from it. **(4)** Only now fix, until that same repro flips green. No fix without a reproduction that was first red for the real reason. The fix must make the feature *work*, not disappear: disabling it, defaulting it off, or routing the affected platform onto a degraded path is the no-fallbacks violation from §2's design-philosophy clause wearing a bug-fix hat — a *mitigation*, not a fix, and a defect to reject now, never to ship or post as "verified." If the only remedy you can find removes or degrades the behavior, you haven't understood the bug yet — keep digging (fork the upstream dependency if that's what a real fix needs) before you settle. +- **Feature / new behavior:** write the covering test (e2e/integration/unit as fits) before or alongside the change. +- **Refactor/chore:** no test-first requirement; rely on existing coverage. + +**Sync the docs.** Read `.agency/do.md` for its **`## Documentation`** section — a *principle* (discover the stale docs, don't recall a checklist), **not** a fixed file list. Updating the README + Atlas and stopping there is the exact pattern-match-a-couple-and-skip-the-rest trap it warns against. So **grep every doc surface for the term you touched** — the command, flag, type, or word — across `README.md`, every `packages/*/README.md`, **`website/`** (the kolu.dev marketing pages, e.g. `src/pages/*.astro`, which hand-list commands and carry "next up is X" prose that goes false), and `docs/atlas/`. For **each** hit, either edit it or record why it's still accurate — "I updated the README" is not a doc-sync until the changed package's README and every user-facing marketing surface were each *grepped and resolved*. The docs commit rides the same review gauntlet as the code. Skip only when the change is genuinely doc-neutral. + +**Add a changelog entry.** For any **user-facing** change, append one line to `website/src/content/changelog/unreleased.mdx` under the right `###` heading — `Added` / `Fixed` / `Changed` / `Heads-up` (the editorial home for disruptive changes: a removed feature, a changed default, a migration). Create the heading if a freshly-reset section doesn't have it yet. Write it as prose a *user* reads, not a commit subject — no PR link yet (the PR doesn't exist until §3; you backfill the link there). Skip only when the change has no user-visible effect (pure refactor/chore/internal). The file is `merge=union`, so a plain append (or a new heading) never conflicts. + +Run **check** and **fmt**, then commit (conventional message) and push the feature branch. **`just check` (tsc + biome) green is not proof the shipped artifact *builds*** — when the change adds or edits a bundler/server entrypoint (a `vite.config.ts`, a `nix run` server wrapper, any module the real build loads) that **imports a workspace package**, tsc resolves extensionless imports that native ESM / the bundler will *reject*, so a clean typecheck can sit on top of a `vite build` / `nix run .#<pkg>` that doesn't build at all. For that kind of change the §5 venue gate fires early: actually run the real build on a pu box (`nix run .#<pkg>` / `vite build`), don't infer it from the typecheck. Leaving it for CI/evidence to surface is how a non-building entrypoint reaches the gauntlet. **The same is true of a dependency change**: the moment the change touches `package.json` / `pnpm-lock.yaml` (a `pnpm add`/`remove`/`update`), the recorded `fetchPnpmDeps` FOD hash in `nix/modules/typescript.nix` goes stale and **every** linux nix-build CI lane (`ci::pnpm-hash-fresh`, `ci::nix`, `ci::smoke`, …) reds at once — a guaranteed wasted CI cycle if it's left for §5 to surface. **Load `/nix-typescript` (Skill tool) and refresh the hash the instant the lockfile changes**, in the **background** (`nix build` takes minutes — kick it off and keep coding, per that skill), so the corrected hash rides this same commit. `just check` never catches this; only a real `nix build` does. + +## 3. Open the PR + +**Before any review** — so every reviewer's findings land as comments on a real PR. Load **`/forge-pr`** (Skill tool) and `gh pr create --draft` with a genuine title/body covering the scope so far. The PR exists for the rest of the run; later steps push commits and post comments to it. + +**Backfill the changelog PR link.** If §2 added a changelog entry, fill in its PR now that the number exists — set the **`pr={<n>}`** prop on the entry's `<Change title="…" pr={<n>}>…</Change>` (auto-injected into changelog MDX, so no import; it renders the GitHub-style PR chip). Then commit and push so the link rides this PR. Skip if §2 added no entry. + +**If there's a plan of record, finalize it now.** Once the PR URL exists, **finalize the Atlas note via `/atlas`**: set `status: implemented`, link the PR with `<PrLink pr={<n>} />`, rebuild + stage `dist/`, commit (`docs(atlas): link PR #<n>`) and push so it's part of this PR. *(A legacy `docs/plans/*.html` plan stays HTML — edit its status/PR link in place.)* + +## 4. Review gauntlet + +Run **`/be-review`** (Skill tool) — it runs four reviewers **serially**, each the +sole editor while it runs: `/lens-debate` applying the agreed fixes, then +`/codex-debate` (its per-round commits are the debate), then `/simplify`, then +code-police. Each step reads a clean tree (the previous step has committed) and +applies its own fixes directly — no snapshot, no apply pass. be-review pushes once +at the end and *then* posts the PR comments (lens, codex, and a code-police +summary), so no comment advertises a local-only commit. + +**This phase is non-negotiable, and it costs you almost nothing:** the reviewers run +OFF your context, as backgrounded `Workflow`s that notify you when they settle. So +"this would balloon my context / budget" is **never** grounds to skip a reviewer, run +fewer than all four, or substitute a hand-rolled review for the real gauntlet — that +excuse doesn't survive ten seconds of scrutiny, and dropping a step you were told to +run is the single worst gauntlet failure. `/be`'s autonomy means *don't ask permission +for each step*, NOT *decide which steps matter*. If a mandatory step is genuinely +infeasible, **STOP and ask the user** at that moment — never silently substitute and +disclose it later in the wrap-up. + +- Pass `base`, the change **`rationale`** (so the lenses don't flag deliberate + decisions), and **`context`** — the task intent and key decisions you hold from + this run, so the codex author **inherits what you know instead of re-deriving it + from the diff**. Preflight is a non-empty diff and (since codex runs) `codex login + status`. +- Lens-debate commits its agreed fixes; codex's rounds commit `fix(…)`; simplify + and code-police commit `refactor:` / `fix(police):`. Confirm the post-push PR + comments landed: lens, codex, and — when the police track ran — the code-police + summary. +- On an **unresolved** lens finding, adjudicate it yourself before moving on. + +**Performance pass.** If the diff touches a perf-sensitive surface (SolidJS +reactivity, the surface wire, the terminal/canvas render loop, timers/listeners, +the client bundle, or kaval), review it against the performance map — +`docs/atlas/src/content/atlas/performance.mdx` +([published](https://kolu.dev/atlas/performance.html)): don't regress a *banked* +win, and don't add a catalogued anti-pattern (an unstable memo reference or +coarse reactive dep, a visibility-blind timer, a full-set wire broadcast, an +eager heavy import). When the change **banks** an opportunity or **surfaces** a +new one, update that note via `/atlas` so the map stays current — measured, not +guessed (a faithfully-reproduced negative counts too). + +## 5. Ship — CI and evidence in parallel + +**Heavy work runs on a pu box, never locally — production kolu lives on this +machine.** Builds, the dev server, and evidence capture all go on an ephemeral pu +box whenever `systemctl --user is-active kolu` is `active` (the normal case). A +prior run piled local `just dev-auto` + nix builds beside a live production kolu +and the **OOM-killer `SIGKILL`ed production**; random ports dodged its *ports* but +not its *RAM*. Load **`/dev-server`** §0 for the local-vs-pu venue gate before +launching the app for *any* reason — including an interactive "let me SEE it" +check during §2. `/ci` and `/evidence` already run on pu; keep it that way. + +`/ci` and `/evidence` are independent — one exercises the build/test pipeline, the +other captures on-screen behavior — so **run them concurrently**; don't wait for +green before capturing. + +1. **Kick off `/ci` first, backgrounded** — start the pipeline so it churns while + you capture evidence. **Drive it through the odu MCP face, not a shelled-out + `nix run .#odu`:** when an odu MCP server is wired (the `mcp__odu__*` tools — + check before shelling out), every run *and every status/log check* goes through + it — `run` → `wait_for_settle` (fail-fast) → read the red node's log via + `ReadMcpResourceTool` on `surface://collections/logs/{id}` → `node_rerun`, per + the `/ci` skill. Reaching for `nix run .#odu -- run/status` while that server is + present is the fallback path, not the default. React to `failed`/`errored` nodes + the moment they land: fix→fmt→commit→retry on real failures, confirm green on + the final `HEAD`. + - **macOS (`aarch64-darwin`) CI host — pick by availability, in this order: + `nix-infra@rasam.tail12b27.ts.net`, then `sincereintent`.** Both are Apple-Silicon darwin builders; + `nix-infra@rasam.tail12b27.ts.net` is the primary and `sincereintent` the fallback. Before pinning the + darwin lane, probe them **in that order** — `tailscale status` (skip a host + shown `offline` / `last seen Nh ago`) plus a quick `ssh -o ConnectTimeout=8 + <user>@<host> true` — and pin the **first that answers** in `mcp__odu__run + hosts=["aarch64-darwin=<user>@<host>", …]`, noting in the report which host + served the lane. An unreachable host is an infra fault, never a lane to park + or call green: if `nix-infra@rasam.tail12b27.ts.net` is down, fall through to `sincereintent` and run the + lane yourself; only if **neither** answers is the darwin lane genuinely + blocked (report it as blocked — never silently drop the platform or report + green on a lane that never ran; an unreachable host is the no-fallbacks rule's + "a caught error must surface"). This live availability order is what to apply + even where `.agency/do.md`'s steady-state note still reads "rasam, not + sincereintent / sincereintent retired": that line is the default pin, this + ordering supersedes it the moment the primary is dark. + - **The same `nix-infra@rasam.tail12b27.ts.net → sincereintent` order governs *every* darwin lane this + run starts — including a downstream/companion repo's CI** (e.g. the drishti + PR a `@kolu/surface` change requires per `surface.md`). A consuming repo's + own `hosts.json` may name a *different*, possibly-dark darwin host (drishti's + `zest`); when it's offline you fall through to the **same** working + fallback. But that repo's CI is the shelled-out `nix run … odu -- run` + path, not `mcp__odu__run`, so pin the override with **`--host + aarch64-darwin=srid@sincereintent`** (per the `/ci` skill) — **never** by + exporting inline JSON into `$ODU_HOSTS`, which odu reads as a *file path*, + not a value: an inline `$ODU_HOSTS='{…}'` is **silently ignored**, the lane + falls back to the repo's on-disk `zest`, and you burn a full CI run on the + dead host. If you must set `$ODU_HOSTS`, write a real hosts *file* and point + at it; otherwise reach for `--host`. +2. **Concurrently, run `/evidence`** while CI runs — follow the **`## PR + evidence`** section of `.agency/do.md` for the capture procedure, then post the + result under `## Evidence`. For bug fixes, demonstrate the now-fixed behavior + even when there's no visual diff. Skip only if that section says to (or is + absent). +3. **Join before Done** — confirm CI is green on the final `HEAD` **and** evidence + is posted. If a CI fix-commit changed visible behavior *after* capture, + re-capture so the evidence matches what actually merges. **Tearing down any + daemon you spawned for capture (a local kaval / pulam dialer, an ssh tunnel) is + governed by `/dev-server` §5** — kill the PID you captured at spawn (`$!`), + **never** `pgrep -f`/`pkill -f` a socket-path/port substring: it matches the + production kaval/kolu daemon, not your dialer. Cheaper still: leave the ephemeral + test daemon for the user / OS rather than guess a PID. + +## Done + +Report the PR URL, the gauntlet outcome (lens-debate consensus + fixes applied, codex consensus or reviewer-error, police findings actioned), and CI status. Never merge — the human reviews the commits and merges when satisfied. + +**Then close the loop — run `/self-improve` (Skill tool), passing this run's `$CLAUDE_CODE_SESSION_ID`** so it can mine this session for recurring friction and turn it into a sharper skill-set. It runs **forked** (`context: fork`) so the whole analysis stays off your context — hence the explicit session id. It produces nothing unless a lesson durably recurs, ships any fix on its own draft PR (never this branch, never merged), and restores this branch — a clean, no-PR run is the common outcome. + +ARGUMENTS: $ARGUMENTS diff --git a/.agents/skills/ci/SKILL.md b/.agents/skills/ci/SKILL.md new file mode 100644 index 000000000..ec4196798 --- /dev/null +++ b/.agents/skills/ci/SKILL.md @@ -0,0 +1,195 @@ +--- +name: ci +description: Reference for the `odu` runner — how to invoke a full pipeline, a single recipe, or a platform-pinned node, and how to attach to a live run, from a project whose CI odu runs. Trigger when the user asks to "run CI", "run the pipeline", "re-run a check", to run named lanes or recipes (e.g. "run fmt and nix", "just the e2e lane", bare selectors like `fmt`/`nix`/`e2e`), or names a recipe by `<recipe>@<platform>`. This skill — not a repo's local `just ci` / `just <recipe>` — is how an odu-run request is served. +--- + +# odu + +[`odu`](https://github.com/juspay/odu) (Tamil ஓடு — "run") runs the `just` +recipe DAG tagged `[metadata("ci")]` across platforms and posts GitHub +commit statuses per `<recipe>@<platform>` context. Unlike batch runners, +the run is **live state you attach to**: the coordinator serves a typed +surface on `.ci/odu.sock`, so `status`/`logs`/`attach` are in-band — no +process-compose, no separately-versioned socket client. + +> **A request to run CI is a request to run `odu` — never `just ci`.** Many +> consuming repos expose a `just ci` (or `just <recipe>`) target that runs a +> pipeline locally. Do **not** shell out to it: it is a parallel, non-attachable +> path that bypasses everything odu gives you — the live surface, per-node GitHub +> statuses, structured results, fail-fast, `cancel`/`supersede`, and the log +> resources below. "run CI", "run fmt and nix", "re-run the e2e lane" all mean +> *drive an odu run*, by the MCP face first and the `odu` CLI otherwise. +> +> **Prefer the MCP face for runs.** When the `odu-mcp` skill is present (the +> `mcp__odu__*` tools — check for an odu MCP server before shelling out), drive +> runs through it — `run` (pass `selectors` for named lanes/recipes) → +> `wait_for_settle` (fail-fast) → read the red node's log → `node_rerun`, with +> `cancel` / `run({supersede})` to call off or replace a run. It spawns the same +> coordinator but gives you structured results and the fail-fast loop instead of +> scraping terminal output. The `nix run … -- run` CLI below is the reference and +> the fallback when no MCP server is wired. +> +> **Logs are a resource, not a tool.** Don't look for a log-tail tool — there +> isn't one. A node's output is the MCP **resource** `surface://collections/logs/{id}` +> (`{id}` is the node, e.g. `ci::unit@aarch64-darwin`), read with +> `ReadMcpResourceTool`: the live buffered tail while the run is up, else the +> durable per-SHA log on disk. So when `wait_for_settle` returns a red node, the +> "read the log" step is `ReadMcpResourceTool` on that node's +> `surface://collections/logs/{id}` — subscribe for push updates, or just re-read +> to poll. (`surface://streams/nodes` is the pipeline snapshot resource alongside +> it.) + +## Invoking + +```sh +nix run github:juspay/odu -- <subcommand> [args] +``` + +Pin a ref for reproducibility, or — if the consuming repo npins-pins odu +and re-exports it (kolu does) — prefer its own flake output so the version +is repo-controlled: + +```sh +nix run .#odu -- <subcommand> [args] +``` + +## Modes + +**Strict by default** — `odu run` refuses a dirty tree, pins `HEAD` via +`git worktree`, posts commit statuses, and splits per-recipe logs into +`.ci/<sha>/<plat>/<recipe>.log`. Three flags relax that policy: + +| Flags | Tree | HEAD pin | Status posts | Use for | +| --- | --- | --- | --- | --- | +| _(none — default)_ | clean (refuses dirty) | `git worktree` at HEAD | posted | "real" CI runs | +| `--no-post` | clean | `git worktree` at HEAD | _none_ | non-GitHub strict consumers; debugging strict without writing the PR's check list | +| `--no-snapshot` (implies `--no-post`) | live working tree | none | _none_ | strict-mode dev iteration without clean-tree refuse | +| `--no-strict` (meta — same as `--no-snapshot --no-post`) | live working tree | none | _none_ | dev iteration; the one-flag opt-out for "just run the pipeline" | + +Every mode ends with the same `── ci run summary @ <sha7> ──` verdict block +(the sha reads `<sha7>+dirty` for a live-tree run on uncommitted changes) +and exits non-zero if any node failed or errored. + +## Common invocations + +```sh +# Full pipeline (the [metadata("ci")] root, every configured platform). +nix run github:juspay/odu -- run + +# Dev iteration on a dirty tree: no clean-tree refuse, no HEAD pin, no posts. +nix run github:juspay/odu -- run --no-strict + +# Re-run a single failed recipe on one lane — overwrites the same GitHub +# commit-status context the full run wrote (closes the red check). +nix run github:juspay/odu -- run e2e@x86_64-linux + +# One recipe across every pipeline platform; selectors compose. +nix run github:juspay/odu -- run e2e lint + +# Restrict the WHOLE fanout to one platform (repeatable). +nix run github:juspay/odu -- run --platform x86_64-linux + +# Skip the dependency closure; run ONLY the named nodes (_ci-setup still rides). +nix run github:juspay/odu -- run --no-deps e2e@aarch64-darwin + +# A different DAG root instead of the [metadata("ci")] recipe. +nix run github:juspay/odu -- run --root ci::e2e + +# One-shot redirect of a platform's host (how a pool-lease wrapper pins a box). +nix run github:juspay/odu -- run --host x86_64-linux=my-build-box + +# One NDJSON line per node transition, for agents/tools driving CI: +# {"node":"ci::e2e@x86_64-linux","recipe":"ci::e2e","platform":"x86_64-linux", +# "status":"running|success|failed|skipped|errored","exit_code":1, +# "log":".ci/<sha7>/x86_64-linux/ci::e2e.log"} +nix run github:juspay/odu -- run --progress json +``` + +Without `--progress json`, output adapts to where stdout points: a live +colour lane-matrix with a log-tail footer on a TTY; quiet transition lines +plus a once-a-minute "… still running" heartbeat when piped. + +## Inspection subcommands (no side effects) + +```sh +nix run github:juspay/odu -- dump # resolved pipeline as JSON +nix run github:juspay/odu -- graph # dependency graph (Mermaid) +nix run github:juspay/odu -- protect --dry-run # the (recipe × platform) contexts +nix run github:juspay/odu -- protect # PATCH branch protection to them +``` + +## Live introspection (attach to a run in progress) + +While `odu run` is live in a checkout, these attach to its surface over +`.ci/odu.sock`: + +```sh +nix run github:juspay/odu -- status # snapshot; -o json for tooling +nix run github:juspay/odu -- attach # live TUI dashboard on a tty + # (digits attach · n/p cycle · + # r rerun · q quit); -o json + # = transition stream +nix run github:juspay/odu -- logs -f e2e@x86_64-linux +nix run github:juspay/odu -- cancel # stop the live run, cleanly +``` + +No run in progress ⇒ exit non-zero with `no run in progress in this +checkout (no live socket at .ci/odu.sock)`. One run per checkout — a +second `odu run` refuses while the socket is live. + +**Cancel / supersede / linger.** `odu cancel` drives the live run's teardown +from a second process (finalize posted statuses, close lanes, drop the socket) +and waits until it's gone — no need to wait out a doomed run or `pkill` the +coordinator. `odu run --supersede` cancels whatever's live here first, then +starts ("stop this, run the fixed commit"). By default a run exits the instant +it drains; `odu run --linger` keeps it serving past settle so a node can be +rerun later (retry a flake), self-reaping after an idle period or on `cancel`. + +## Hosts config + +`$ODU_HOSTS` (a file path) → `~/.config/odu/hosts.json` → fallback +`~/.config/justci/hosts.json` (zero-config migration from justci): + +```json +{ + "x86_64-linux": "my-linux-builder", + "aarch64-darwin": "me@mac-mini.local" +} +``` + +Keys are Nix system tuples; values are anything ssh dials, or `localhost` +(runs directly against the snapshot, no closure copy). Missing platforms +silently drop from the fanout. `--host PLAT=ADDR` overrides per run. + +A lane host needs only **ssh + Nix + outbound https**: the runner ships as +a Nix closure (`nix copy` → realise on the host), and the source arrives by +`git fetch` of the **pushed** SHA — remote lanes cannot test unpushed +commits (no git-bundle transport; push first). The lane host's own nix is +used on the runner's PATH (never a pinned client — version skew against the +host daemon corrupts CA-derivation handling). + +## Semantics worth knowing + +- **Lanes are one-shot**: a lane whose ssh link dies mid-run fails as + `errored` (GitHub state `error`, `Errored (<dur>)` description); live + state does not survive a runner restart — the per-SHA log files do. +- **Skipped nodes post no status**: an absent required context is what + blocks the merge. +- The coordinator resolves the **generic lane runner from odu's own flake**, + not the repo under test: + `nix eval $ODU_RUNNER_FLAKE#packages.<platform>.odu-runner.drvPath`, where + `ODU_RUNNER_FLAKE` is baked onto the `odu` wrapper from `self.outPath` at + build time. A consuming repo no longer re-exports `odu-runner`. There is no + override or fallback — the runner is the exact build that shipped the + coordinator (they share an RPC contract); a binary built without the baked + flake refuses to run. + +## When NOT to use this skill + +- Questions about odu's internals or design history — read the + [README](https://github.com/juspay/odu/blob/master/README.md) and the + kolu Atlas note + [*A CI runner you attach to*](https://github.com/juspay/kolu/blob/master/docs/atlas/dist/mini-ci-vs-justci.html). +- Project-specific CI operations (warm pools, host leases, banned flags) + — that's the consuming repo's operational docs, layered on top of this + reference. diff --git a/.agents/skills/code-police/SKILL.md b/.agents/skills/code-police/SKILL.md index 4883b148c..7455f51ae 100644 --- a/.agents/skills/code-police/SKILL.md +++ b/.agents/skills/code-police/SKILL.md @@ -3,12 +3,17 @@ name: code-police description: Review code for quality, simplicity, and common mistakes before declaring work complete. context: fork model: sonnet +argument-hint: "[--no-elegance]" --- # Code Police Review the current changes (scoped to the current branch/PR) against the rules below **plus any additional rules from the project**. The three passes — rule checklist, fact-check, elegance — run as parallel sub-agents on fresh contexts; the implementer's main context just wrote the diff and is biased to rationalize it, so reviewing inline laundered violations through. Sub-agents start cold, which is the point. After they return, the orchestrator stitches their findings into a single summary. +## Arguments + +`--no-elegance` — skip Pass 3 (elegance) entirely. Pass 1 (rules) and Pass 2 (fact-check) still run. Use this when the elegance pass would be redundant because `/simplify` already ran over this same tree — e.g. a caller that invokes `/simplify` standalone and then `/code-police`. Without it, Pass 3 re-invokes `/simplify` on an already-simplified tree, paying a full skill invocation (agent spawn, diff re-read, model tokens) to re-derive a near-guaranteed no-op. When the flag is set, report Pass 3 as `Elegance | – | Skipped (--no-elegance)` in the summary. + ## Project rules Before spawning the pass sub-agents, read `.agency/code-police.md` if it exists. Treat any rules declared there — whether inline or as a pointer to another file (`See ./code-police-rules.md`) — as additions to the built-in rules below. They appear as separate rows in the Pass 1 checklist with the project's chosen rule IDs. @@ -82,7 +87,7 @@ Spawn Pass 1 and Pass 2 as **two parallel sub-agents** via the harness's agent t Each sub-agent inherits no context from the implementer's main thread; the prompts below are self-contained and reference the rules-of-record by file path so a single source of truth stays in this skill. -Pass 3 runs **after** Pass 1 and Pass 2 return. It applies fixes (via `/simplify`) and would race against Pass 1/2's grep-and-read work if run in parallel; sequential is the safer ordering. See "Pass 3: Elegance" below. +Pass 3 runs **after** Pass 1 and Pass 2 return. It applies fixes (via `/simplify`) and would race against Pass 1/2's grep-and-read work if run in parallel; sequential is the safer ordering. See "Pass 3: Elegance" below. If `--no-elegance` was passed, skip Pass 3 — only Pass 1 and Pass 2 run. Once all three pass outputs are in hand, stitch them into the summary table in the **Output** section. @@ -138,6 +143,8 @@ Sub-agent prompt: ### Pass 3: Elegance +**Skip if `--no-elegance` was passed.** Do not run this pass and do not invoke `/simplify`; report `Elegance | – | Skipped (--no-elegance)` in the summary. The caller asserted `/simplify` already ran over this tree, so a second run is redundant. Pass 1 and Pass 2 are unaffected. + **Skip on tiny diffs.** Run `git diff origin/HEAD...HEAD --shortstat` (or the appropriate base-branch ref). If the diff is **under 10 lines**, skip this pass and report `Elegance | 0 | Skipped (tiny diff)` in the summary. The elegance pass's three-lens fan-out has overhead that's disproportionate to a few-line change; Pass 1 and Pass 2 still run. If the diff exceeds the threshold, proceed below. Review the changes for elegance and simplicity. diff --git a/.agents/skills/codex-debate/SKILL.md b/.agents/skills/codex-debate/SKILL.md new file mode 100644 index 000000000..b54081444 --- /dev/null +++ b/.agents/skills/codex-debate/SKILL.md @@ -0,0 +1,572 @@ +--- +name: codex-debate +description: 'Run an automated codex⇄Claude debate to consensus — no round cap, no deadlock exit. Two explicit subcommands. `review` (also the bare/back-compat default) — codex (reviewer) critiques the current diff and a Claude subagent (author) fixes/disputes, looping until they agree. `answer` — Claude and codex each answer a freeform prompt in parallel, then cross-check until they agree, and a unified answer is returned. Use when the user types `/codex-debate`, asks to "have codex review this", "run the codex debate", "review this PR with codex", "argue this with codex until you agree", or passes a question to "have Claude and codex debate/answer until they agree".' +argument-hint: "review [<pr-number>] [--base <branch>] [--no-commit] [--no-comment] [--rationale <note>] [--context <note>] | answer \"<prompt>\"" +--- + +# Codex ⇄ Claude debate + +This skill runs an automated debate between **codex** and **Claude** that loops to +consensus with no round cap and no deadlock exit. It has **two modes**, selected by +an **explicit leading subcommand** — never by guessing from the argument's shape: + +- **`review`** — codex reviews the current diff, a Claude author fixes/disputes, + round after round until they agree, and the trail is **committed + posted to the + PR** (a mutating, outward-facing mode). This is everything from + [Review mode](#review-mode) down. +- **`answer`** — Claude and codex **each answer a freeform prompt in parallel**, + then **cross-check each other** until both agree, and a **unified answer** is + returned to you (read-only; plus a saved transcript). See + [Answer mode](#answer-mode). + +The two modes have **different side-effect contracts** (review mutates + writes to +the PR; answer is read-only), so the mode is chosen **explicitly**, not inferred +from whether the argument looks like a PR number or like prose. Inferring a +mutating action from prose shape is exactly the coupling this design avoids. + +## Mode detection (do this first) + +Look at the **first whitespace-delimited token** of `$ARGUMENTS`: + +- **`answer`** → **answer mode**. The prompt is everything after the `answer` + token. Jump to [Answer mode](#answer-mode); the review-mode steps do not apply. +- **`review`** → **review mode**. The remaining args are the review grammar + (`[<pr-number>] [--base …] [--no-commit] [--no-comment] [--rationale <note>] + [--context <note>]`). Continue with [Review mode](#review-mode). +- **No args, OR the first token is a number (a PR number) or a `--flag`** → + **review mode** (the backward-compatible bare alias for the original + `/codex-debate [<pr>] [flags]`, so existing callers like `/be-review` keep + working). Continue with [Review mode](#review-mode). +- **Anything else** (freeform prose with no recognized subcommand) → **ambiguous**. + Do **not** guess — ask the user to pick an explicit mode and stop, e.g.: "Did you + mean `/codex-debate answer \"<your prompt>\"` (read-only) or `/codex-debate review + [<pr>] [flags]` (mutating)?" Only the safe, backward-compatible review grammar + auto-routes; prose never silently triggers a mode. + +Both modes require Claude Code's **`Workflow` tool** (the engine). Under +codex/opencode runtimes the skill is inert. + +<a id="review-mode"></a> +# Review mode — Codex ⇄ Claude review debate + +Automate the back-and-forth you'd otherwise courier by hand: **codex** (the +reviewer) critiques the current change, a **Claude subagent** (the author) +fixes what it agrees with and disputes what it doesn't, codex re-reviews, and so +on — round after round, **until they reach consensus**. codex reviews from a +**warm session**: round 1 cold-starts the reviewer, and every later round +*resumes that same codex session* (`codex exec resume`), so codex carries its own +prior review and reasoning forward instead of reconstructing it from the diff + +rebuttal each round — when Claude disputes a finding, codex argues from its +original rationale. There is no round cap and +no "deadlock" surrender: a debate that quits without agreement defeats the +purpose, so the two sides keep arguing until one concedes. You stay out of the +middle: each round lands as its own commit whose +message carries the debate context (codex's findings + Claude's dispositions) so +the PR history reads as the debate, and the summary is **posted to the PR** as a +comment at the end. + +## Why this shape + +The two sides are asymmetric, and that asymmetry is the whole design: + +- **codex** is CLI-invokable headlessly (`codex exec`, authed via ChatGPT), so it + runs from a shell command. +- **Claude on a Max plan is *not* headless** — `claude -p` doesn't work with Max + auth. But the **Workflow tool's `agent()` spawns Claude subagents through the + harness**, not `claude -p`, so it works. That subagent is the author side. + +So the debate runs as a Workflow: `agent()` is Claude, a Bash-invoked +`codex exec` is the reviewer, and the script couriers structured verdicts +between them and decides when they agree. Both sides are forced to emit +schema-constrained JSON, so consensus is detected in code, not by vibes. + +**This skill requires Claude Code's `Workflow` tool** (it is the engine). Under +codex/opencode runtimes the skill is inert. + +## Arguments + +A leading `review` subcommand token, if present, is consumed by mode detection; +what remains is `[<pr-number>] [--base <branch>] [--no-commit] [--no-comment] +[--rationale <note>] [--context <note>]` (the bare alias passes the whole argument +string through unchanged). Parse: + +- **`<pr-number>`** (optional): a PR to debate. If given, `gh pr checkout <n>` + first and default the base to that PR's base branch. If omitted, debate the + **current branch's** working-tree diff. +- **`--base <branch>`**: ref to diff against. Always a **remote-tracking ref**, never + a stale local branch. Default: `origin/<PR base>` when a PR number is given, else + the repo default branch as `git symbolic-ref --short refs/remotes/origin/HEAD` + (e.g. `origin/master`) — used **as-is**, NOT stripped to local `master` (which + can lag the remote). Fallback `origin/master`. Step 1 runs `git fetch origin` + first so the ref is current. The workflow then resolves this to the **merge-base** + of `base` and HEAD and diffs against that, so commits `base` gained since the + branch forked aren't reviewed as part of this change. +- **`--no-commit`**: don't commit per round — leave all agreed changes + uncommitted in the working tree for you to commit yourself. Default is to + **commit each round** (see below). +- **`--no-comment`**: don't post the debate summary to the PR. By **default**, when + a PR exists, the debate summary IS posted as a PR comment (see step 3). Pass + this to suppress the outward-facing write and report in chat only. +- **`--rationale <note>`** (optional): the author's note on **deliberate** design + decisions. Threaded into **both** sides — codex's round-1 review prompt (so it + doesn't flag intentional choices as defects; its warm session carries the note + across later rounds) and the Claude author's prompt **every round** (so it + *disputes*, rather than "fixes", a finding that contradicts a deliberate choice). + Mirrors `/lens-debate`'s `rationale`. Pull it from the PR/issue description, or the + caller (`/be-review`) passes the change rationale straight through. +- **`--context <note>`** (optional): the **main-agent context** the Claude author + should **inherit** — what this change is FOR (its task/intent and key decisions the + orchestrator already holds). Injected into the author's prompt **every round** so it + no longer reconstructs intent from the diff alone (`agent()` is one-shot and can't + be resumed the way codex is, so re-injection is how it inherits at all). Given to + the **author only**, not codex — codex stays an independent reviewer of the code, + not the author's narrative. `/be-review` passes the task context through. + +## Steps + +### 1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- **`git fetch origin`** so remote-tracking refs are current — the base is an + `origin/...` ref, and a stale one would diff against the wrong tree. +- Resolve `base` per the rules above (a remote-tracking ref like `origin/master`). +- If a PR number was given, `gh pr checkout <n>` and confirm the branch. +- Confirm there is a non-empty diff: `git diff --stat <base>`. If empty, tell the + user there's nothing to review and stop. +- **Preflight codex**: `codex login status`. If not logged in, stop and tell the + user to run `codex login` (suggest the `!` prefix to do it in-session). + +### 2. Run the debate Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed script, passing +context through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/codex-debate/debate.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + base: "<base branch>", + commit: <false only if --no-commit>, + skillDir: ".claude/skills/codex-debate", + context: "<main-agent context the author inherits; omit/'' if none>", + rationale: "<author's note on deliberate decisions; omit/'' if none>" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It +alternates `codex:roundN` and `claude:roundN` agents under a **Debate** phase — +the user can watch live via `/workflows`. Each Claude round edits the working +tree and (unless `--no-commit`) **commits exactly that round's changed files in +the same session** — one commit per round, with a message embedding the round's +codex findings and Claude's dispositions — never pushing or merging. (The commit +is no longer a separate `commit:roundN` agent: the author already has the tree +open, so it commits its own round.) + +Ephemeral scratch (verdicts, the debate ledger) lives under the gitignored, +per-worktree `<repoPath>/.codex-debate/`, so **parallel debates in different +worktrees never collide** and the scratch never shows up in the diff codex +reviews. It returns: + +``` +{ status: "consensus" | "commit-incomplete" | "section-incomplete" | "reviewer-error", + rounds, base, finalVerdict, filesChanged, commitGaps, sectionGaps, transcript, + commentHeader, // the comment's small deterministic header (badge + round count + effort + base) + workDir, sectionGlob } // where the per-round section files live; cat them under the header (step 3) +``` + +(each `transcript[]` round also carries a `commit` SHA when that round committed; +`commitGaps` lists the round numbers whose author edited files but returned no +commit SHA — empty unless `status === "commit-incomplete"`; `sectionGaps` lists the +round numbers whose author left no disposition section file — empty unless +`status === "section-incomplete"`.) + +There is one more, **earlier** terminus the workflow can return **before** the +debate loop even starts — `merge-base-error`. If `git merge-base <base> HEAD` +fails (a missing/typoed/stale base, or unrelated history), the diff scope can't be +trusted, so the workflow **aborts up front** rather than review the base branch's +drift as if this change made it. It returns a **different, smaller shape** — no +`commentHeader`, `workDir`, or `sectionGlob`, because no debate (and so no section +files) ever ran: + +``` +{ status: "merge-base-error", base, rounds: 0, transcript: [], finalVerdict: null, + note } // human-readable: which base failed and how to fix it (e.g. `git fetch`) +``` +The debate is recorded as small Markdown section files under `<workDir>` — **two +per round**: `section-NNN-1-codex.md` (codex's verdict + findings) and +`section-NNN-2-claude.md` (the author's per-finding dispositions), zero-padded so +the `section-*.md` glob sorts in chronological order. **Each file is written by the +party that owns its content**: a Haiku writer renders codex's small *structured* +verdict to disk, and the **author writes its own dispositions directly** — the same +way codex writes its verdict to a path. That author-writes-its-own-file design is +deliberate: it keeps the author's *structured* return a **minimal ack** +(`filesChanged`/`commitSha`/`done`), so a big multi-finding narrative can never +overflow the structured-output encoding (the failure that used to crash the debate +on large diffs). The author's disposition file does triple duty — it's the author's +cross-round **memory**, the **rebuttal** codex reads next round (`codex-review.sh` +cats it straight into codex's prompt), and the **published comment** (step 3 cats +the section files under `commentHeader`). So the comment is still a **deterministic** +render — a shell `cat`, never re-improvised through an agent, nothing weak ever +retyping a large blob. codex is *not* a memory reader — it keeps its own warm +session and only ever reads the one rebuttal file. + +- **consensus** — every finding codex raised is resolved (any severity — Claude + fixed it or codex conceded the dispute). This is the *only* way the debate ends + *normally*: it keeps running rounds until codex and Claude agree on every point, + with no round cap and no deadlock exit. (The harness's own + per-workflow agent backstop is the sole hard ceiling; if you ever need to stop + a debate by hand, interrupt it via `/workflows` or `TaskStop`.) +- **commit-incomplete** — the debate *converged* (codex approved, nothing open), + but a round's author edited files yet returned **no commit SHA**, so its + in-session commit didn't land and the "one commit per round" contract broke for + the round(s) in `commitGaps`. The edits are **not lost** — they stay in the + working tree and the next reviewer diffs them against the base — but this is + **not** a clean consensus: a human must reconcile the uncommitted round(s) + (e.g. commit the outstanding tree) before relying on the per-round history. Do + **not** report it as a plain consensus (see step 3). +- **section-incomplete** — the debate *converged*, but a round's author **skipped + or under-filled its disposition section file** (`section-NNN-2-claude.md` missing, + empty, or missing a backticked marker for an open finding, for the round(s) in + `sectionGaps`). That file is the hole-free trail everyone draws on — + the author's memory, the rebuttal codex reads next round, and part of the posted + comment — so a miss means the published record has a gap. The code guards against + feeding an empty rebuttal to codex (it warns and keeps the prior pointer), and the + tree edits are still present, but this is **not** a clean consensus: a human must + fill in the missing round(s) before trusting the per-round record. Do **not** + report it as a plain consensus (see step 3). +- **merge-base-error** — an *up-front* abort, **before** any debate round runs: + `git merge-base <base> HEAD` failed (missing/typoed/stale base, or unrelated + history), so the review scope can't be trusted. The return carries a human-readable + `note` (which base failed, how to fix it — e.g. `git fetch`) and **none** of the + comment-assembly fields (`commentHeader`/`workDir`/`sectionGlob`), because no + section files were ever written. Report the scope failure and **skip comment + assembly/posting entirely** (see step 3); fix the base ref and re-run. +- **reviewer-error** — the one *abnormal* terminus: codex itself failed to + produce a verdict (broken/unavailable CLI), so the workflow synthesized an + error verdict and aborted rather than spin forever on a dead reviewer. This is + **infrastructure failure, not a debate outcome** — `finalVerdict.summary` + carries the failure detail (including how many attempts were made). Do **not** + treat it as consensus (see step 3). **Transient failures are retried first:** + `codex-review.sh` retries the `codex exec` invocation with linear backoff + (default 3 attempts; tune via `CODEX_REVIEW_RETRIES` / `CODEX_REVIEW_BACKOFF`) + and only synthesizes the reviewer-error verdict once every attempt comes back + empty — so a single codex hiccup no longer sinks the round. + +### 3. Present the result + +**First branch on `status`.** If `status === "merge-base-error"`, the workflow +**aborted before any debate ran** — `git merge-base <base> HEAD` failed, so the +review scope couldn't be trusted. This return has **no** `commentHeader`, +`workDir`, or `sectionGlob` (no section files exist), so there is **nothing to +assemble**: do **not** run the posting block below. Report the scope failure — +surface the return's `note` (it names the failing base and the fix) — tell the +user to repair the base ref (e.g. `git fetch`, fix a typo'd/stale ref) and re-run, +and **skip the rest of this section**. + +If `status === "reviewer-error"`, the debate did +**not** reach consensus — codex never produced a real verdict. Report it as a +**failure**, not a success: surface `finalVerdict.summary` (and the workflow log) +so the user sees codex was broken/unavailable, and tell them to fix codex (e.g. +`codex login`, check the CLI) and re-run. Do **not** post a consensus badge or a +`## Codex ⇄ Claude debate` PR comment for this path — there is no agreement to +report. Skip the rest of this section. + +If `status === "commit-incomplete"`, the debate converged but at least one round +left its edits **uncommitted** (the round numbers are in `commitGaps`). Report it +as **converged-but-not-clean**: assemble and post the comment (see the posting +block below — `commentHeader` already shows a `⚠️` badge, not the consensus +check), then tell the user which round(s) are uncommitted and that the outstanding +tree must be committed before the per-round history can be trusted. Do **not** call +it a clean consensus. + +If `status === "section-incomplete"`, the debate converged but at least one round's +author **skipped or under-filled its disposition section file** (missing, empty, or +omitting a marker for an open finding; round numbers in `sectionGaps`). +Report it as **converged-but-not-clean**: assemble and post the comment as usual +(the `⚠️` badge is already set), then tell the user which round(s) are missing +their disposition record and that the per-round history has a gap a human should +fill before trusting it. Do **not** call it a clean consensus. + +Otherwise (`status === "consensus"`) report in chat (do **not** push or merge — +the per-round commits sit on the local branch for the human to review): + +- The outcome — **consensus** — and how many rounds it took to get there. +- **The reviewer's reasoning effort** — sourced from the workflow's single + `REASONING_EFFORT` constant (`xhigh` today), which is passed down to + `codex-review.sh`'s `-c model_reasoning_effort` and into the comment header, so + the published value and the config codex actually ran at share one home. Read + it off the header rather than asserting it independently. State it so the depth + of the review is on the record. +- `git log --oneline <base>..HEAD` (the per-round debate commits) and + `git diff --stat <base>` so the user sees what the debate changed. +- A compact per-round summary — read it straight from the section files + (`cat <workDir>/section-*.md`: each round's codex verdict, then the author's + dispositions and commit SHA) so the convergence reads round by round. No need to + re-derive it from `transcript`; the sections already render it. +- The agreed changes are committed per round on the local branch (or, under + `--no-commit`, uncommitted in the working tree). The user reviews, then pushes + / merges (or runs `/do --from post-implement`) when satisfied. +- **Post the debate summary to the PR (default).** When a PR exists and + `--no-comment` was NOT passed, **assemble** the comment from the workflow's + return — the small `commentHeader`, then the per-round section files `cat`-ed in + glob order — and `gh pr comment <pr> -F <file>`: + + ```bash + mkdir -p "$workDir" # reviewer-error/--no-commit runs may not have created it + { + printf '%s\n' "$commentHeader" + for f in "$workDir"/section-*.md; do printf '\n'; cat "$f"; printf '\n'; done + } > "$workDir/comment.md" + gh pr comment <pr> -F "$workDir/comment.md" + ``` + + (`$workDir` is the returned `workDir`, i.e. `<repoPath>/.codex-debate`; the + `for`-loop guarantees a blank line between sections regardless of each file's + trailing newline.) The result is the `## Codex ⇄ Claude debate` header (consensus + badge, round count, the **reasoning-effort** note from the workflow's + `REASONING_EFFORT` constant) followed by the per-round breakdown of codex's + findings and the author's dispositions — the **same** section files the author + read as memory and codex read as the rebuttal. So the comment is a + **deterministic** shell concat of the record everyone drew on, not an + LLM-improvised table — nothing weak ever retypes a blob. This is an outward-facing + write — on by default because the whole point is to leave the review trail on the + PR; `--no-comment` suppresses it. + +<a id="answer-mode"></a> +# Answer mode — Codex ⇄ Claude answer debate + +When the argument is a **freeform prompt** (not a PR number/flags), the skill +generalizes the same debate machinery from *reviewing a diff* to *answering a +question*. The shape is **symmetric**, not author⇄reviewer: **Claude and codex are +two equal peers**. They each answer the prompt **independently and in parallel**, +then **cross-check each other's answer** round after round — conceding where the +other is right, holding firm (with evidence) where it isn't — **until both agree**. +A final pass **synthesizes their two converged answers into one unified reply**, +which you present to the user along with a saved transcript. + +Both peers are **codebase-aware but read-only**: each may read this repo (`git +diff/log`, read files, grep) to ground its answer, but neither edits anything — +codex stays under `--sandbox read-only` (kernel-enforced), and the Claude peer is +instructed not to write. Consensus is **schema-detected in code**: each side emits +a structured answer with an `agreesWithOther` boolean and an `objections` list, and +the loop ends only when **both** sides report no remaining disagreement. There is +**no round cap and no deadlock exit** — same as review mode. + +## Steps + +### A1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- Capture the **prompt**: everything **after the `answer` subcommand token** (strip + surrounding quotes). If it's empty, ask the user what they want answered and stop. +- **Preflight codex**: `codex login status`. If not logged in, stop and tell the + user to run `codex login` (suggest the `!` prefix to do it in-session). +- No `git fetch` / base resolution / `gh pr checkout` here — answer mode doesn't + diff a branch. + +### A2. Run the answer Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed answer script, +passing the prompt through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/codex-debate/answer.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + prompt: "<the user's freeform prompt, verbatim>", + skillDir: ".claude/skills/codex-debate" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It runs an +**Answer** phase (round 1: `claude:round1` and `codex:round1` in parallel), a +**Reconcile** phase (rounds 2+: each side cross-checks the other, in parallel, +round after round), and a **Synthesis** phase that merges the two agreed answers. +Watch live via `/workflows`. Ephemeral scratch (per-side answers, cross-check +files, per-round sections, the saved transcript) lives under the gitignored, +per-worktree `<repoPath>/.codex-debate/`, so parallel debates never collide. It +returns: + +``` +{ status: "consensus" | "reviewer-error" | "agent-error" | "synthesis-error" | "no-prompt", + rounds, prompt, finalAnswer, transcriptPath, reasoningEffort, codexError } +``` + +- **consensus** — the only normal terminus: both sides agreed and then both + **approved the synthesized candidate** (see the convergence note), and + `finalAnswer` is that approved unified answer. `transcriptPath` points at the saved + Markdown transcript (`.codex-debate/answer-<slug>.md`). +- **reviewer-error** — codex itself failed to produce an answer (broken/unavailable + CLI) after retries; `codexError` carries the failure detail. Infrastructure + failure, not a debate outcome. +- **agent-error** — one side died on a terminal API error after retries. +- **synthesis-error** — both sides DID agree, but the final synthesis pass produced + no answer (the synthesis agent died or returned empty). Not a successful answer — + report it as a failure (there is agreement on record, only the merge failed). +- **no-prompt** — the prompt was empty (shouldn't happen if A1 guarded it). + +### A3. Present the result + +- If `status === "consensus"`: present **`finalAnswer`** to the user as the answer + — this is the unified reply both Claude and codex agreed on. State **how many + rounds** it took to converge and that **codex answered at `reasoningEffort`** + (read it off the return value). Point the user at the saved transcript + (`transcriptPath`) for the full convergence trail; optionally `cat` the + `.codex-debate/answer-section-*.md` files to show a compact per-round summary + (each side's answer, what changed, remaining objections). This mode makes **no + outward-facing writes** — no PR comment, no commits — it just answers. +- If `status !== "consensus"`: report it as a **failure**, not an answer. Surface + `codexError` (for `reviewer-error`) or the workflow log so the user sees what + broke, and tell them how to fix it (e.g. `codex login`) and re-run. Do **not** + present a half-debate as if it were an agreed answer. + +## Answer-mode safety & notes + +- **Both peers read-only — but enforced ASYMMETRICALLY.** codex runs under + `--sandbox read-only` (kernel-enforced, belt-and-suspenders with the prompt text — + it reads arbitrary repo files and could be prompt-injected). The **Claude peer is + only prompt-enforced**: the harness's `agent()` exposes no sandbox/tool restriction + (the same is true of every Claude reviewer in `/lens-debate` and review mode), so + Claude's read-only behaviour rests on instruction, not a kernel guard. A + prompt-injected or mistaken Claude agent *could* in principle edit files or run a + git write — answer mode does not, and cannot here, harden against that the way it + does for codex. If that risk matters for a given prompt, run the debate in a + disposable/read-only worktree. Treat the read-only guarantee as **hard for codex, + best-effort for Claude.** +- **Warm codex session.** Round 1 cold-starts `codex exec`; every later round + resumes the same session (`codex exec resume`) so codex cross-checks from its own + prior answer rather than reconstructing it. The session id lives in the + gitignored per-worktree `.codex-debate/` (a distinct `codex-answer-session.id`, + so it never collides with review mode's session), degrading gracefully to a cold + start if capture ever fails. +- **Symmetric convergence, schema-detected, candidate-confirmed.** Each side emits + `agreesWithOther` + `objections`; a side counts as agreeing only when it sets + `agreesWithOther:true` AND leaves no objection, so a stray objection can't be + papered over by an over-eager boolean. Because the two run in parallel each round, + a single mutually-agreeing round can be a **swap false positive** (Claude adopts + codex's prior answer while codex adopts Claude's — both report agreement, but their + current outputs are swapped and still differ), and they can keep swapping back and + forth, so counting consecutive parallel agreements does **not** prove the current + outputs match. The only sound test is to make both sides judge **one shared piece + of text**. So when a round shows mutual agreement, the workflow synthesizes a single + **candidate** from the two agreed answers and runs a **confirmation phase**: both + sides review that *identical* candidate (without rewriting their own answer) and + either approve it or object. Approval is on one fixed text both actually saw, so no + swap is possible; if both approve, that candidate is the converged answer — already + signed off by both debaters (which is also why `finalAnswer` is never unapproved + synthesized text). If either objects, the candidate is dropped and the cross-check + loop resumes with the objections folded in. No round cap, no deadlock exit. +- **Chat + saved transcript, no outward writes.** The unified answer is presented + in chat and the full transcript is saved to the gitignored + `.codex-debate/answer-<slug>.md`. Unlike review mode, answer mode never commits + or posts to a PR. + +## Safety & notes (review mode) + +- **codex runs read-only — enforced, not just asked.** codex is invoked with + `--sandbox read-only`, so the kernel sandbox blocks file writes and other + state-mutating syscalls; the prompt's "don't write" instruction is belt-and- + suspenders, not the only guard. This matters because codex reviews arbitrary + diffs and could be prompt-injected by file contents. The only writes to the + tree come from the Claude author rounds. (codex auto-falls-back to its bundled + bubblewrap when the system one is absent, so read-only works in containers.) + Resume rounds enforce the same read-only policy via `-c sandbox_mode=read-only` + (the `resume` subcommand has no `--sandbox` flag) — same kernel guard, set + through config instead of the flag. +- **Warm reviewer session.** Round 1 cold-starts `codex exec`; the runner records + codex's session id (its `thread_id`, captured from the `--json` event stream) + under the scratch dir and every later round `codex exec resume`s it, so codex + retains its own prior review across rounds. The session id lives in the + gitignored per-worktree `.codex-debate/`, so parallel debates never resume each + other's sessions. If the id is ever missing (round-1 capture failed), a later + round transparently cold-starts with the full prompt + rebuttal — graceful + degradation, never a wedge. +- **Warm author (context, not session).** The Claude author can't be resumed the + way codex is — `agent()` is one-shot and Claude isn't headless under Max auth, + so there's no session id to carry forward. The equivalent is context, not state: + each follow-up round the author **reads the per-round section files** + (`cat .codex-debate/section-*.md`) — every prior round's codex findings and its + own dispositions — so it builds on its last round rather than re-deriving the + whole diff, and won't re-fix or re-litigate findings already settled. Each round + writes two small files (a Haiku-rendered codex section and the author's own + disposition section), so round N>1 always sees rounds 1..N-1; round 1 has none + yet, so it's byte-identical to a cold start (and if no sections exist, the author + falls back to the diff + verdict). The *same* sections compose the PR comment step + 3 posts and the rebuttal codex reads, so the author's memory, the published + summary, and codex's rebuttal are one record. Crucially, the author **writes its + own disposition section directly** — it never has to pour that narrative through a + structured field — so its structured return stays a minimal ack and can't overflow + on a large, many-finding diff (the failure this design replaced). The Haiku writer + only ever renders codex's small *structured* verdict, so nothing weak retypes a + large blob. codex stays on its own warm session and never reads the sections — + only the one rebuttal file each round. +- **Inherited context, not just diff.** On top of that cross-round memory, when the + caller passes `context` and/or `rationale` the author **inherits them in EVERY + round's prompt** — the main-agent intent (what the change is FOR) and the + deliberate-decision note. So even **round 1** reasons from the change's purpose + rather than the diff alone, and the author *disputes* a finding that contradicts a + deliberate choice instead of dutifully "fixing" it. The `rationale` also rides + codex's round-1 prompt (see `codex-review.sh`), so the reviewer doesn't raise those + intentional choices at the source; `context` is the author's alone (codex stays an + independent reviewer of the code, not the narrative). +- **Commits, but never pushes or merges.** Each round is committed locally (unless + `--no-commit`) so the PR history reads as the debate, but the skill never + pushes or merges. Consensus means "both AIs agree on the committed code," not + "ship it" — the human reviews the commits and pushes/merges. +- **Parallel-safe.** Ephemeral scratch (verdicts and the per-round section files, + the author-written ones doubling as the rebuttal) lives under the gitignored, + per-worktree `<repoPath>/.codex-debate/`, so debates on many worktrees run at once + without clobbering each other — no shared `/tmp` paths, and each worktree's section + files are its own. +- **Posts to the PR by default.** When a PR exists, the debate summary — the + `commentHeader` followed by the per-round section files `cat`-ed together (step 3) + — is posted as a PR comment (outward-facing write) unless `--no-comment` is passed + — the point is to leave the review trail on the PR. +- **Runs to consensus — no cap, no deadlock exit.** The loop ends only when codex + and Claude agree; it does not bail out at a round cap or declare a "deadlock," because + a debate that quits without agreement is pointless. The two sides keep arguing + until one concedes. The harness's own per-workflow agent backstop is the sole + hard ceiling; interrupt via `/workflows` or `TaskStop` if you ever need to stop + one by hand. **The one carve-out is *not* a deadlock exit:** a finding that is *not a + code edit for this worktree* but a downstream / ship-phase / process gate (a companion + repo pinning this repo's final post-review HEAD, a CI/release step, a cross-repo PR) + cannot be satisfied during the review — it targets the *post*-gauntlet HEAD. When + CLAUDE shows a finding is such a gate, codex marks it **resolved-and-deferred** + (acknowledged, handed to the ship phase) instead of holding it open forever. The CODE + debate still converges to consensus the normal way; this only stops the loop spinning + on a process gate neither side can land mid-review. It is narrow by design — a genuine + code defect CLAUDE simply dislikes is still argued to consensus, no exit. (This is the + loop that once spun until a human killed it on a `@kolu/surface` cross-repo run.) + +## Files + +Shared: + +- `scripts/codex-exec-lib.sh` — the sourced core both modes share: the read-only + `codex exec`/`resume` invocation, warm-session resolve/persist, retry/backoff, + thread-id capture, and the synthesized error-verdict fallback (via a caller hook). + The two mode scripts source this and add only their own prompts + verdict shape. + +Review mode: + +- `debate.workflow.js` — the Workflow script (the loop + consensus logic). +- `scripts/codex-review.sh` — the review-specific invocation (arg parsing, the + review prompts, the verdict schema/session file, the verdict-shaped error). +- `scripts/codex-verdict.schema.json` — the JSON Schema codex's verdict is constrained to. + +Answer mode: + +- `answer.workflow.js` — the Workflow script for the symmetric answer-debate + (parallel answers → cross-check loop to agreement → synthesis). +- `scripts/codex-answer.sh` — the answer-specific invocation (arg parsing, the + answer prompts, the answer schema/session file, the answer-shaped error). +- `scripts/codex-answer.schema.json` — the JSON Schema codex's answer is constrained to. + +These are generated from `agents/.apm/skills/codex-debate/`; edit the source there and +run `just ai apm` to regenerate. + +ARGUMENTS: $ARGUMENTS diff --git a/.agents/skills/codex-debate/answer.workflow.js b/.agents/skills/codex-debate/answer.workflow.js new file mode 100644 index 000000000..09ca6fdb2 --- /dev/null +++ b/.agents/skills/codex-debate/answer.workflow.js @@ -0,0 +1,488 @@ +export const meta = { + name: 'codex-answer-debate', + description: 'Have Claude and codex each answer a prompt in parallel, then cross-check until they agree, and synthesize one unified answer (no round cap, no deadlock exit)', + phases: [ + { title: 'Answer', detail: 'claude + codex answer the prompt independently, in parallel' }, + { title: 'Reconcile', detail: 'each cross-checks the other, round after round, until both agree' }, + { title: 'Synthesis', detail: 'merge the two agreed answers into one unified reply' }, + ], +} + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so it arrives as a +// STRING even when the caller passed a real object; a bare `args.repoPath`/`.prompt` +// would then be `undefined` and every input silently default. Parse a stringified +// `args` defensively (empty string → {}; object used as-is; malformed JSON throws +// loudly). See debate.workflow.js for the full cross-repo failure this fixes. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The user's freeform prompt — the question both assistants answer and then +// cross-check toward one agreed reply. Required; the orchestrator passes it. +const prompt = (a.prompt || '').trim() +// Where the generated skill lives, so the codex runner can find codex-answer.sh. +const skillDir = a.skillDir || '.claude/skills/codex-debate' +// Per-worktree scratch dir, shared with the review mode. Gitignored, derived from +// repoPath (the worktree root === $PWD) so parallel debates in DIFFERENT worktrees +// never collide on shared /tmp paths and these files never pollute the repo. +const workDir = `${repoPath}/.codex-debate` + +// Model tiers. The claude-answer round does real reasoning (answering, then +// cross-checking codex) → `model` (Opus). The final synthesis is also user-facing +// prose, so it runs on `model` too. The codex RUNNER and the transcript writer must +// relay text faithfully (a verbatim copy, not a paraphrase — the weakest tier +// corrupts it silently) → `copyModel` (Sonnet). Defaults match a direct invocation. +const model = a.model || 'opus' +const copyModel = a.copyModel || 'sonnet' + +// The reasoning effort codex runs at, scoped to the debate. This JS constant is +// the SINGLE home for the value: it is passed script-ward (a 4th positional arg to +// codex-answer.sh, which sets `-c model_reasoning_effort`) and read by the +// transcript header, so the `-c` flag and the header both derive from here. +const REASONING_EFFORT = 'xhigh' + +// POSIX single-quote a path for safe interpolation into a shell command (spaces, +// globs, metacharacters inert; embedded single quotes escaped via '\'' ). Used for +// the destructive scratch reset (`rm -f`) below. +const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'` + +// A filesystem-safe slug for this prompt, so the saved transcript has a readable, +// deterministic name (Date.now()/Math.random() are unavailable in workflow scripts, +// so the name is derived purely from the prompt text). Falls back to 'answer'. +const slug = + (prompt.toLowerCase().match(/[a-z0-9]+/g) || []).slice(0, 8).join('-').slice(0, 60) || 'answer' +const answerDocPath = `${workDir}/answer-${slug}.md` + +// --------------------------------------------------------------------------- +// Schema — shared by both debaters (codex's runner mirrors +// scripts/codex-answer.schema.json). `reviewerError` is set ONLY by the codex +// runner script when codex itself failed; the claude side never sets it. +// --------------------------------------------------------------------------- +const OBJECTION = { + type: 'object', + additionalProperties: false, + properties: { point: { type: 'string' }, reason: { type: 'string' } }, + required: ['point', 'reason'], +} +const ANSWER_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + answer: { type: 'string' }, + keyPoints: { type: 'array', items: { type: 'string' } }, + agreesWithOther: { type: 'boolean' }, + objections: { type: 'array', items: OBJECTION }, + changedMind: { type: 'string' }, + reviewerError: { type: 'boolean' }, + }, + required: ['answer', 'keyPoints', 'agreesWithOther', 'objections', 'changedMind'], +} +const FINAL_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { answer: { type: 'string' } }, + required: ['answer'], +} + +if (!prompt) { + return { + status: 'no-prompt', + rounds: 0, + prompt, + transcriptPath: null, + finalAnswer: null, + note: 'No prompt was passed to the answer-debate workflow; nothing to answer.', + } +} + +// --------------------------------------------------------------------------- +// The two debaters (symmetric: each answers, then cross-checks the other) +// --------------------------------------------------------------------------- +// CLAUDE answers/cross-checks via a harness subagent (Claude isn't headless under +// Max auth, so agent() is the only way to run it). Read-only: it may inspect the +// repo to ground its answer but must not edit anything. +async function claudeAnswers(round, other, myPrev) { + const block = + round === 1 + ? `Answer the question below thoroughly and honestly — your best, most defensible answer. You are in a debate with another assistant ("CODEX") answering the SAME question independently; you'll cross-check each other afterward, so make this strong.` + : `This is a CROSS-CHECK round. You and CODEX each answered the question; now reconcile toward ONE agreed answer. + +Your OWN previous answer (build on it — don't re-derive from scratch): +${JSON.stringify(myPrev, null, 2)} + +CODEX's LATEST answer (and its objections to your previous answer) (JSON): +${JSON.stringify(other, null, 2)} + +Weigh CODEX's answer against yours: + - Where CODEX is right and you were wrong or incomplete, UPDATE your answer to match and say what you changed in changedMind. + - Where CODEX is wrong or has a gap, hold your position and record it under objections with a specific, evidence-backed reason.` + const prompt_ = `You and CODEX were asked the SAME question. ${block} + +You may inspect the repo at \`${repoPath}\` to ground your answer — your shell cwd may be a different worktree, so use \`git -C ${repoPath}\` and absolute paths under it. READ-ONLY: read files, \`git -C ${repoPath} diff/log\`, grep; do NOT edit, create, delete, or run any git write command. Cite file:line for claims about this codebase. If the question isn't about this repo, answer from your own knowledge. + +The question: +${prompt} + +Return the schema: + - answer: your complete, self-contained answer as it stands now (on a cross-check round, your UPDATED unified answer). + - keyPoints: the core claims your answer rests on, one per item. + - objections: your remaining disagreements with CODEX's latest answer (empty on round 1, and empty once you fully agree). + - changedMind: what CODEX convinced you to change this round (empty on round 1 or if nothing changed). + - agreesWithOther: true ONLY when CODEX's latest answer is correct and complete and you have NO objection left — your two answers say the same thing. false on round 1.` + return agent(prompt_, { + label: `claude:round${round}`, + phase: round === 1 ? 'Answer' : 'Reconcile', + model, + schema: ANSWER_SCHEMA, + }) +} + +// A confirmation/approval turn for ONE side, judging a SINGLE shared candidate +// answer (the synthesized merge) WITHOUT rewriting its own. Both sides judge the +// IDENTICAL text, so no swap/oscillation is possible (the swap hazard exists only +// because each parallel round adopts the OTHER's separate answer). Returns +// `agreesWithOther` + `objections` against that candidate. Claude runs it directly +// (it's read-only reasoning); codex runs it through the same cross-check machinery +// (the candidate is handed to it as "CLAUDE's latest answer" to approve). +async function claudeConfirms(round, candidate) { + const prompt_ = `You and CODEX were asked the SAME question, debated, and AGREED. A unified candidate answer has been synthesized from your two agreed answers. Your ONLY job now is to APPROVE it or object — do NOT rewrite it, do NOT produce a new answer of your own. + +You may inspect the repo at \`${repoPath}\` to verify (READ-ONLY — \`git -C ${repoPath} diff/log\`, read files, grep; do NOT edit/create/delete or run any git write). + +The question: +${prompt} + +The candidate unified answer to approve: +${candidate} + +Return the schema: + - answer: echo the candidate VERBATIM (you are approving it, not rewriting it). + - keyPoints: the core claims the candidate rests on. + - objections: anything the candidate gets wrong, drops, or overstates relative to what you agreed — empty if you approve it as-is. Be specific (file:line for repo claims). + - changedMind: empty (you are confirming, not revising). + - agreesWithOther: true ONLY if you approve the candidate as a correct, complete unified answer with NO objection left.` + return agent(prompt_, { + label: `claude:confirm${round}`, + phase: 'Synthesis', + model, + schema: ANSWER_SCHEMA, + }) +} + +// CODEX answers/cross-checks via codex-answer.sh (warm session across rounds). The +// agent here is a MECHANICAL RUNNER: it writes the prompt + cross-check files, +// shells out to the script, and relays codex's JSON answer verbatim — it does NOT +// answer the question itself. The user's prompt and CLAUDE's latest answer carry +// arbitrary characters, so they're written with the Write tool, never a heredoc. +// On a CONFIRM turn (`confirming`), `other` is the SINGLE shared synthesized +// candidate STRING; the runner writes it to the cross-check file and passes the +// script's `confirm` token so codex plugs into the same verbatim/approve-or-object +// contract as the workflow's claudeConfirms turn (NOT the ordinary cross-check +// contract, which would tell codex to UPDATE its own answer instead of approving). +async function codexAnswers(round, other, confirming) { + const answerPath = `${workDir}/answer-codex-${round}.json` + const promptPath = `${workDir}/answer-prompt.txt` + const crossPath = `${workDir}/answer-crosscheck.json` + // The cross-check argument to the script: `-` on round 1 (codex answers + // independently), else the file holding either CLAUDE's latest answer (ordinary + // cross-check) or the synthesized candidate to approve (confirm turn). + const crossArg = round === 1 ? '-' : crossPath + // On a confirm turn the cross-check file holds the candidate VERBATIM (a plain + // string); on an ordinary cross-check it holds CLAUDE's answer JSON. + const crossContent = confirming ? other : JSON.stringify(other, null, 2) + const crossStep = + round === 1 + ? `2. (No cross-check this round — codex answers independently.)` + : `2. Using the Write tool (NOT a shell heredoc — the content has special characters), create \`${crossPath}\` with EXACTLY this content (overwriting any existing file): + +${crossContent}` + // Pass the script's `confirm` token on a confirm turn so it selects the + // approve-the-candidate prompt shape rather than the cross-check shape. + const confirmArg = confirming ? ` ${shq('confirm')}` : '' + // Every path below is spliced into a shell command the runner agent executes, so + // POSIX-quote each one (worktrees or skill dirs with spaces/metacharacters would + // otherwise break the command or misdirect it). The Write-tool file CONTENTS + // (${prompt}, ${crossContent}) are not shell-parsed and stay verbatim. + const runnerPrompt = `You are a MECHANICAL RUNNER for one round of an automated answer-debate. Do exactly the steps below and nothing else. Do NOT answer the question yourself, do NOT edit repository files, do NOT add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${shq(workDir)}\`. Using the Write tool (NOT a heredoc), create \`${promptPath}\` with EXACTLY this content (overwriting any existing file): + +${prompt} + +${crossStep} + +3. Run (cd into the repo root so the script's internal \`git\` targets THIS worktree — your shell cwd may be a different worktree): + \`cd ${shq(repoPath)} && bash ${shq(`${skillDir}/scripts/codex-answer.sh`)} ${shq(promptPath)} ${crossArg === '-' ? '-' : shq(crossArg)} ${shq(answerPath)} ${shq(REASONING_EFFORT)}${confirmArg}\` + + This shells out to the codex CLI as a read-only peer; it can take 1-3 minutes. It prints a JSON answer as its final stdout and also writes it to \`${answerPath}\`. + +4. Read \`${answerPath}\` and return its exact contents as your structured output. Copy the values faithfully; do not paraphrase or "improve" them.` + return agent(runnerPrompt, { + label: confirming ? `codex:confirm${round}` : `codex:round${round}`, + phase: confirming ? 'Synthesis' : round === 1 ? 'Answer' : 'Reconcile', + model: copyModel, // must relay codex's answer JSON faithfully + schema: ANSWER_SCHEMA, + }) +} + +// --------------------------------------------------------------------------- +// Transcript rendering — deterministic, in-process (no agent retypes the blob) +// --------------------------------------------------------------------------- +function renderObjections(objs) { + if (!objs || objs.length === 0) return ' - _(none)_' + return objs.map((o) => ` - **${o.point}** — ${o.reason}`).join('\n') +} + +function renderSide(name, ans) { + if (!ans) return `**${name}** — _(no turn this round)_` + const lines = [ + `**${name}** — agrees with other: \`${!!ans.agreesWithOther}\``, + '', + ans.answer, + ] + if (ans.changedMind && ans.changedMind.trim()) lines.push('', `_changed mind:_ ${ans.changedMind}`) + lines.push('', 'Objections to the other side:', renderObjections(ans.objections)) + return lines.join('\n') +} + +// A confirm round is not a normal answer round: both sides judged ONE shared +// synthesized candidate (approve-or-object), so rendering each side's `answer` +// would misleadingly show two texts for a round whose whole point was that both +// judged the IDENTICAL one. Show the candidate ONCE, then each side's verdict +// (agrees + objections) against it. +function renderConfirmVerdict(name, ans) { + if (!ans) return `**${name}** — _(no turn this round)_` + return [ + `**${name}** — approved: \`${!!ans.agreesWithOther}\``, + 'Objections to the candidate:', + renderObjections(ans.objections), + ].join('\n') +} + +function roundSection(entry) { + if (entry.confirming) { + return [ + `### Round ${entry.round} — confirmation`, + '', + 'Both sides judged this single synthesized candidate (approve or object):', + '', + entry.candidate, + '', + renderConfirmVerdict('claude', entry.claude), + '', + renderConfirmVerdict('codex', entry.codex), + ].join('\n') + } + return [ + `### Round ${entry.round}`, + '', + renderSide('claude', entry.claude), + '', + renderSide('codex', entry.codex), + ].join('\n') +} + +function transcriptHeader(meta) { + const badge = meta.status === 'consensus' ? '✅ **Agreed**' : `⚠️ **${meta.status}**` + return `# Codex ⇄ Claude answer-debate + +> **Prompt:** ${meta.prompt} + +${badge} after ${meta.rounds} round(s) · codex answered at \`${meta.reasoningEffort}\` reasoning effort` +} + +function renderTranscript(transcript, meta, finalAnswer) { + const parts = [transcriptHeader(meta)] + if (finalAnswer) parts.push('## Final unified answer', '', finalAnswer) + parts.push('## Convergence trail', ...transcript.map(roundSection)) + return parts.join('\n\n') +} + +phase('Answer') + +const transcript = [] +let status = 'consensus' +let claudeAns = null +let codexAns = null + +// A side "agrees" only when it BOTH set agreesWithOther AND left no objection. The +// boolean and the objections list must be consistent (the schema says so), but a +// model can set the flag while still listing a disagreement; honour the objections +// too so a leftover objection can't be papered over by an over-eager boolean. +const sideAgrees = (a) => a.agreesWithOther === true && (a.objections || []).length === 0 + +// Synthesize a SINGLE candidate answer from the two agreed answers. This is the +// user-facing unified reply, but it is NOT returned until BOTH sides approve it (the +// confirmation phase below), so the synthesized text is never reported as consensus +// without both debaters having signed off on it. Returns the candidate string, or +// null if the synthesis agent died / returned empty. +async function synthesize(claudeFinal, codexFinal) { + const synth = await agent( + `Claude and codex were each asked the question below and, after cross-checking, AGREED. Merge their two (now-equivalent) answers into ONE clean, unified, self-contained answer for the user — no "Claude said / codex said" framing, no meta-commentary about the debate, just the best single answer. Preserve every substantive point both kept; where they used different wording for the same idea, pick the clearest. Keep any file:line citations. + +The question: +${prompt} + +Claude's final answer (JSON): +${JSON.stringify(claudeFinal, null, 2)} + +Codex's final answer (JSON): +${JSON.stringify(codexFinal, null, 2)} + +Return the unified answer in \`answer\`.`, + { label: 'synthesis', phase: 'Synthesis', model, schema: FINAL_SCHEMA }, + ) + return synth && synth.answer ? synth.answer : null +} + +// --------------------------------------------------------------------------- +// The loop — round 1 is the independent answer (parallel); rounds 2+ are +// cross-checks. Runs until BOTH sides agree, then a CONFIRMATION phase on a single +// synthesized candidate. No round cap, no deadlock exit: each side keeps +// cross-checking until they converge (the harness's per-workflow agent backstop is +// the only hard ceiling — interrupt via /workflows or TaskStop). +// +// Both sides run in PARALLEL each round, so in round N each cross-checks the OTHER's +// round-(N-1) answer. That parallelism creates a SWAP/OSCILLATION hazard: if Claude +// adopts codex's prior answer while codex simultaneously adopts Claude's prior +// answer, both can report agreesWithOther:true in the SAME round even though their +// CURRENT outputs are swapped and still differ — and they can keep swapping back and +// forth, so counting consecutive parallel agreements does NOT prove the current +// outputs match. The only sound test is to make BOTH sides judge ONE shared piece of +// text. So when a round shows mutual agreement, we synthesize a single candidate +// from the two agreed answers and run a CONFIRMATION phase: both sides review that +// IDENTICAL candidate (without rewriting their own answer) and either approve it or +// object. Approval is on one fixed text both actually saw, so no swap is possible. If +// both approve, that candidate IS the converged answer (already debater-approved). If +// either objects, its objections fold back into the cross-check loop and we continue. +// --------------------------------------------------------------------------- +let finalAnswer = null +// On a confirmation turn, each side judges the SAME synthesized candidate STRING. +// Carried across iterations so a rejected confirmation feeds the candidate + the +// objector's complaints back into the next ordinary cross-check round. +let pendingCandidate = null +for (let round = 1; ; round++) { + const confirming = pendingCandidate !== null + const prevClaude = claudeAns + const prevCodex = codexAns + // On a confirm turn BOTH sides run their dedicated approve-a-fixed-candidate + // interface (claudeConfirms / codexAnswers(..., confirming)), so both plug into + // one verbatim/approve-or-object contract instead of the ordinary cross-check. + const [claude, codex] = await parallel([ + () => + confirming + ? claudeConfirms(round, pendingCandidate) + : claudeAnswers(round, round === 1 ? null : prevCodex, prevClaude), + () => + confirming + ? codexAnswers(round, pendingCandidate, true) + : codexAnswers(round, round === 1 ? null : prevClaude, false), + ]) + + // codex infrastructure failure — terminal. The runner could not get an answer + // out of codex (broken/unavailable CLI), so it synthesized reviewerError:true. + // Retrying a dead reviewer just spins, so abort and surface the failure. This is + // deliberately separate from the "no deadlock exit" rule for real disagreement. + if (codex && codex.reviewerError) { + status = 'reviewer-error' + log(`Round ${round}: codex error — aborting. ${codex.answer}`) + transcript.push({ round, claude, codex }) + break + } + // A side died on a terminal API error after retries (agent() returned null). + // We can't reconcile half a debate, so abort loudly rather than loop on nulls. + if (!claude || !codex) { + status = 'agent-error' + log(`Round ${round}: ${!claude ? 'claude' : 'codex'} produced no answer (agent error) — aborting.`) + transcript.push({ round, claude, codex }) + break + } + + claudeAns = claude + codexAns = codex + // On a confirm round both sides judged the ONE shared candidate; tag the entry + // (with the candidate itself) so the transcript renders it as an approve/object + // verdict on a single text rather than as two separate answer rounds. + const entry = confirming + ? { round, claude, codex, confirming: true, candidate: pendingCandidate } + : { round, claude, codex } + transcript.push(entry) + + // CONFIRMATION phase: both sides judged the SAME synthesized candidate. If both + // approve it (agreesWithOther:true + no objections), that candidate is the agreed, + // debater-approved unified answer — converge. If either objects, drop the candidate + // and continue the cross-check loop (their objections are already in `claudeAns` / + // `codexAns` and feed the next round). + if (confirming) { + if (sideAgrees(claude) && sideAgrees(codex)) { + finalAnswer = pendingCandidate + log(`Round ${round}: both sides approved the synthesized candidate — converged.`) + break + } + log(`Round ${round}: candidate rejected (claude agrees=${sideAgrees(claude)}, codex agrees=${sideAgrees(codex)}) — resuming cross-check.`) + pendingCandidate = null + continue + } + + // From round 2 on (round 1 has no cross-check), if BOTH sides report no remaining + // disagreement, synthesize one candidate and enter the confirmation phase next + // round. A single parallel-agreeing round can be a swap false positive, so we do + // NOT converge here — we converge only after both sides approve the SAME candidate. + const bothAgree = round >= 2 && sideAgrees(claude) && sideAgrees(codex) + if (bothAgree) { + phase('Synthesis') + pendingCandidate = await synthesize(claude, codex) + if (!pendingCandidate) { + // The merge itself failed (synthesis agent died / returned empty). The sides + // DID agree; only the merge broke — surface that explicitly rather than spin. + status = 'synthesis-error' + log('Synthesis produced no candidate — both sides agreed but the merge failed; reporting synthesis-error.') + break + } + log(`Round ${round}: both sides agree — synthesized a candidate; confirming it next round.`) + phase('Reconcile') + } else { + log( + `Round ${round}: claude agrees=${sideAgrees(claude)} (objections=${(claude.objections || []).length}), codex agrees=${sideAgrees(codex)} (objections=${(codex.objections || []).length})`, + ) + } +} + +log(`Answer-debate ended: ${status} after ${transcript.length} round(s).`) + +// Persist the full transcript to a single readable file the user can revisit +// (chat + saved transcript). Rendered deterministically in-process, then handed to +// one mechanical writer — the payload can be large, so use copyModel for faithful +// reproduction (the same tier the codex relay uses). +const transcriptText = renderTranscript( + transcript, + { status, rounds: transcript.length, prompt, reasoningEffort: REASONING_EFFORT }, + finalAnswer, +) +await agent( + `You are a MECHANICAL WRITER. Do exactly these steps and nothing else — do not edit any other file, do not run git, do not add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${shq(workDir)}\`. +2. Using the Write tool, create \`${answerDocPath}\` with EXACTLY this content, overwriting any existing file: + +${transcriptText}`, + { label: 'transcript:write', phase: 'Synthesis', model: copyModel }, +) + +return { + status, + rounds: transcript.length, + prompt, + transcriptPath: answerDocPath, + finalAnswer, + reasoningEffort: REASONING_EFFORT, + // The error terminus carries codex's failure detail in the synthesized verdict's + // answer text. Sourced from the transcript (not codexAns) because the + // reviewer-error branch breaks BEFORE assigning codexAns — the failing round is + // recorded in the transcript, so read the detail back from there. + codexError: + status === 'reviewer-error' + ? transcript.find((e) => e.codex && e.codex.reviewerError)?.codex.answer || null + : null, +} diff --git a/.agents/skills/codex-debate/debate.workflow.js b/.agents/skills/codex-debate/debate.workflow.js new file mode 100644 index 000000000..519d4a706 --- /dev/null +++ b/.agents/skills/codex-debate/debate.workflow.js @@ -0,0 +1,671 @@ +export const meta = { + name: 'codex-debate', + description: 'Run a codex<->claude review debate on the current diff until they reach consensus (no round cap, no deadlock exit)', + phases: [ + { title: 'Debate', detail: 'codex reviews -> claude responds, round after round' }, + ], +} + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so `args` arrives as +// a STRING even when the caller passed a real object — a bare `args.repoPath` is then +// `undefined` and EVERY input below silently falls back to its default. That's the +// cross-repo bug: `repoPath` degrades to `.` (the cwd), the debate runs `git -C .` +// against the WRONG repo, and it reports a vacuous "clean" (or, worse, commits fixes +// onto the cwd repo). It also means `base`/`model`/`rationale`/`context` never thread +// through; same-repo runs only "work" by cwd coincidence. So parse a stringified +// `args` defensively here: an empty string means "no args" → {}; an already-parsed +// object is used as-is; malformed JSON THROWS loudly (fail-fast) rather than degrading +// to a silent default. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The diff base. Resolved to the MERGE-BASE of (rawBase, HEAD) just before the +// debate (see phase 'Debate') so commits rawBase gained since the branch forked +// aren't reviewed as if this change made them. `let` because that resolution +// reassigns it; every prompt reads the resolved value. (Idempotent when the +// caller already passed a merge-base SHA, e.g. /be-review.) +let base = a.base || 'origin/master' +// Where the generated skill lives, so the codex runner can find codex-review.sh. +const skillDir = a.skillDir || '.claude/skills/codex-debate' +// Per-worktree scratch dir for rebuttal/verdict files. Derived from repoPath +// (the worktree root === $PWD) so parallel debates in DIFFERENT worktrees never +// collide on shared /tmp paths, and `.codex-debate/` is gitignored so these +// files never pollute the diff codex reviews. +const workDir = `${repoPath}/.codex-debate` +// POSIX single-quote a path for safe interpolation into a shell command. Wraps +// in single quotes (so spaces, globs, and shell metacharacters are inert) and +// escapes any embedded single quote via the '\'' idiom. Used for the one +// DESTRUCTIVE command (the ledger `rm -f` below); the benign `mkdir -p` prompts +// elsewhere can tolerate an unquoted path, but a mistargeted `rm -f` cannot. +const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'` +// The debate is recorded as small Markdown SECTION FILES under the gitignored +// scratch dir — TWO per round: `section-NNN-1-codex.md` (codex's verdict) and +// `section-NNN-2-claude.md` (the author's dispositions). Each is written by the +// party that OWNS its content, never forced through a structured payload: +// * codex's section — a Haiku writer renders the small STRUCTURED verdict +// (approval + findings) to disk; faithful, nothing to bloat. +// * claude's section — the AUTHOR writes its OWN per-finding dispositions +// directly (it's already editing the tree), exactly the way codex writes its +// verdict to a path. This is the STRUCTURAL fix for the old crash: the author +// used to pour its whole narrative into a structured field, which overflowed +// the StructuredOutput encoding and silently dropped the required array until +// the retry cap tripped. Now the narrative goes to the file and the author's +// structured return is a MINIMAL ACK (filesChanged/commitSha/done) whose size +// is decoupled from the finding count entirely, so it can never overflow. +// These section files serve THREE roles at once, no copy ever re-typed by a weak +// agent: (1) the author's cross-round memory (it cats them for full history); +// (2) the REBUTTAL codex reads next round — codex-review.sh `cat`s the author's +// section file straight into its prompt, so codex still sees every disposition; +// (3) the published PR comment, which the orchestrator assembles by `cat`-ing the +// section files after a small in-process header (see ledgerHeader / commentHeader) +// — a deterministic shell concat, never re-rendered through an agent. codex is NOT +// a memory reader: it keeps its own warm session, so it only ever reads the one +// rebuttal file, not the whole ledger. +// Commit each round's changes individually (default on). The author commits its +// OWN round in-session — it already edits the tree, so it stages exactly what it +// changed and writes a message carrying the debate context (codex's findings + +// its dispositions). Never pushes or merges — that stays the human's call. +const commit = a.commit !== false +// Model tiers. The claude-author round does real reasoning (fixing/disputing +// codex's findings, and committing its own round) → `model` (Opus). Everything +// else here is mechanical — the codex runner just shells out to codex-review.sh +// and copies the verdict, the codex-section writer dumps a rendered verdict to a +// file, the merge-base resolver runs one git command → `mechModel` +// (Haiku). (The CLAUDE section is written by the author itself, on `model`, as part +// of its round — not by a mechanical writer.) Defaults match a direct invocation; +// /be-review passes both explicitly. +const model = a.model || 'opus' +const mechModel = a.mechModel || 'haiku' +// Fidelity tier (Sonnet). One "mechanical" job isn't a trivial command but a +// faithful COPY: the codex runner reads codex's verdict JSON off disk and must +// return it byte-for-byte. A paraphrase silently corrupts the debate (and schema +// validation checks the verdict's SHAPE, not its wording), and Haiku is the +// weakest tier for verbatim reproduction — so the verdict relay runs a notch up. +// Still far cheaper/faster than Opus; the real reviewing is codex's, not this +// agent's. The small per-round codex-section writes stay on Haiku (tiny payloads). +const copyModel = a.copyModel || 'sonnet' + +// --- Context the Claude implementor INHERITS -------------------------------- +// Two optional notes the CALLER threads in so the implementor (the Claude author) +// no longer reasons from the diff alone — the gap that made it re-derive the +// change's intent every round and re-litigate deliberate choices codex (rightly, +// on a bare diff) flags. +// +// `context` (#1): the MAIN-AGENT context — what this change is FOR (the task/intent +// and key decisions the orchestrator already holds). Injected into the implementor +// EVERY round: agent() is one-shot and Claude isn't headless under Max auth, so it +// can't be resumed the way codex is — re-injection is how it "inherits" at all. +// Deliberately NOT given to codex, which stays an independent reviewer of the +// actual code rather than the author's narrative. +const context = (a.context || '').trim() +// `rationale` (#2): the author's note on DELIBERATE decisions — the same note +// /lens-debate already accepts, now threaded here too. Given to BOTH sides: codex +// (its round-1 prompt, via codexReviews → codex-review.sh — so the reviewer doesn't +// raise them at the source; codex's warm session carries the note across rounds) +// AND the implementor (so it DISPUTES, rather than "fixes", a finding that +// contradicts a deliberate choice). +const rationale = (a.rationale || '').trim() +// The two notes as ready-to-interpolate implementor-prompt blocks. Empty when the +// note is absent, so the prompt stays byte-identical to the contextless form then. +const contextBlock = context + ? `\nContext you INHERIT from the main agent — what this change is FOR (its task/intent and key decisions). Weigh codex's findings against it: a finding that contradicts this intent is a candidate to DISPUTE, not blindly fix.\n${context}\n` + : '' +const rationaleBlock = rationale + ? `\nAuthor's note on DELIBERATE decisions (chosen on purpose — do NOT "fix" them away; dispute the finding unless codex shows the decision itself is wrong):\n${rationale}\n` + : '' +// codex reads the rationale from a file (it's constant across rounds, written once +// before the loop); `-` means "no rationale" to codex-review.sh. +const rationaleFile = `${workDir}/rationale.md` +const rationaleFileArg = rationale ? rationaleFile : '-' + +// The reasoning effort codex runs at, scoped to the debate. This JS constant is +// the SINGLE home for the value: it is passed script-ward (a 4th positional arg +// to codex-review.sh, which sets `-c model_reasoning_effort`) and read by +// ledgerHeader for the published comment, so the `-c` flag and the header both +// derive from here via the one-directional invocation channel — no literal +// repeated across files held together by "remember to update all of them". +const REASONING_EFFORT = 'xhigh' + +// --------------------------------------------------------------------------- +// Schemas — the codex verdict schema mirrors scripts/codex-verdict.schema.json +// so the runner agent returns the same shape codex was constrained to. +// --------------------------------------------------------------------------- +const FINDING = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + severity: { type: 'string', enum: ['blocking', 'major', 'minor', 'nit'] }, + location: { type: 'string' }, + issue: { type: 'string' }, + suggestion: { type: 'string' }, + status: { type: 'string', enum: ['open', 'resolved'] }, + }, + required: ['id', 'severity', 'location', 'issue', 'suggestion', 'status'], +} + +const CODEX_VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + approved: { type: 'boolean' }, + summary: { type: 'string' }, + findings: { type: 'array', items: FINDING }, + responseToRebuttal: { type: 'string' }, + // Set by scripts/codex-review.sh ONLY when codex itself failed to produce a + // verdict (broken/unavailable reviewer). It is the machine-detectable fatal + // signal the loop aborts on — infrastructure failure, not a debate outcome. + reviewerError: { type: 'boolean' }, + }, + required: ['approved', 'summary', 'findings', 'responseToRebuttal'], +} + +// The author's structured return is a MINIMAL ACK by design — no `summary`, no +// per-finding `actions`. The full narrative (the per-finding dispositions AND the +// round summary) is written by the author to its section file instead (see +// claudeResponds), exactly the way codex writes its verdict to a path. This is the +// structural fix for the old crash: a large structured payload (the author pouring +// its whole F1/F2/F3 narrative into `summary` + one `detail` per finding) +// overflowed the StructuredOutput encoding, which silently dropped the required +// array and tripped the retry cap. With only these few small, fixed fields the +// payload size is decoupled from the finding count entirely, so it can never +// overflow. `filesChanged` is bounded by the files touched (not narrative); +// `commitSha` is one hash; `done` is a flag. +const CLAUDE_RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + filesChanged: { type: 'array', items: { type: 'string' } }, + // The author commits its own round (it already edits the tree), so it returns + // the resulting SHA here. "" when it changed nothing or ran under --no-commit. + commitSha: { type: 'string' }, + done: { type: 'boolean' }, + }, + required: ['filesChanged', 'done'], +} + +// Consensus = no finding left open, any severity. The loop runs until codex +// resolves every one (CLAUDE fixed it, or codex conceded a dispute). No cap. +function openFindings(verdict) { + return (verdict.findings || []).filter((f) => f.status !== 'resolved') +} + +// --------------------------------------------------------------------------- +// The two debaters +// --------------------------------------------------------------------------- +async function codexReviews(round, rebuttalPath) { + const verdictPath = `${workDir}/verdict-${round}.json` + // The rebuttal codex reads is the author's PRIOR-round disposition section file + // (it wrote it itself; codex-review.sh `cat`s it straight into codex's prompt). + // No inline blob, no separate rebuttal-file write step — the file already exists. + // `-` on round 1 (no prior author turn yet). + const rebuttalArg = rebuttalPath || '-' + const rebuttalNote = rebuttalPath + ? ` (\`${rebuttalPath}\` is the author's prior-round disposition section — codex reads it as the rebuttal. If it's somehow missing, the script proceeds with no rebuttal and warns; that's fine.)` + : ` (No prior rebuttal this round — the \`-\` argument tells the script there's none.)` + + const prompt = `You are a MECHANICAL RUNNER for one round of an automated code-review debate. Do exactly the steps below and nothing else. Do NOT review the code yourself, do NOT edit any repository files, do NOT add commentary. + +First ensure the scratch dir exists: \`mkdir -p ${workDir}\`. + +1. Run (cd into the repo root so the script's internal \`git diff\`/\`git status\` target THIS worktree — your shell cwd may be a different worktree): + \`cd ${repoPath} && bash ${skillDir}/scripts/codex-review.sh ${base} ${rebuttalArg} ${verdictPath} ${REASONING_EFFORT} ${rationaleFileArg}\` +${rebuttalNote} + + This shells out to the codex CLI as a read-only reviewer; it can take 1-3 minutes. It prints a JSON verdict as its final stdout and also writes it to the \`-o\` path. + +2. Read \`${verdictPath}\` and return its exact contents as your structured output. Copy the values faithfully; do not paraphrase or "improve" them.` + + return agent(prompt, { + label: `codex:round${round}`, + phase: 'Debate', + model: copyModel, // not trivial: must relay codex's verdict JSON faithfully + schema: CODEX_VERDICT_SCHEMA, + }) +} + +async function claudeResponds(round, verdict, doCommit) { + // WARM AUTHOR. We can't truly resume the Claude author (agent() is one-shot, + // and Claude isn't headless under Max auth, so there's no session to resume the + // way `codex exec resume` carries codex's reasoning forward). The achievable + // equivalent is context, not state: every follow-up round the author reads the + // per-round section files — the record of every prior round's findings (codex's + // section, Haiku-written) and its OWN dispositions (the claude section it wrote + // itself last round) — and builds on them instead of re-deriving the diff. codex's + // section is written each round in the loop and the author writes its claude + // section as the LAST step of its own turn, so on round N>1 the files already hold + // rounds 1..N-1. Round 1 has none yet, so its prompt is byte-identical to a cold start. + const priorBlock = + round > 1 + ? `This is a FOLLOW-UP round. Every prior round is recorded as a small Markdown file under the debate's scratch dir — read them FIRST for the full history (codex's past findings and YOUR own dispositions): + \`cat ${workDir}/section-*.md\` (or Read them individually; if none exist, fall back to the diff + the verdict below) +Build on what you already did; don't re-derive the diff from scratch, and don't re-fix or re-litigate anything already settled. For any finding you DISPUTED, check codex's \`responseToRebuttal\` in the verdict below: if codex conceded, you're done with it; if codex held firm, weigh its reasoning and either fix it or hold with a sharper argument. Spend this round on findings still \`open\` plus any new ones. + +` + : '' + const prompt = `You authored the changes on this branch. CODEX reviewed them and returned the verdict below — what do you think? Fix what you agree with, push back (with reasons) on what you don't. + +Work in the repo at \`${repoPath}\` — your shell cwd may be a different worktree, so use ABSOLUTE paths under it and \`git -C ${repoPath}\`. See the change with \`git -C ${repoPath} diff ${base}\`. +${contextBlock}${rationaleBlock} +${priorBlock}CODEX's verdict (JSON): +${JSON.stringify(verdict, null, 2)} + +Address EVERY finding, any severity (don't skip minors/nits): + - agree → fix it in the working tree; disposition "fixed". + - disagree → leave the code, dispute it with a specific technical reason (cite file:line); disposition "disputed". Concede when codex is right. + - partly → fix the valid part, explain the rest; disposition "partial". + - NOT a code edit for this worktree — a downstream / ship-phase / process gate (a companion repo pinning this repo's FINAL post-review HEAD, a CI/release step, a cross-repo PR) that cannot be satisfied mid-review → disposition "disputed", and SAY EXPLICITLY it is a ship-phase gate, not a code change, so codex marks it resolved-and-deferred rather than holding the debate open on something neither side can land here. Use this ONLY for a genuine non-code/process gate, never to dodge a code change you'd rather not make. + +You may run the formatter on files you touched. SELF-VERIFY before you claim "fixed": a fix you didn't run isn't a fix. Run the project's own fast static-check gate (its lint + typecheck task — e.g. \`just check\`/\`npm run lint\`; discover it from the repo, don't hand-roll one) over your edits and make it pass; only mark a finding "fixed" once the gate is green. Never claim a lint won't fire without running it — that's the exact "I watched it work" gap that lands a red tree on the next step. If the gate stays red after a genuine attempt, say so in the disposition rather than reporting a clean "fixed". ${ + doCommit + ? `Once you've addressed every finding AND you actually changed files (and the static-check gate is green), COMMIT this round's work yourself — the debate records one commit per round. Stage ONLY the files you changed (never \`git add -A\` or \`git add .\`) and commit with \`git -C ${repoPath}\`, subject \`fix: codex review — debate round ${round}\` and a body that summarizes your changes plus, briefly, codex's findings and how you dispositioned each. Do NOT push. You'll return the resulting SHA (\`git -C ${repoPath} rev-parse HEAD\`) in \`commitSha\`.` + : `Edit the working tree only — do NOT git add/commit/push; you'll leave \`commitSha\` empty.` + } + +RECORD your dispositions to a file — this is the durable trail and the heart of how this debate stays robust. The next round's author reads it as memory, codex reads it as your rebuttal, and it's what gets posted to the PR. So the FULL per-finding narrative lives in this file, NEVER in your structured return. Using the Write tool, create the file \`${claudeSectionFile(round)}\` (overwrite any existing one) with EXACTLY this Markdown shape: + +**claude** — <one sentence: what you did this round> + +- \`F1\` **fixed** — <why; what you changed; cite file:line> +- \`F2\` **disputed** — <the specific technical reason codex is wrong; cite file:line> +- \`F3\` **partial** — <what you fixed and what you didn't, and why> +${doCommit ? '... one bullet PER finding (disposition ∈ fixed | disputed | partial), then:\n\ncommit: `<the SHA you committed, or omit this whole line if you changed nothing>`' : '... one bullet PER finding (disposition ∈ fixed | disputed | partial). (No commit line — running under --no-commit.)'} + +CRITICAL — your STRUCTURED RETURN IS A MINIMAL ACK, nothing more. Return ONLY: \`filesChanged\` (the source paths you edited — never the scratch file above), \`commitSha\` (the SHA, or "" if you changed nothing / under --no-commit), and \`done\` (true once you've addressed every finding this round). Do NOT put your summary or any per-finding detail in the structured output — all of that goes in the section file and the commit body. (This is deliberate: past runs CRASHED because the author poured a multi-finding narrative into a structured field, which overflowed the output encoding and dropped a required field until the retry cap tripped. Writing the narrative to the file instead removes that failure by construction — keep the structured payload tiny.)` + + return agent(prompt, { + label: `claude:round${round}`, + phase: 'Debate', + model, // deep reasoning: the author fixing/disputing real findings + schema: CLAUDE_RESPONSE_SCHEMA, + }) +} + +// --------------------------------------------------------------------------- +// The shared ledger — section files on disk, assembled into the comment by the +// orchestrator (a faithful `cat`). The workflow renders only the small CODEX +// section (from the structured verdict) and the comment HEADER in-process; the +// author writes its own claude section (see claudeResponds). +// --------------------------------------------------------------------------- +// One codex finding as a Markdown bullet — the single projection of a finding's +// fields for the ledger section. (The per-round commit message is now written by +// the author itself, in its own session, so this no longer feeds it.) +function findingBullet(f) { + return `- \`${f.id}\` · ${f.severity} · ${f.status} — ${f.issue} (${f.location})` +} + +// One round's findings, as a Markdown list. Shared by the codex-section renderer. +function renderFindings(verdict) { + const list = (verdict.findings || []).map((f) => findingBullet(f)).join('\n') + return list || '- _(none)_' +} + +// CODEX's side of one round, as a Markdown section: its verdict, findings, and +// response to the author's rebuttal. Rendered in-process from the STRUCTURED +// verdict (small, faithful) and written to disk by a Haiku writer. The author's +// side is a SEPARATE file the author writes itself (its dispositions), so this +// renderer no longer touches the claude response at all — that's the decoupling +// that keeps the author's structured payload minimal. This carries the round's +// `### Round N` header (it's written every round, including the terminal one). +function codexSection(round, verdict) { + const lines = [ + `### Round ${round}`, + '', + `**codex** — approved: \`${verdict.approved}\``, + '', + verdict.summary, + '', + 'Findings:', + renderFindings(verdict), + ] + if (verdict.responseToRebuttal) lines.push('', `_codex on the rebuttal:_ ${verdict.responseToRebuttal}`) + return lines.join('\n') +} + +// The comment header (small). The full comment is this header followed by the +// per-round section files, `cat`-ed together by the orchestrator (see SKILL step 3) +// — a deterministic shell concat, never re-rendered through an agent. The workflow +// returns this header as `commentHeader`; it can't read the section files itself +// (no I/O), so it hands the orchestrator the header + the section dir. +// +// This header's chrome (the `## ` title, the badge, the `base.slice(0, 12)`) is +// deliberately kept STRUCTURALLY PARALLEL to lens-debate's renderComment header +// chrome. The no-module workflow runtime has no imports, so a truly shared +// renderer isn't available; the two are instead siblings that move together. A +// house-style change (badge emoji, base-slice length, a new metadata row) is a +// mechanical mirror edit — make it here and in lens-debate's renderComment. If +// the runtime ever admits a shared helper file, lift this common chrome there. +function ledgerHeader(meta) { + const badge = meta.status === 'consensus' ? '✅ **Consensus**' : `⚠️ **${meta.status}**` + return `## Codex ⇄ Claude debate\n\n${badge} after ${meta.rounds} round(s) · codex reviewed at \`${meta.reasoningEffort}\` reasoning effort · base \`${(meta.base || '').slice(0, 12)}\`` +} + +// Per-round section file paths. TWO files per round, named so the `section-*.md` +// glob sorts both into round order AND codex-before-claude WITHIN a round +// (`section-001-1-codex.md` < `section-001-2-claude.md` < `section-002-1-codex.md`), +// so `cat`-ing the glob yields the debate in chronological order for both the +// author's memory read and the comment assembly. Zero-padded for the same reason. +const codexSectionFile = (round) => `${workDir}/section-${String(round).padStart(3, '0')}-1-codex.md` +const claudeSectionFile = (round) => `${workDir}/section-${String(round).padStart(3, '0')}-2-claude.md` + +// Drop a string to a scratch file via a mechanical Haiku writer — the single home +// for the "write this content to this path" idiom (the workflow can't do file I/O +// itself, and Claude isn't headless, so a tiny agent does it). Both the per-round +// codex-section writer and the one-shot rationale writer route through here; +// payloads are small (one round / one note) so Haiku is safe, and overwriting is +// idempotent (safe on a resume). +function writeFileAgent(path, content, label) { + const prompt = `You are a MECHANICAL WRITER. Do exactly these steps and nothing else — do not edit any other file, do not run git, do not add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${workDir}\`. +2. Using the Write tool, create \`${path}\` with EXACTLY this content, overwriting any existing file: + +${content}` + return agent(prompt, { label, phase: 'Debate', model: mechModel }) +} + +// Write ONE round's CODEX section to its own small file (the claude section is the +// author's own write, in claudeResponds). The author reads these as cross-round +// memory and the orchestrator cats them into the posted comment. No whole-ledger +// retype: the payload is just this round's structured verdict, rendered. +async function writeCodexSection(round, verdict) { + return writeFileAgent(codexSectionFile(round), codexSection(round, verdict), `ledger:codex:round${round}`) +} + +// VERIFY the author actually wrote its disposition section this round. The author's +// claude section is the load-bearing handoff: it's the next round's rebuttal (codex +// reads it), the author's own cross-round memory, AND part of the posted comment. +// If the author skipped the Write, `lastClaudeSectionPath` would still point at a +// path that doesn't exist — codex-review.sh would warn and proceed with an EMPTY +// rebuttal, and the debate could still converge over a hole in the trail. So after +// every author turn we deterministically check the file exists, is non-empty, AND +// carries a backticked disposition marker for EVERY open finding this round. The +// workflow has no file I/O of its own, so a thin mechanical agent runs `test -s` +// plus an exact `grep -F` per finding id. We do NOT parse prose or score the +// disposition text — only that a `\`Fn\`` token is present, which is an exact, +// bounded check the author prompt already mandates ("one bullet PER finding", +// each id backticked). This closes the hole the file-as-source-of-truth opened: +// a non-empty but INCOMPLETE section (omitting `F2`) used to advance the rebuttal +// pointer and could converge over a per-finding hole in the durable trail. A miss +// — empty file OR any missing finding id — is recorded as a section gap and +// downgrades the terminal status (see below), the same fail-loud-not-silent +// treatment as a missed commit. Codex's own next-round re-review still polices +// the SUBSTANCE of each disposition; this guards only the COMPLETENESS of the +// published per-finding record, which nothing else covers. +async function verifyClaudeSection(round, openIds) { + const path = claudeSectionFile(round) + // Each open finding must appear as a backticked id token (e.g. `F1`) in the + // section. `grep -F` is a literal substring match — no regex/prose brittleness. + const idChecks = openIds + .map((id) => `grep -Fq ${shq(`\`${id}\``)} ${shq(path)} || { echo ${shq(`missing-${id}`)}; ok=0; }`) + .join('; ') + const idList = openIds.length ? openIds.map((id) => `\`${id}\``).join(', ') : '(none)' + const res = await agent( + `You are a MECHANICAL RUNNER. Run exactly this and nothing else, then report:\n\`ok=1; test -s ${shq(path)} || { echo empty; ok=0; }${idChecks ? `; ${idChecks}` : ''}; echo "ok=$ok"\`\nThis checks the section file exists, is non-empty, and contains a backticked marker for every open finding (${idList}). Return \`ok\`: true if the final line was "ok=1", false otherwise (any "empty" or "missing-Fn" line means false). Do not edit any file. Do not run git.`, + { + label: `verify:claude:round${round}`, + phase: 'Debate', + model: mechModel, + schema: { type: 'object', additionalProperties: false, required: ['ok'], properties: { ok: { type: 'boolean', description: 'true when the section file exists, is non-empty, and carries a backticked marker for every open finding' } } }, + }, + ) + return res?.ok === true +} + +const transcript = [] +// 'consensus' is the only NORMAL terminus. 'reviewer-error' is the one abnormal +// terminus: codex itself failed to produce a verdict (broken/unavailable). That +// is infrastructure failure, not a debate outcome, so it ends the loop too — +// distinct from the deliberate "no deadlock exit" for substantive disagreement. +let status = 'consensus' +let finalVerdict = null +// The author's PRIOR-round disposition section file — fed to codex next round as +// the rebuttal (codex-review.sh cats it into codex's prompt). null until the first +// author turn writes one. This replaces the old in-memory rebuttal blob: the author +// writes the file itself, so the dispositions never round-trip through structured +// output, and codex reads them straight off disk. +let lastClaudeSectionPath = null +// Rounds where the author edited files (commit mode on) but returned no SHA — +// the in-session commit it was told to make didn't land. The edits aren't lost +// (they stay in the tree and the next reviewer still diffs them against base), +// but the "one commit per round" contract was broken for that round, so the run +// is NOT a clean consensus: we downgrade the terminal status below rather than +// report success over a missed commit. Not a hard abort: a transient SHA omission +// shouldn't nuke a multi-round debate whose edits are all present in the tree. +const commitGaps = [] +// Rounds where the author's disposition section file is missing, empty, OR missing +// a backticked marker for one or more open findings after its turn — the handoff +// that feeds the rebuttal, the author's memory, and the comment broke. We DON'T +// silently let an empty or per-finding-incomplete rebuttal slip to codex (which +// would warn and proceed, possibly converging over a hole in the trail): a miss is +// recorded here and downgrades the terminal status to 'section-incomplete' below. +// Not a hard abort for the same reason as commitGaps — the tree edits are still +// present and reviewed. +const sectionGaps = [] + +// --------------------------------------------------------------------------- +// The loop — runs until consensus. No round cap, no deadlock exit. +// --------------------------------------------------------------------------- +// The debate continues, round after round, until codex resolves every finding +// (any severity). No upper bound, no "deadlock" surrender: the two sides argue +// every point until one concedes. (The harness's per-workflow agent backstop is +// the only hard ceiling; interrupt via /workflows or TaskStop by hand.) +phase('Debate') + +// Resolve the diff base to the merge-base of (base, HEAD) so codex reviews only +// what THIS branch changed, not commits the base branch gained since the branch +// forked (those would otherwise show up in `git diff base` — master's drift +// reviewed as ours). A thin mechanical git agent; the workflow can't run git +// itself. Idempotent when `base` is already a merge-base SHA (caller resolved it). +const rawBase = base +const baseRes = await agent( + `You are a MECHANICAL RUNNER. Run \`git -C ${repoPath} merge-base ${base} HEAD\` and return ONLY the resulting commit SHA (hex) in \`sha\`. If the command FAILS (missing/typoed base, stale ref, unrelated history), return \`sha\`: "" and put the verbatim git error in \`error\` — do NOT fall back to the raw base ref. Do nothing else.`, + { label: 'resolve:merge-base', phase: 'Debate', model: mechModel, schema: { type: 'object', additionalProperties: false, required: ['sha'], properties: { sha: { type: 'string', description: 'the merge-base SHA, or "" on failure' }, error: { type: 'string', description: 'the git error when sha is empty' } } } }, +) +// Fail loud on a bad base. Falling back to the raw `${base}` tip would review the +// base branch's drift since the fork as if this change made it — the exact noise +// the merge-base removes — so a missing/typoed/stale base must abort, not degrade. +if (!baseRes?.sha?.trim()) { + const err = (baseRes?.error || '').trim() + log(`Aborting: \`git merge-base ${rawBase} HEAD\` failed; the diff scope can't be trusted. Not falling back to the raw ${rawBase} tip.`) + return { + status: 'merge-base-error', + base: rawBase, + rounds: 0, + transcript: [], + finalVerdict: null, + note: `merge-base of \`${rawBase}\` and HEAD could not be resolved (missing/typoed base, stale ref, or unrelated history), so the review scope is untrustworthy. Fix the base ref (e.g. \`git fetch\`) and re-run.${err ? `\ngit error:\n${err}` : ''}`, + } +} +base = baseRes.sha.trim() +log(`Diffing against ${base.slice(0, 12)} (merge-base of ${rawBase} and HEAD), so the base branch's drift since the fork isn't reviewed.`) + +// Clear any stale ledger from a PRIOR debate in this worktree. The scratch dir +// is persistent (per-worktree, not per-run) and the section files use a flat, +// stable `section-NNN-*.md` namespace, so a previous longer debate's high-numbered +// sections would otherwise survive into this run — the author cats `section-*.md` +// as its memory, and the orchestrator cats them into the posted comment, so stale +// sections would pollute BOTH the author's context and the published trail. (The +// glob also catches a prior author's claude section files, which double as the +// rebuttal codex reads, so a stale one must not linger either.) A thin mechanical +// agent (the workflow can't run shell itself). The reset is section/ledger-scoped: +// it deletes only the stale `section-*.md` files, not the whole scratch dir, so +// other artifacts in there (verdict-N.json and any other per-run files) keep their +// own lifecycle and a future pre-loop writer won't be silently wiped. This script +// has no true resume (agent() is one-shot, the whole workflow re-runs from +// scratch), so a fresh start owns a fresh ledger. +await agent( + `You are a MECHANICAL RUNNER. Run exactly this and nothing else: \`mkdir -p -- ${shq(workDir)} && rm -f -- ${shq(workDir)}/section-*.md\`. Do not edit any other file. Do not run git.`, + { label: 'ledger:reset', phase: 'Debate', model: mechModel }, +) + +// Persist the author's rationale ONCE (it's constant across rounds) so +// codex-review.sh can inject it into codex's round-1 prompt; codex's warm session +// then carries the note across later rounds without re-injection. Only when a +// rationale was passed — otherwise rationaleFileArg is `-` and no file is needed. +if (rationale) { + await writeFileAgent(rationaleFile, rationale, 'rationale:write') +} + +for (let round = 1; ; round++) { + const verdict = await codexReviews(round, lastClaudeSectionPath) + finalVerdict = verdict + const entry = { round, codex: verdict, claude: null } + transcript.push(entry) // record this round (mutated in place as it progresses) + + // Write codex's section for this round to disk straight away — before any + // terminal break — so EVERY round (including a consensus-approval or error round + // that never reaches the author) lands in the section record the author reads as + // memory and the orchestrator cats into the comment. The claude section is the + // author's own write later in the round, when there is an author turn. + await writeCodexSection(round, verdict) + + // Reviewer error — terminal failure path. The runner could not get a verdict + // out of codex (broken/unavailable CLI), so codex-review.sh synthesized an + // error verdict carrying reviewerError:true. There are no findings to route to + // Claude, and retrying a broken reviewer just spins forever, so abort the + // debate and surface the failure. This is deliberately separate from the + // "no deadlock exit" rule, which only governs substantive disagreement. + if (verdict.reviewerError) { + status = 'reviewer-error' + log(`Round ${round}: reviewer error — aborting debate. ${verdict.summary}`) + break + } + + const open = openFindings(verdict) + log(`Round ${round}: codex approved=${verdict.approved}, findings open=${open.length}`) + + // Consensus requires BOTH no open finding AND codex's explicit approval. An + // inconsistent verdict — `approved:false` with nothing open — is not consensus: + // codex declined to approve while leaving us nothing to route to Claude, so + // treating it as agreement would ship an unapproved change. There's no finding + // to debate, so re-running codex would just replay the same inconsistency; + // surface it as a reviewer error (the terminal abnormal path) instead of + // looping forever or falsely converging. + if (open.length === 0 && verdict.approved !== true) { + status = 'reviewer-error' + log(`Round ${round}: inconsistent verdict — approved=false with no open findings; aborting as reviewer-error.`) + break + } + + // Consensus: codex approved AND every finding resolved (any severity). + if (open.length === 0) { + break + } + + // Claude responds: fixes what it agrees with (editing the tree), disputes the + // rest, and writes its dispositions to its own claude section file (its memory, + // the rebuttal codex reads next round, and part of the posted comment). It reads + // the per-round section files for its cross-round memory. We point the rebuttal at + // the path it just wrote so the NEXT round feeds it to codex — but only AFTER + // verifying the file actually landed (see verifyClaudeSection below). + const response = await claudeResponds(round, verdict, commit) + entry.claude = response + log( + `Round ${round}: claude done=${response.done}, files=${(response.filesChanged || []).length}`, + ) + + // VERIFY the author wrote its disposition section before we lean on it. The file + // is the next round's rebuttal, the author's memory, and part of the comment — if + // it's missing/empty/incomplete the handoff broke. We require a backticked marker + // for every finding the author had to address this round (`open`), so a non-empty + // but partial section can't slip a per-finding hole into the trail. Only point + // `lastClaudeSectionPath` at it (so codex reads it as the rebuttal next round) once + // it exists AND covers every open id; on a miss record the gap, leave the rebuttal + // pointer where it was (codex sees `-`/the prior round's section rather than an + // incomplete one), and downgrade the terminal status. + if (await verifyClaudeSection(round, open.map((f) => f.id))) { + lastClaudeSectionPath = claudeSectionFile(round) + } else { + sectionGaps.push(round) + log(`Round ${round}: author's disposition section ${claudeSectionFile(round)} is missing, empty, or missing a marker for an open finding — handoff broke; not feeding it to codex as the rebuttal.`) + } + + // The author commits its own round in-session (one commit per round, message + // carrying codex's findings and its dispositions), so here we just record the + // SHA it returned. Only when it actually changed files; flag the inconsistency + // if it reported changes but no commit rather than silently dropping it. + if (commit && (response.filesChanged || []).length > 0) { + entry.commit = (response.commitSha || '').trim() + if (entry.commit) { + log(`Round ${round}: committed ${entry.commit}`) + } else { + // The author edited the tree but didn't return a SHA: its in-session commit + // didn't land. Record the gap so the terminal status reflects it instead of + // reporting a clean consensus over a round that broke the one-commit-per-round + // contract. The edits themselves remain in the tree for the next reviewer. + commitGaps.push(round) + log(`Round ${round}: author changed ${response.filesChanged.length} file(s) but returned no commit SHA — round left uncommitted`) + } + } + // No section write here: codex's section was written at the top of the loop, and + // the author wrote its own claude section during its turn — both already on disk. +} + +const filesChanged = Array.from( + new Set(transcript.flatMap((e) => (e.claude && e.claude.filesChanged) || [])), +) + +// Downgrade a would-be consensus when any round's in-session commit didn't land. +// The debate may have converged (codex approved, nothing open), but with the +// "one commit per round" contract broken we must NOT advertise a clean consensus: +// /be-review keys off this status (and the SKILL's status table) to decide whether +// the step settled cleanly. 'commit-incomplete' is a distinct, non-consensus +// terminus — the edits are all in the tree (the next reviewer diffs them), but a +// human/caller must reconcile the uncommitted round(s). We don't touch a status +// that's already abnormal (reviewer-error), which is strictly more severe. +if (status === 'consensus' && commitGaps.length) { + status = 'commit-incomplete' + log(`Round(s) ${commitGaps.join(', ')} left uncommitted despite changing files — downgrading consensus to commit-incomplete.`) +} + +// Downgrade a would-be consensus when any round's author skipped its disposition +// section file OR left it missing a marker for an open finding. The debate may read +// as converged, but a missing or per-finding-incomplete section is a hole in the +// trail the author, codex (as the rebuttal), and the posted comment all draw on — +// so we must NOT advertise a clean consensus. 'section-incomplete' is a distinct, +// non-consensus terminus: a human/caller must fill in the missing round(s) before +// trusting the per-round record. We don't override an already-abnormal status +// (reviewer-error, or commit-incomplete which is reported the same converged-but- +// -not-clean way) — the first downgrade already marks the run unclean. +if (status === 'consensus' && sectionGaps.length) { + status = 'section-incomplete' + log(`Round(s) ${sectionGaps.join(', ')} are missing the author's disposition section or a finding marker — downgrading consensus to section-incomplete.`) +} + +log(`Debate ended: ${status} after ${transcript.length} round(s); ${filesChanged.length} file(s) changed.`) + +// The terminal round needs no extra section write: codex's section for it was +// written at the top of the loop (every round), and a terminal round has no author +// turn (and so no claude section) by definition. + +// Hand the orchestrator everything it needs to post the comment, but NOT a single +// pre-rendered `comment` string — the author's per-round dispositions live in the +// section files on disk (it wrote them itself), and the workflow can't read files. +// So we return the small in-process `commentHeader` plus the section dir + glob; the +// orchestrator assembles the comment with a faithful `cat` (header followed by the +// section files in glob order) and posts that — a deterministic shell concat, no +// agent ever retyping the ledger. See SKILL step 3. +return { + status, + rounds: transcript.length, + base, + finalVerdict, + filesChanged, + // Rounds whose author-side commit didn't land (empty unless status is + // 'commit-incomplete'). Lets the caller pinpoint and reconcile the gap. + commitGaps, + // Rounds whose author-side disposition section file is missing/empty (empty unless + // status is 'section-incomplete'). Lets the caller pinpoint the hole in the trail. + sectionGaps, + transcript, + // The comment's deterministic header (badge + round count + reasoning effort + + // base). The orchestrator posts: this header, a blank line, then + // `cat <workDir>/section-*.md`. + commentHeader: ledgerHeader({ status, rounds: transcript.length, base, reasoningEffort: REASONING_EFFORT }), + // Where the per-round section files live, so the orchestrator can cat them. + workDir, + sectionGlob: `${workDir}/section-*.md`, +} diff --git a/.agents/skills/codex-debate/scripts/codex-answer.schema.json b/.agents/skills/codex-debate/scripts/codex-answer.schema.json new file mode 100644 index 000000000..55e44f84e --- /dev/null +++ b/.agents/skills/codex-debate/scripts/codex-answer.schema.json @@ -0,0 +1,49 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "answer": { + "type": "string", + "description": "Your complete, self-contained answer to the user's prompt as it stands THIS round. On a cross-check round this is your UPDATED unified answer, revised in light of the other assistant's answer." + }, + "keyPoints": { + "type": "array", + "items": { "type": "string" }, + "description": "The core claims your answer rests on, one per item — the things the other side must agree with for there to be consensus." + }, + "agreesWithOther": { + "type": "boolean", + "description": "true ONLY when the other assistant's latest answer is correct and complete and you have NO substantive disagreement left — your answers say the same thing. false on the first round (you haven't seen theirs yet) and whenever any objection below remains." + }, + "objections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "point": { + "type": "string", + "description": "The specific claim in the OTHER assistant's answer you disagree with, or the gap you think it leaves." + }, + "reason": { + "type": "string", + "description": "Why it's wrong or incomplete — cite file:line or concrete evidence when the prompt is about this repo." + } + }, + "required": ["point", "reason"] + }, + "description": "Your remaining disagreements with the other assistant's latest answer. Empty when you fully agree (agreesWithOther:true) and on round 1." + }, + "changedMind": { + "type": "string", + "description": "What you revised this round because the other assistant convinced you, and why. Empty string on round 1 or when nothing changed." + } + }, + "required": [ + "answer", + "keyPoints", + "agreesWithOther", + "objections", + "changedMind" + ] +} diff --git a/.agents/skills/codex-debate/scripts/codex-answer.sh b/.agents/skills/codex-debate/scripts/codex-answer.sh new file mode 100755 index 000000000..e881bd881 --- /dev/null +++ b/.agents/skills/codex-debate/scripts/codex-answer.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# codex-answer.sh — the canonical, deterministic codex invocation for the +# SYMMETRIC answer-debate (the `answer` mode of /codex-debate). codex answers a +# freeform user prompt as one of two equal debaters; on follow-up rounds it +# CROSS-CHECKS the other assistant's ("CLAUDE") latest answer against its own and +# either concedes (revising its answer) or holds firm (recording objections), +# looping until both sides agree. codex runs as a READ-ONLY peer — it may read +# this repo to ground its answer (git diff/log, read files, grep) but cannot +# modify anything. The output is constrained to codex-answer.schema.json and +# written to <out-json>. +# +# This script owns only what is SPECIFIC to answering a prompt: arg parsing, the +# warm/cold prompt text, the answer schema + session file, and the answer-shaped +# error verdict. The shared codex-driving core (read-only exec/resume, retry/ +# backoff, thread-id capture, session persistence) lives in codex-exec-lib.sh. +# +# Usage: +# codex-answer.sh <prompt-file> <crosscheck-file|-> <out-json> [reasoning-effort] [confirm] +# +# <prompt-file> path to a file holding the user's prompt/question +# <crosscheck-file> path to a file holding CLAUDE's latest answer (JSON) for +# codex to cross-check, or "-" on the first round (codex +# hasn't seen CLAUDE's answer yet — it answers independently). +# On a CONFIRM turn this file instead holds the synthesized +# unified CANDIDATE answer codex is asked to approve verbatim. +# <out-json> path the JSON answer is written to (also echoed to stdout) +# <reasoning-effort> codex model_reasoning_effort for this run; the answer +# workflow passes its REASONING_EFFORT constant here so the +# value has one home. Defaults to "xhigh" for standalone runs. +# [confirm] the literal token "confirm" selects the CONFIRM prompt shape: +# codex judges ONE shared synthesized candidate (held in the +# crosscheck-file) and approves it VERBATIM or objects — it does +# NOT rewrite its own answer. Mirrors the workflow's +# claudeConfirms turn so both peers plug into one confirm contract. +# +# Notes: +# * codex runs under `--sandbox read-only` (see codex-exec-lib.sh), which enforces +# read-only at the kernel boundary, NOT merely by prompt text. codex reads +# arbitrary repo files to ground its answer and could be prompt-injected by file +# contents, so the read-only promise must be enforced, not advertised. +# * Always emits a schema-valid answer on stdout, even if codex errors — a +# synthesized error answer (reviewerError:true) so the loop never wedges. +# * WARM SESSION: round 1 cold-starts codex; every later round resumes that same +# session so codex retains its OWN prior answer + reasoning across rounds. +set -uo pipefail + +prompt_file="${1:?usage: codex-answer.sh <prompt-file> <crosscheck-file|-> <out-json> [reasoning-effort] [confirm]}" +crosscheck_file="${2:?missing crosscheck-file (use - for none)}" +out="${3:?missing out-json path}" +# The answer workflow owns this value (its REASONING_EFFORT constant) and passes +# it down; "xhigh" is only the default for a standalone invocation of this script. +effort="${4:-xhigh}" +# CONFIRM mode: the 5th arg is the literal "confirm" when codex is judging ONE +# synthesized candidate (held in crosscheck_file) rather than cross-checking +# CLAUDE's separate answer. This swaps in the confirm prompt shape below — a +# verbatim/approve-or-object contract symmetric to the workflow's claudeConfirms. +is_confirm= +[ "${5:-}" = "confirm" ] && is_confirm=1 + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The codex-FACING output schema. Do NOT add a `reviewerError` property to it: +# codex's `--output-schema` (OpenAI structured outputs) requires every declared +# property to be in `required` with additionalProperties:false, so an optional +# `reviewerError` 400s the request. codex never emits reviewerError; the error +# answer synthesize_error_verdict writes carries it but is validated by the +# workflow's in-JS ANSWER_SCHEMA, not by this file. (This has been re-added in +# error twice — review mode's codex-verdict.schema.json omits it for the same reason.) +schema="$here/codex-answer.schema.json" +# shellcheck source=codex-exec-lib.sh +source "$here/codex-exec-lib.sh" + +# The user's prompt. Required and must be non-empty — an empty prompt would make +# codex answer nothing and silently degrade the debate, so fail loud instead. +if [ ! -s "$prompt_file" ]; then + echo "ERROR: prompt file '$prompt_file' is missing or empty." >&2 + exit 2 +fi +prompt_text="$(cat "$prompt_file")" + +# Pull CLAUDE's latest answer, if any (the cross-check input). Built as a plain +# string and injected below via a simple variable reference so any special +# characters in the JSON (backticks, $, ...) stay literal. +crosscheck="" +if [ "$crosscheck_file" != "-" ]; then + if [ -s "$crosscheck_file" ]; then + crosscheck="$(cat "$crosscheck_file")" + else + # A cross-check was expected (path given, not "-") but the file is missing or + # empty — the handoff broke. Proceed without it, but make the failure loud so + # codex's cross-check isn't silently skipped. + echo "WARNING: expected cross-check file '$crosscheck_file' is missing or empty; proceeding with no cross-check this round." >&2 + fi +fi + +# WARM SESSION. Round 1 (crosscheck_file == "-") cold-starts and resets any stale +# id; later rounds resume codex's own answer session. Resolve the id first so the +# prompt below can lean on codex's retained context when warm. +session_id_file="$(dirname "$out")/codex-answer-session.id" +[ "$crosscheck_file" = "-" ] && is_round1=1 || is_round1= +resume_id="$(codex_resolve_session "$session_id_file" "$is_round1")" + +# Synthesize codex-answer's error verdict shape when codex produces nothing after +# every attempt (called by codex_exec_round). reviewerError:true is the signal the +# workflow aborts the debate on. +synthesize_error_verdict() { + local out="$1" tail_log="$2" attempts="$3" + jq -n --arg log "$tail_log" --arg attempts "$attempts" '{ + answer: ("codex produced no answer this round after " + $attempts + " attempt(s). Tail of log: " + $log), + keyPoints: [], + agreesWithOther: false, + objections: [], + changedMind: "", + reviewerError: true + }' >"$out" +} + +# Whether this is a cross-check round: a cross-check file was provided (not "-") AND +# it actually held CLAUDE's answer. A follow-up round MUST cross-check even when the +# warm session id is missing — dropping the cross-check would tell codex to answer +# independently again (agreesWithOther:false, no objections) and the debate could +# never converge. So the cross-check, not the resume id, gates which prompt we use. +is_crosscheck= +[ -n "$crosscheck" ] && is_crosscheck=1 + +# A reusable block carrying CLAUDE's latest answer + the cross-check instructions, +# spliced into BOTH the warm and cold follow-up prompts (mirrors codex-review.sh's +# $rebuttal_block) so a missing resume id degrades to a COLD CROSS-CHECK, never to a +# fresh independent answer. Empty on round 1. +crosscheck_block="" +if [ -n "$is_crosscheck" ]; then + crosscheck_block="$(cat <<EOF + +CLAUDE's LATEST answer (JSON) is: +$crosscheck + +Cross-check CLAUDE's answer against your own. Then: + - Where CLAUDE is right and you were wrong or incomplete, UPDATE your answer to + match and note what you changed in changedMind. + - Where CLAUDE is wrong or has a gap, keep your position and record it under + objections with a specific, evidence-backed reason (cite file:line for repo + questions). +EOF +)" +fi + +# Prompt shapes, chosen by (confirm × resume id × cross-check): +# * CONFIRM (confirm token): codex judges ONE synthesized candidate +# (in $crosscheck) and approves it VERBATIM or objects — it does NOT rewrite its +# own answer. One contract symmetric to the workflow's claudeConfirms turn, so +# both peers plug into the same approve-a-fixed-candidate interface. Takes +# precedence over warm/cold below (the session is warm here, but the activity is +# approval, not cross-check). +# * WARM follow-up (resume id + cross-check): lean prompt leaning on codex's +# retained answer. +# * COLD follow-up (no resume id + cross-check): the full answer prompt PLUS the +# cross-check block — codex re-derives its own answer from scratch but still +# reconciles against CLAUDE's, so the debate keeps converging. +# * COLD first round (no cross-check): the independent answer prompt. +# Unquoted heredocs: only $prompt_text, $crosscheck, and $crosscheck_block expand; +# their expansions are inserted literally (heredoc results aren't re-scanned), so +# special chars stay inert. +if [ -n "$is_confirm" ]; then + prompt="$(cat <<EOF +You are CODEX. You and another assistant ("CLAUDE") were each asked the SAME +question, debated, and AGREED. A unified candidate answer has been synthesized from +your two agreed answers. Your ONLY job now is to APPROVE it or object — do NOT +rewrite it, do NOT produce a new answer of your own. + +You may inspect this repository to verify (READ-ONLY — read files, run git +diff/log/grep; do NOT modify, create, or delete anything, and run no git write +command: add/commit/push/stash/checkout). Cite file:line for repo claims. + +The original question was: +$prompt_text + +The candidate unified answer to approve is: +$crosscheck + +Return the JSON schema: + - answer: echo the candidate VERBATIM (you are approving it, not rewriting it). + - keyPoints: the core claims the candidate rests on. + - objections: anything the candidate gets wrong, drops, or overstates relative to + what you agreed — empty if you approve it as-is. Be specific (file:line for repo + claims). + - changedMind: empty (you are confirming, not revising). + - agreesWithOther: true ONLY if you approve the candidate as a correct, complete + unified answer with NO objection left. +EOF +)" +elif [ -n "$resume_id" ] && [ -n "$is_crosscheck" ]; then + prompt="$(cat <<EOF +You are CODEX, continuing the SAME answer session you started earlier — you still +have your own previous answer and reasoning in context. You and another assistant +("CLAUDE") were each asked the SAME question and are now cross-checking each other +to reach ONE agreed answer. + +The original question was: +$prompt_text + +Cross-check CLAUDE's answer against your own (READ-ONLY — you may read repo files, +git diff/log, grep to verify, but do NOT modify, create, or delete anything, and +run no git write command: add/commit/push/stash/checkout). +$crosscheck_block +Return the JSON schema: + - answer: your UPDATED, self-contained unified answer as it stands now. + - keyPoints: the core claims your answer rests on. + - objections: your remaining disagreements with CLAUDE's latest answer (empty + when you fully agree). + - changedMind: what CLAUDE convinced you to change this round (empty if nothing). + - agreesWithOther: true ONLY when CLAUDE's latest answer is correct and complete + and you have NO objection left — your two answers say the same thing. +EOF +)" +else + prompt="$(cat <<EOF +You are CODEX, a rigorous, truthful expert. Answer the user's question below +thoroughly and honestly — exactly as you would for a careful colleague. You are in +a debate with another assistant ("CLAUDE") who is answering the SAME question +independently; you cross-check each other until you agree, so give your best, most +defensible answer. + +You may inspect this repository to ground your answer (READ-ONLY — read files, run +git diff/log/grep; do NOT modify, create, or delete anything, and run no git write +command: add/commit/push/stash/checkout). Cite file:line for claims about this +codebase. If the question isn't about this repo, answer from your own knowledge. + +The question: +$prompt_text +$crosscheck_block +Return the JSON schema: + - answer: your complete, self-contained answer (on a cross-check round, your + UPDATED unified answer revised in light of CLAUDE's). + - keyPoints: the core claims your answer rests on, one per item. + - objections: your remaining disagreements with CLAUDE's latest answer — empty + when you fully agree, and empty on the first round (no cross-check yet). + - changedMind: what CLAUDE convinced you to change this round (empty if nothing, + and empty on the first round). + - agreesWithOther: true ONLY when CLAUDE's latest answer is correct and complete + and you have NO objection left. false on the first round (no cross-check yet). +EOF +)" +fi + +# Drive codex for this round (retry/backoff, thread capture, error fallback) — the +# shared core does the work; this script supplied the prompt, schema, and shapes. +codex_exec_round "$schema" "$out" "$session_id_file" "$effort" "$resume_id" "$prompt" diff --git a/.agents/skills/codex-debate/scripts/codex-exec-lib.sh b/.agents/skills/codex-debate/scripts/codex-exec-lib.sh new file mode 100644 index 000000000..943b466b2 --- /dev/null +++ b/.agents/skills/codex-debate/scripts/codex-exec-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# +# codex-exec-lib.sh — the shared, SOURCED core of the codex⇄claude debate scripts. +# +# Both modes of /codex-debate drive codex the same way; only WHAT they ask and the +# verdict SHAPE differ. This file is the single home for the part that is identical +# (and the part most volatile to codex CLI changes): driving codex headless and +# READ-ONLY, resuming its warm session, retrying transient failures with backoff, +# capturing the thread id, and — when codex produces nothing after every attempt — +# synthesizing a schema-valid error verdict so the debate loop never wedges. +# +# It is `source`d, not executed. The two callers (codex-review.sh, codex-answer.sh) +# own the parts that DIFFER: parsing their own args, building the prompt, choosing +# the schema + per-mode session-id file, and defining the verdict shape. +# +# Contract — a caller must: +# 1. source this file; +# 2. define a function synthesize_error_verdict <out> <tail_log> <attempts> +# that writes its own schema-valid error verdict (reviewerError:true) to <out>; +# 3. resolve the warm-session resume id with codex_resolve_session <id-file> <round1?> +# (so it can pick the warm vs cold prompt), then build $prompt; +# 4. run one round with codex_exec_round <schema> <out> <id-file> <effort> <resume-id> <prompt>. +# +# Tunables (shared by both modes; names kept for back-compat): +# CODEX_REVIEW_RETRIES total attempts per round (default 3) +# CODEX_REVIEW_BACKOFF base seconds; attempt n waits n*base (default 5) + +# Resolve the warm-session resume id for this round, and reset it on round 1. +# +# * Round 1 (is_round1 == "1"): start a NEW session — drop any id left behind by +# a previous debate in this worktree so we never resume a stale one. Echoes "". +# * Later rounds: echo the persisted id (empty if none was captured — the caller +# then cleanly cold-starts with the full prompt, never a wedge). +# +# Echoing (rather than setting a global) keeps the caller explicit: it captures the +# id, uses it to choose the warm vs cold prompt, and passes it back to +# codex_exec_round. Usage: resume_id="$(codex_resolve_session "$id_file" "$round1")" +codex_resolve_session() { + local session_id_file="$1" is_round1="$2" + if [ "$is_round1" = "1" ]; then + rm -f "$session_id_file" + return 0 + fi + if [ -s "$session_id_file" ]; then + cat "$session_id_file" + fi +} + +# One codex invocation: warm-resume when we have a session id (carries codex's own +# prior turn), else a cold start. `--json` emits a `thread.started` event carrying +# codex's thread_id (captured by codex_exec_round to resume next round); it does NOT +# change the verdict, which `--output-schema`/`-o` still write to "$out". `resume` +# has no `--sandbox` flag, so read-only is enforced there via `-c sandbox_mode` — +# the same kernel-enforced policy, set through config instead of the flag. +# +# Reads $resume_id, $schema, $out, $effort, $prompt from the enclosing +# codex_exec_round via bash's dynamic scope (they're `local` there). +_codex_run_once() { + if [ -n "$resume_id" ]; then + codex exec resume \ + -c sandbox_mode="read-only" \ + -c model_reasoning_effort="$effort" \ + --json \ + --output-schema "$schema" \ + -o "$out" \ + "$resume_id" "$prompt" + else + codex exec \ + --sandbox read-only \ + -c model_reasoning_effort="$effort" \ + --json \ + --output-schema "$schema" \ + -o "$out" \ + "$prompt" + fi +} + +# Run ONE debate round against codex and leave a schema-valid verdict in <out> +# (also echoed to stdout). Owns the out/log lifecycle, the retry/backoff loop, the +# thread-id capture, and the error-verdict fallback. +# +# codex_exec_round <schema> <out> <session_id_file> <effort> <resume_id> <prompt> +# +# model_reasoning_effort is scoped to the debate here (via -c, from <effort>) rather +# than the user's global ~/.codex/config.toml — review/answer is the one place we +# always want codex thinking at full depth, regardless of their default. +# +# RETRY/BACKOFF. codex's CLI fails transiently often enough to matter (API hiccups, +# a spurious internal error) and writes no verdict — which would otherwise degrade +# the round to reviewer-error on a single bad roll. Retry with linear backoff, +# accepting the first attempt that writes a non-empty verdict to <out>. Only after +# every attempt fails empty do we synthesize the reviewerError verdict (via the +# caller's synthesize_error_verdict hook). +codex_exec_round() { + local schema="$1" out="$2" session_id_file="$3" effort="$4" resume_id="$5" prompt="$6" + local log="$out.log" + + # The out path lives under a per-worktree scratch dir (.codex-debate/); make sure + # it exists before codex tries to write the verdict there. + mkdir -p "$(dirname "$out")" + + local attempts="${CODEX_REVIEW_RETRIES:-3}" + local backoff="${CODEX_REVIEW_BACKOFF:-5}" + # Validate both as positive integers. Left unchecked, a non-numeric value makes the + # arithmetic test error every iteration, so the loop would spin forever instead of + # giving up. Fall back to the documented defaults (and clamp attempts to >=1) + # loudly rather than wedge the headless debate on a typo'd override. + if ! [[ "$attempts" =~ ^[0-9]+$ ]] || [ "$attempts" -lt 1 ]; then + echo "WARNING: CODEX_REVIEW_RETRIES='$attempts' is not a positive integer; using 3." >&2 + attempts=3 + fi + if ! [[ "$backoff" =~ ^[0-9]+$ ]]; then + echo "WARNING: CODEX_REVIEW_BACKOFF='$backoff' is not a non-negative integer; using 5." >&2 + backoff=5 + fi + + local n=1 wait_s + : >"$log" # start each round fresh; attempts below APPEND so no failure's diagnostics are lost + while :; do + rm -f "$out" + # Append (not truncate): when every attempt fails, the synthesized error + # verdict's tail_log must reflect ALL attempts' diagnostics, not just the last. + echo "=== attempt $n/$attempts ===" >>"$log" + if ! _codex_run_once </dev/null >>"$log" 2>&1; then + echo "codex exec exited non-zero (attempt $n/$attempts; see $log)" >&2 + fi + # Success the moment codex writes a verdict: the kernel sandbox + --output-schema + # make a non-empty "$out" a real, schema-valid verdict, not a partial. + [ -s "$out" ] && break + # Out of attempts — fall through to the synthesized error verdict. + [ "$n" -ge "$attempts" ] && break + wait_s=$(( backoff * n )) + echo "codex produced no verdict (attempt $n/$attempts); retrying in ${wait_s}s..." >&2 + n=$(( n + 1 )) + sleep "$wait_s" + done + + if [ -s "$out" ]; then + # Persist codex's session id so the NEXT round can resume this same warm session + # (carrying codex's own prior turn). The successful attempt's `thread.started` + # is the last one appended to the log; on a resume round it echoes the same id, + # so overwriting is a harmless refresh. Failure to capture an id just means next + # round cold-starts via the caller's fallback — not fatal. + local sid + # Extract the LAST thread_id in the log (one awk, no grep|tail|cut pipeline). + # Splitting on '"', the value sits two fields AFTER the "thread_id" key field + # (key, then ":", then value) — NOT a fixed column, since other quoted keys + # (e.g. "type":"thread.started") precede it on the same JSON event line. + sid="$(awk -F'"' '{for (i = 1; i < NF; i++) if ($i == "thread_id") sid = $(i + 2)} END {print sid}' "$log")" + if [ -n "$sid" ]; then + printf '%s\n' "$sid" >"$session_id_file" + fi + fi + + if [ ! -s "$out" ]; then + # codex produced no verdict — hand off to the caller's shape-specific synthesizer + # so the debate loop can surface the failure instead of hanging. reviewerError is + # the machine-detectable signal the workflow aborts on: a broken/unavailable codex + # is INFRASTRUCTURE failure, not substantive disagreement, so it must NOT spin the + # loop forever. + local tail_log + tail_log="$(tail -c 2000 "$log" 2>/dev/null || true)" + synthesize_error_verdict "$out" "$tail_log" "$attempts" + fi + + cat "$out" +} diff --git a/.agents/skills/codex-debate/scripts/codex-review.sh b/.agents/skills/codex-debate/scripts/codex-review.sh new file mode 100755 index 000000000..597d09579 --- /dev/null +++ b/.agents/skills/codex-debate/scripts/codex-review.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# +# codex-review.sh — the canonical, deterministic codex invocation for the +# codex<->claude review debate (the `review` mode of /codex-debate). Runs the +# codex CLI as a READ-ONLY reviewer of the current working-tree state against a +# base branch, constrained to codex-verdict.schema.json, and writes the JSON +# verdict to <out-json>. +# +# This script owns only what is SPECIFIC to reviewing a diff: arg parsing, the +# warm/cold review prompt text, the verdict schema + session file, and the +# verdict-shaped error fallback. The shared codex-driving core (read-only exec/ +# resume, retry/backoff, thread-id capture, session persistence) lives in +# codex-exec-lib.sh. +# +# Usage: +# codex-review.sh <base-branch> <rebuttal-file|-> <out-json> [reasoning-effort] [rationale-file|-] +# +# <base-branch> branch to diff against (e.g. master) +# <rebuttal-file> path to a file holding CLAUDE's previous-round response — the +# author-written Markdown disposition section (its per-finding +# fixed/disputed/partial trail), NOT JSON. "-" on the first round +# (no rebuttal yet). Cat'd verbatim into codex's prompt below. +# <out-json> path the JSON verdict is written to (also echoed to stdout) +# <reasoning-effort> codex model_reasoning_effort for this run; the debate +# workflow passes its REASONING_EFFORT constant here so the +# value has one home. Defaults to "xhigh" for standalone runs. +# <rationale-file> path to a file holding the author's note on DELIBERATE +# decisions, or "-" for none. Injected into the round-1 (cold) +# review prompt so codex doesn't flag intentional choices as +# defects; codex's warm session carries it across later rounds. +# +# Notes: +# * codex runs under `--sandbox read-only` (see codex-exec-lib.sh), which enforces +# read-only at the kernel boundary (file writes and other state-mutating +# syscalls denied), NOT merely by prompt text. codex reviews arbitrary diffs and +# could be prompt-injected by file contents, so the read-only promise must be +# enforced, not advertised. +# * Always emits a schema-valid verdict on stdout, even if codex errors — a +# synthesized error verdict (approved:false) so the loop never wedges. +# * WARM SESSION: round 1 cold-starts codex and records its session id; every later +# round resumes that same session (`codex exec resume <id>`) so codex retains its +# OWN prior review + reasoning across rounds. +set -uo pipefail + +base="${1:?usage: codex-review.sh <base-branch> <rebuttal-file|-> <out-json> [reasoning-effort] [rationale-file|-]}" +rebuttal_file="${2:?missing rebuttal-file (use - for none)}" +out="${3:?missing out-json path}" +# The debate workflow owns this value (its REASONING_EFFORT constant) and passes +# it down; "xhigh" is only the default for a standalone invocation of this script. +effort="${4:-xhigh}" +# Author's note on deliberate decisions (constant across rounds); "-" = none. Only +# the cold/round-1 prompt injects it — codex's warm session retains it after that. +rationale_file="${5:--}" + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +schema="$here/codex-verdict.schema.json" +# shellcheck source=codex-exec-lib.sh +source "$here/codex-exec-lib.sh" + +# Pull CLAUDE's previous-round response (the author's Markdown disposition section), +# if any. Built as a plain string and injected below via a simple variable reference +# so any special characters in the text (backticks, $, ...) stay literal (heredoc +# expansion results are not re-scanned). +rebuttal="" +if [ "$rebuttal_file" != "-" ]; then + if [ -s "$rebuttal_file" ]; then + rebuttal="$(cat "$rebuttal_file")" + else + # A rebuttal was expected (path given, not "-") but the file is missing or + # empty — the handoff broke. Proceed without it, but make the failure loud + # so codex's responseToRebuttal isn't silently empty. + echo "WARNING: expected rebuttal file '$rebuttal_file' is missing or empty; proceeding with no rebuttal this round." >&2 + fi +fi + +rebuttal_block="" +if [ -n "$rebuttal" ]; then + rebuttal_block=" +This is a FOLLOW-UP round — you already gave your full review. Your job now is to +CLOSE OUT the findings already on the table, not re-scan the whole diff for more. +For each existing finding: verify CLAUDE's fix and mark it resolved, or address +CLAUDE's dispute (concede and mark resolved, or hold firm with specific reasoning +in responseToRebuttal). Raise a NEW finding ONLY if CLAUDE's changes this round +introduced it (a regression). Do NOT keep surfacing pre-existing issues you didn't +raise in round 1 — that prevents the debate from ever converging. + +CLAUDE responded to your PREVIOUS review as follows: +$rebuttal +" +fi + +# The author's note on DELIBERATE decisions, if supplied — injected into the COLD +# review prompt below so codex doesn't raise intentional choices as findings. Read +# from a file (the workflow writes it once) so multi-line notes with special chars +# survive intact, the same way the rebuttal is handled. +rationale="" +if [ "$rationale_file" != "-" ]; then + if [ -s "$rationale_file" ]; then + rationale="$(cat "$rationale_file")" + else + # A rationale was expected (path given, not "-") but the file is missing or + # empty — the rationale:write handoff broke. Proceed without it (a missing + # rationale degrades to a bare-diff review the IMPLEMENTOR still disputes from + # its own inherited rationale block — it is a false-finding SUPPRESSOR, not a + # correctness input, so we don't abort the round), but make the failure loud + # so the round isn't silently mistaken for a rationale-aware review. Mirrors + # the rebuttal warning above. + echo "WARNING: expected rationale file '$rationale_file' is missing or empty; proceeding with no deliberate-decisions note this round (codex reviews the bare diff)." >&2 + fi +fi +rationale_block="" +if [ -n "$rationale" ]; then + rationale_block=" +The author flagged the following as DELIBERATE decisions. Do NOT raise them as +findings unless the reasoning itself is wrong — if it is, say specifically why: +$rationale +" +fi + +# WARM SESSION. Round 1 (rebuttal_file == "-") cold-starts and resets any stale id; +# later rounds resume codex's own review session. Resolve the id first so the prompt +# below can lean on codex's retained context when warm. +session_id_file="$(dirname "$out")/codex-session.id" +[ "$rebuttal_file" = "-" ] && is_round1=1 || is_round1= +resume_id="$(codex_resolve_session "$session_id_file" "$is_round1")" + +# Synthesize codex-review's error verdict shape when codex produces nothing after +# every attempt (called by codex_exec_round). reviewerError:true is the signal the +# workflow aborts the debate on. +synthesize_error_verdict() { + local out="$1" tail_log="$2" attempts="$3" + jq -n --arg log "$tail_log" --arg attempts "$attempts" '{ + approved: false, + summary: ("codex produced no verdict this round after " + $attempts + " attempt(s). Tail of log: " + $log), + findings: [], + responseToRebuttal: "", + reviewerError: true + }' >"$out" +} + +# Two prompts: a lean follow-up for the WARM (resume) path that leans on codex's +# retained context, and the full review prompt for the COLD path (round 1, or the +# fallback when no session id was captured). Unquoted heredocs: only $base, +# $rebuttal, $rebuttal_block, and (in the cold prompt) $rationale_block expand; +# their expansions are inserted literally (heredoc results aren't re-scanned), so +# special chars in $rebuttal / $rationale stay inert. The rationale rides the cold +# prompt ONLY — codex's warm session already retains it from round 1. +if [ -n "$resume_id" ]; then + prompt="$(cat <<EOF +You are CODEX, continuing the SAME review session you started earlier — you still +have your own previous review and reasoning in context. The author ("CLAUDE") has +now responded to that review and changed the working tree. + +The tree changed since your last turn, so re-inspect the CURRENT state (READ-ONLY — +do not modify, create, or delete anything, and run no git write command: +add/commit/push/stash/checkout): + + git diff $base (committed + unstaged changes on this branch) + git status --short (untracked/new files — read those too; they aren't in the diff) + +Ignore the debate's own scratch dir '.codex-debate/' if it appears. + +CLAUDE responded to your previous review as follows: +$rebuttal + +CLOSE OUT the findings already on the table — do NOT re-scan the whole diff for new +pre-existing issues you didn't raise before (that prevents the debate from ever +converging). For each existing finding (reuse its stable id): verify CLAUDE's fix +and mark it resolved, or address CLAUDE's dispute — concede (mark it resolved) or +hold firm with specific technical reasoning in responseToRebuttal. Raise a NEW +finding ONLY if CLAUDE's changes THIS round introduced it (a regression). + +EXCEPTION to "hold firm": if CLAUDE shows a finding is NOT a code edit for THIS +worktree but a downstream / ship-phase / process gate (a companion repo pinning this +repo's final post-review HEAD, a CI/release step, a cross-repo PR), mark it RESOLVED — +acknowledged and DEFERRED to the ship phase. You cannot satisfy a ship-phase gate +mid-review, and holding it open deadlocks the debate forever; the review converges on +the CODE. This is ONLY for a genuine non-code/process gate, NEVER a code change CLAUDE +would simply rather not make — those you still hold firm on. + +Return your updated review in the JSON schema: + - findings: one entry per issue, each with severity and the stable id you used + before. status=resolved once addressed (CLAUDE fixed it, OR you accept CLAUDE's + reasoning); else open. + - approved: true ONLY when EVERY finding is resolved, at every severity. + - responseToRebuttal: address each of CLAUDE's disputes individually — concede or + hold firm with specific, technical reasoning. Leave no dispute unanswered. +EOF +)" +else + prompt="$(cat <<EOF +You are CODEX, a rigorous senior code reviewer. Review the changes in this branch +and give your honest, thorough feedback — exactly as you would on a serious PR. +You're in a debate with the author ("CLAUDE"), who will fix what they agree with +and push back, with reasons, on what they don't. + +Inspect the change yourself (READ-ONLY — do not modify, create, or delete anything, +and run no git write command: add/commit/push/stash/checkout): + + git diff $base (committed + unstaged changes on this branch) + git status --short (untracked/new files — read those too; they aren't in the diff) + +Read every changed file plus enough surrounding code to judge it in context. +Ignore the debate's own scratch dir '.codex-debate/' if it appears. +$rationale_block +Give ALL your feedback in this pass — every issue worth raising, at EVERY severity +(blocking, major, minor, nit): correctness bugs, logic errors, silently swallowed +errors, unjustified fallbacks, security problems, and clear simplicity/efficiency +issues. Don't hold issues back for a later round, and don't limit yourself to +blockers — surface everything you see now. Cite file:line. (If the change is +genuinely clean, approving with no findings is fine — just never stay quiet about +a real issue to seem agreeable.) +$rebuttal_block +Return your review in the JSON schema: + - findings: one entry per issue, each with a severity and a stable id (F1, F2, …) + reused across rounds for the same issue. Set status=resolved once it is + adequately addressed (CLAUDE fixed it, OR you accept CLAUDE's reasoning); else open. + - approved: true ONLY when EVERY finding is resolved — all your feedback addressed + at every severity, not just blockers. The review is not done while any issue you + raised still stands open. + - responseToRebuttal: when CLAUDE disputes a finding, address each dispute + individually — concede (mark that finding resolved) or hold firm with specific, + technical reasoning. Leave no dispute unanswered. Empty on round 1. + +One EXCEPTION to "hold firm": if CLAUDE shows a finding is NOT a code edit for THIS +worktree but a downstream / ship-phase / process gate (a companion repo pinning this +repo's final post-review HEAD, a CI/release step, a cross-repo PR), mark it RESOLVED — +acknowledged and DEFERRED to the ship phase. You cannot satisfy a ship-phase gate +mid-review, and holding it open deadlocks the debate forever; the review converges on +the CODE. This is ONLY for a genuine non-code/process gate, NEVER a code change CLAUDE +would simply rather not make — those you still hold firm on. +EOF +)" +fi + +# Drive codex for this round (retry/backoff, thread capture, error fallback) — the +# shared core does the work; this script supplied the prompt, schema, and shapes. +codex_exec_round "$schema" "$out" "$session_id_file" "$effort" "$resume_id" "$prompt" diff --git a/.agents/skills/codex-debate/scripts/codex-verdict.schema.json b/.agents/skills/codex-debate/scripts/codex-verdict.schema.json new file mode 100644 index 000000000..7703af22e --- /dev/null +++ b/.agents/skills/codex-debate/scripts/codex-verdict.schema.json @@ -0,0 +1,61 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "approved": { + "type": "boolean", + "description": "true ONLY when every finding is resolved — all your feedback addressed at any severity (minor and nit included), not just blockers." + }, + "summary": { + "type": "string", + "description": "One-paragraph assessment of the change as it currently stands." + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable identifier reused across rounds for the same issue, e.g. F1, F2." + }, + "severity": { + "type": "string", + "enum": ["blocking", "major", "minor", "nit"] + }, + "location": { + "type": "string", + "description": "file:line (or file) the finding applies to." + }, + "issue": { + "type": "string", + "description": "What is wrong and why it matters." + }, + "suggestion": { + "type": "string", + "description": "Concrete fix or direction." + }, + "status": { + "type": "string", + "enum": ["open", "resolved"], + "description": "resolved once the authoring engineer has adequately addressed it (by a fix or a dispute you accept); otherwise open." + } + }, + "required": [ + "id", + "severity", + "location", + "issue", + "suggestion", + "status" + ] + } + }, + "responseToRebuttal": { + "type": "string", + "description": "Directly address the authoring engineer's disputes from the previous round: concede (and mark the finding resolved) or hold firm with specific reasoning. Empty string on the first round." + } + }, + "required": ["approved", "summary", "findings", "responseToRebuttal"] +} diff --git a/.agents/skills/do/SKILL.md b/.agents/skills/do/SKILL.md index e0dd2d25d..5c8a8ac3c 100644 --- a/.agents/skills/do/SKILL.md +++ b/.agents/skills/do/SKILL.md @@ -340,10 +340,11 @@ Check whether a PR already exists for this branch (`gh pr view`). ```md ## [Hickey/Lowy](https://kolu.dev/blog/hickey-lowy/) Analysis - | # | Lens | Finding | Disposition | - |---|--------|------------------------------------------|-------------------| - | 1 | Hickey | viewportDimensions complects two roles | Fixed in this PR | - | 2 | Lowy | useViewport encapsulates ghost concern | Fixed in this PR | + | # | Lens | Finding | Disposition | + |---|--------|------------------------------------------|---------------------| + | 1 | Hickey | viewportDimensions complects two roles | Fixed in this PR | + | 2 | Lowy | useViewport encapsulates ghost concern | Fixed in this PR | + | 3 | Lowy | clipboard.ts named after a consumer | ⚠️ **No-op** | ### Hickey rationale <prose from the hickey sub-agent> @@ -352,7 +353,7 @@ Check whether a PR already exists for this branch (`gh pr view`). <prose from the lowy sub-agent> ``` - The Disposition cell mirrors the sub-agent's Actions disposition verbatim — **Fixed in this PR** or **No-op** (deletion-only / subsumed by another finding). There is no Deferred disposition; if a sub-agent emitted one, the audit step above flipped it to Fixed in this PR. The Finding cell is the short bolded label the sub-agent emits at the start of each Actions entry. If both lenses produced zero findings, write a one-line "No findings — analysis below" instead of an empty table. + The Disposition cell mirrors the sub-agent's Actions disposition verbatim — **Fixed in this PR** or **No-op** (deletion-only / subsumed by another finding). **Render every No-op as `⚠️ **No-op**`** (warning emoji + bold) so the reviewer's eye lands on it; No-op rows are the ones a human most needs to scrutinize (a finding the reviewer acknowledged but didn't fix), and plain text lets them blend into the Fixed-in-this-PR rows above. There is no Deferred disposition; if a sub-agent emitted one, the audit step above flipped it to Fixed in this PR. The Finding cell is the short bolded label the sub-agent emits at the start of each Actions entry. If both lenses produced zero findings, write a one-line "No findings — analysis below" instead of an empty table. **If PR already exists** (followup runs, `--from` entry points): @@ -388,7 +389,13 @@ CI commands are typically local (e.g. `nix flake check`, `just ci`, `make ci`) a ### evidence -**Opt-in step.** Most projects skip this. The step exists so projects with empirical "did the feature actually work" needs — UI screenshots, performance benchmarks, demo recordings, output transcripts — can attach that evidence to the PR without baking the mechanism into agency. +**Opt-in step.** Most projects skip this. The step exists so projects with empirical "did this actually work" needs can attach proof to the PR without baking the mechanism into agency. That proof is **visual** _or_ **behavioral** — and the second kind is easy to under-fire on, because it often has zero visual diff: + +- **Visual** — UI screenshots, before/after stills, demo recordings; **video** when the change is about motion (an animation, a transition). +- **Behavioral** — proof that _state survives an interaction or a restart_. When the diff touches a persistence, restore, session, autosave, debounce/coalesce, or reconnect path, the evidence that matters is "does the round-trip still hold?", not a pixel change. These changes routinely have **no visual diff** yet are exactly where a survives-restart capture proves the fix didn't break recoverability (e.g. resize → stop the app → restart → restore session → panel returns at the resized width). +- **Other empirical** — performance benchmarks, output transcripts. + +**Bug fixes default to "demonstrate the fixed behavior."** The bug was usually invisible — a lost write, a storm, a hang, a broken round-trip — so a before→after or survives-restart clip is the evidence even when nothing _looks_ different. Don't gate evidence on a pixel changing; gate it on "is there a behavior worth proving." **If `--minimal`**: Skip with status `skipped` and reason `"--minimal"`. Move to **done**. @@ -402,6 +409,8 @@ CI commands are typically local (e.g. `nix flake check`, `just ci`, `make ci`) a The section is project-specific and free-form: it can be inline prose describing the capture procedure, a pointer to another file (`See ./scripts/capture-evidence.md`), a script reference (`Run ./scripts/capture-pr-evidence.sh and use its stdout`), or any combination. Don't second-guess the form — read it, then **spawn a sub-agent** (`Agent(subagent_type: "general-purpose", ...)`) so the capture work (MCP calls, screenshot uploads, gh API requests) doesn't pollute `/do`'s main context. +**Read the trigger broadly.** A project's section supplies the _capture mechanism_; the criterion for _when to fire_ is the visual-or-behavioral framing above. If the section's wording leans visual ("when the change has visible UI impact") but the diff is a behavioral fix on a persistence/restore/round-trip/debounce/reconnect path, capture the **behavior** anyway — the absence of a visual diff is not a reason to skip. Only skip when there is genuinely no behavior worth proving (a pure refactor, a docs change, an internal cleanup with no observable before→after). + The sub-agent prompt should include: - The literal section content from `.agency/do.md`. diff --git a/.agents/skills/hickey/SKILL.md b/.agents/skills/hickey/SKILL.md index ca8b6f881..45fc7fd1e 100644 --- a/.agents/skills/hickey/SKILL.md +++ b/.agents/skills/hickey/SKILL.md @@ -65,6 +65,7 @@ For each new abstraction (component, module, signal, type) the code introduces: 1. **Name what it represents at the domain level**, not the implementation level. 2. **Survey the codebase for the canonical in-repo pattern for the same *kind* of operation** — not just the same domain concept. If the diff adds a picker, find every other "pick a thing" surface in the project. If it adds a dialog or popover, find every other modal/overlay surface. If it adds a non-UI primitive (scheduler, error type, config loader, fetcher, state container), find the project's existing instance of that primitive kind. The implementer's "this is a new domain concept" framing is an easiness judgment; the *kind of operation* is the simplicity question. **When the canonical pattern exists and the diff reinvents it rather than extends it, surface that as the headline finding — before any micro-level critique inside the new abstraction.** 3. **"Mirror existing pattern" is an easiness judgment, not a simplicity judgment.** Creating ComponentB because ComponentA exists and looks similar adds a concept. Extending ComponentA keeps concept count flat. +4. **Package surface as a fragmentation site.** When evaluating a new published-shape package (`@org/foo`), read its exports list the way Layer 2 reads a per-entity structure — *does the consumer have to reconstitute one concept by wiring multiple exports together?* If yes, the package has fragmented one primitive into N exports and shipped the integration cost to the consumer. The fix is the same as any Layer-2 fix: collapse to one primitive at the natural layer (one entry point, internal submodules hidden). See `/lowy` §6.5 for the volatility-side argument; the worked example (`@kolu/solid-xterm@0.1` → `@0.2.0`, [kolu#998 commit `4af1c647`](https://github.com/juspay/kolu/commit/4af1c647)) is the same case study used there. **Budget heuristic.** The codebase survey in step 2 is worth its cost when the diff introduces a new top-level abstraction — typically signalled by new files added (`git diff --diff-filter=A --name-only origin/HEAD...HEAD` is non-empty) or a new exported component / module / type. Pure refactors, bug fixes, and line-level edits inside an existing abstraction don't trigger the survey. diff --git a/.agents/skills/kolu/SKILL.md b/.agents/skills/kolu/SKILL.md new file mode 100644 index 000000000..ddb707181 --- /dev/null +++ b/.agents/skills/kolu/SKILL.md @@ -0,0 +1,188 @@ +--- +name: kolu +description: >- + Drive one AI agent from another through kolu's terminals: spawn a Claude + Code / Codex / opencode session in a PTY, prompt it, watch the screen for its + reply, read it, and prompt again — a create→send→snapshot loop run with the + `kaval-tui` CLI directly (no MCP). `kaval-tui` writes input and reads + scrollback; `pulam-tui` adds a precise agent-state done-signal when you drive + hooked terminals. Triggers on "drive another agent", "send a prompt to a + terminal agent", "have one agent prompt another", "agent drives agent", + "orchestrate agents in terminals", "make Claude drive Codex", "prompt the + agent running in that terminal", or wiring a loop where one coding agent + supervises another. +--- + +# kolu — drive one agent from another through its terminal + +You can run a coding agent (Claude Code, Codex, opencode) inside a kolu-owned +PTY and steer it from the outside: type a prompt, submit it, watch the screen +until it's done, read what it said, type the next prompt. The whole toolkit is +**`kaval-tui`** — write input, read the screen, spawn, kill. The driver runs it +directly; there's no server to stand up and no MCP layer. + +`pulam-tui` adds a *precise* done-signal (`wait --until <state>`) — but only for +**hooked** terminals (see the last section). For a raw `kaval-tui`-spawned +terminal, the done-signal is **watching the screen settle**, below. + +## The loop + +```sh +id=$(kaval-tui create --json -- claude | jq -r .id) # spawn the inner agent +kaval-tui send "$id" "refactor the parser to use a lexer" # 1. TYPE the prompt +kaval-tui send "$id" --key Enter # 2. SUBMIT it (its own step) +wait_until_settled "$id" # 3. let its turn finish (below) +kaval-tui snapshot "$id" --viewport # 4. read the screen +kaval-tui send "$id" "now add tests for it"; kaval-tui send "$id" --key Enter # loop +``` + +Leaf commands, all `kaval-tui`: **create** (spawn) · **send** (type) · +**send --key Enter** (submit) · **snapshot** (read) · **kill**. **Typing and +submitting are two separate `send`s** — that is load-bearing, see below. + +> **Read with `snapshot --viewport`, not `| tail`.** A bare `snapshot` prints the +> **whole scrollback** — thousands of lines on a long-running or compacted agent — +> so `snapshot | tail -8` hands you the bottom of the buffer (often just trailing +> blanks), not the live screen. `--viewport` asks the daemon for just its +> terminal's last screenful — the right "what's on screen now" read, and correct +> regardless of how tall your own shell is (over `--host` the remote terminal is a +> different size). `--tail N` (alias `--lines N`) bounds it to the last N lines +> when you want a fixed slice. + +## `kaval-tui send` — type, then submit (two steps) + +`kaval-tui send <id> [text...]` writes input to the terminal — **exactly the text +(and any `--key`s) you pass, with NO implicit Enter**. It types; it does not +submit. **Submitting a prompt is its own second `send`:** + +```sh +kaval-tui send "$id" "fix the failing test in parser.ts" # 1. type the prompt +kaval-tui send "$id" --key Enter # 2. submit it +``` + +**Do this as two separate `send` commands — not `send "text" --key Enter` in one +call.** The separation is load-bearing: an Enter sent in the same breath as the +text races Claude Code's bracketed-paste / debounced input handling — it arrives +before the pasted text has registered and is **silently dropped**, leaving the +prompt staged on the `❯` line while `send` reports success. A standalone +follow-up `send --key Enter` lands after the text has settled, so it actually +submits. (If a turn never seems to start, this is the #1 cause — `snapshot` and +look for the prompt sitting unsent on the `❯` line.) + +Specifics: + +- **Multiline prompts and piped stdin go as one bracketed paste**, so they land + in the input box as a block instead of submitting line-by-line. Automatic + (`--paste` / `--no-paste` force it). For a big prompt, pipe it — + `cat task.md | kaval-tui send "$id"` — then `send "$id" --key Enter`. +- **`--key <name>`** (repeatable, sent after the text) is both the submit channel + (`Enter`) and the control channel: `Escape`, `C-c`, `Enter`, + `Up`/`Down`/`Left`/`Right`, `Tab`, `Home`, `End`, `Backspace`, `M-<char>`. +- **`--json`** → `{ id, bytes, paste, keys }` to confirm what was written. + +**`send` is blind** — it writes whether or not the agent is ready for input. +Always pair it with `snapshot` so you don't fire a prompt into a not-yet-ready +session (e.g. before the TUI has drawn its input box, or over a trust prompt). + +**Interrupt a runaway** before redirecting it: + +```sh +kaval-tui send "$id" --key Escape # stop Claude Code mid-stream +kaval-tui send "$id" --key C-c # SIGINT whatever's running +``` + +## The done-signal — watch the screen settle + +After you submit, you need to know when the turn ends. For a raw +`kaval-tui`-spawned terminal there's no agent-state feed, so use the screen +itself: **poll `snapshot` until it stops changing.** A working agent streams +output, so a screen that's held still for a couple of polls means the turn ended +— it finished, *or* it's blocked asking you something (both mean "your move"). +Then read the snapshot to see which, and respond. + +```sh +# Block until <id>'s screen is unchanged across 2 polls, capped at a deadline so +# a wedged agent can't hang the loop forever (the manual equivalent of a timeout). +wait_until_settled() { + local id=$1 deadline=$(( $(date +%s) + 600 )) prev="" cur stable=0 + while [ "$stable" -lt 2 ] && [ "$(date +%s)" -lt "$deadline" ]; do + sleep 3 + cur=$(kaval-tui snapshot "$id" --viewport) # diff the live screen, not the whole scrollback + if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi + prev=$cur + done +} +``` + +Poll **`--viewport`**, not a bare `snapshot`: a settle test diffs two reads, and +diffing two full-scrollback dumps is slow and noisy (any scrollback churn reads +as "still moving"), while two screenfuls compare cleanly. + +Tune `sleep`/deadline to the work. It's coarser than `pulam-tui wait` (a busy +agent that pauses mid-thought can read as settled), so after it returns, +**confirm the reply is actually present** in the snapshot before moving on. + +## `pulam-tui wait` — the precise done-signal (hooked terminals only) + +When you *do* have agent-state detection, `pulam-tui wait <id> --until <buckets>` +is the exact done-signal — it blocks until the agent reaches a coarse state, then +exits 0: + +- **`working`** — busy (`thinking` / `tool_use` / background task). +- **`awaiting`** — `awaiting_user`: it's **asking you** a question. +- **`waiting`** — the **just-finished** post-turn lull. + +`awaiting` and `waiting` both mean "your move", so `--until awaiting,waiting` +catches a turn ending; `--timeout <ms>` fails loud (exit 2) so a wedged agent +can't hang the loop; if the terminal **exits** before reaching the state, `wait` +fails loud too (exit 3 — the agent you were driving died); `--json` → +`{ id, agent }`. + +> **Mind the stale-state race — wait in two phases.** `wait` matches the agent's +> state **the instant it connects**, replaying whatever it is right now. So right +> after a `send`, the agent may still report the *previous* turn's +> `waiting`/`awaiting` for a beat before it picks up the new prompt — and a lone +> `wait --until awaiting,waiting` would return immediately on that stale state, +> before the turn you asked for has even begun. For a robust loop, wait for the +> pickup first, then the turn-end: +> +> ```sh +> kaval-tui send "$id" "fix the parser"; kaval-tui send "$id" --key Enter +> pulam-tui wait "$id" --until working # 1. it picked up the prompt +> pulam-tui wait "$id" --until awaiting,waiting # 2. its turn ended +> ``` + +> **Caveat — agent state needs HOOKED terminals.** Detection keys on kolu's shell +> rc-hooks (the OSC marks a terminal emits as commands run). `kaval-tui create` +> is the **raw** multiplexer — a plain `$SHELL`, no hooks by design — so an agent +> you spawn that way often isn't detected, and `wait` will just time out. `wait` +> is reliable when you drive **already-hooked** terminals: the ones a running +> **kolu-server** spawned (point `kaval-tui --socket $XDG_RUNTIME_DIR/kolu/pty-host.sock` +> at them), or a future `kolu-tui`. For a raw `kaval-tui create` loop, use the +> screen-settle done-signal above. + +## Reach — which daemon you're driving + +Bare `kaval-tui` **autodiscovers** a running daemon on this machine. Two ways to +point it elsewhere: + +- **`--socket <path>`** targets a specific local daemon — e.g. a running + **kolu-server's** kaval (`$XDG_RUNTIME_DIR/kolu/pty-host.sock`), to drive the + terminals you have open in kolu (these ARE hooked, so `pulam-tui wait` works + against them once a `pulam` reads that kaval). +- **`--host <ssh>`** reaches a daemon on another machine (provisioned with Nix); + a remote PTY survives the link. + +## Acceptance + +Before calling a driven turn done: + +- You **submitted with a separate `send --key Enter`** (not an implicit Enter, + not `send "text" --key Enter` in one call) — a prompt left staged on the `❯` + line is the #1 failure here. +- The inner agent's **reply is actually in the `snapshot`** — not an empty box or + a half-rendered stream. The screen-settle wait is coarse; verify the content. +- Your wait had a **deadline / `--timeout`** so a wedged agent fails instead of + hanging the loop. +- If the screen settled on a **question** (the agent is awaiting you), you **read + it and answered** — you didn't send the next task on top of a blocked prompt. diff --git a/.agents/skills/lens-debate/SKILL.md b/.agents/skills/lens-debate/SKILL.md new file mode 100644 index 000000000..6bd3b4864 --- /dev/null +++ b/.agents/skills/lens-debate/SKILL.md @@ -0,0 +1,263 @@ +--- +name: lens-debate +description: Run a structural-review debate between two lenses — lowy (volatility-based decomposition) and hickey (structural simplicity) — on the current diff. Each reviews independently, then they cross-examine every finding until they agree per-finding, and the agreed fixes are applied. Use when the user types `/lens-debate`, or asks to "have lowy and hickey review this", "run the lens debate", "debate this diff structurally", or "argue the structure of this PR until the lenses agree". +argument-hint: "[<pr-number>] [--base <branch>] [--max-rounds <n>] [--no-commit] [--no-apply] [--no-comment] [--with-police]" +--- + +# Lowy ⇄ Hickey lens debate + +Two structural reviewers argue your change to a settled conclusion. **lowy** +(volatility-based decomposition — do boundaries encapsulate axes of change?) and +**hickey** (structural simplicity — are independent concerns complected, or one +thing fragmented?) each review the diff *independently*, then cross-examine +**every** finding from **both** reviews until they agree on each one. The agreed +`fix` findings are applied — each as its own commit — and the outcome is **posted +to the PR** as a comment. You stay out of the middle: the script couriers +schema-constrained dispositions between the lenses and decides when they agree. + +This is the sibling of `/codex-debate`. Same engine (the `Workflow` tool), same +"both sides emit structured JSON so agreement is detected in code, not by vibes," +same "commits but never pushes or merges." The difference is *who debates whom*. + +## Why this shape + +The structure was found by trial in #1109, and two parts of it are load-bearing: + +- **Independent parallel review, then debate.** lowy and hickey review the diff + *simultaneously and independently* — neither sees the other's findings before + forming its own. A first cut fed hickey a *pre-curated* "lowy finding" to rebut + and it concluded *drop* — framing bias. Running the reviews independently in + parallel made hickey raise the same issue on its own, flipping the verdict to + *fix*. **Curation biases the outcome; independent-then-debate does not.** So the + lenses never trust a handed-down finding list — each reads the source itself. + +- **Both lenses run on Opus** (overriding their `model: sonnet` frontmatter), as + `/be` already requires for structural review. + +- **The lowy lens runs Löwy's electricity probe.** Beyond the generic "where's + the boundary?", the lowy reviewer must name the *receptacle* (the stable + interface consumers plug into), the *volatile implementations* behind it, + whether the thing is "electricity" (a domain-agnostic utility) or an app + concern, and where a consumer is forced to "expose the wires." This is **not a + second lens** — a separate voice would double-count lowy and reintroduce the + framing bias above. It's the same volatility vote with a sharper probe that + reliably pulls structural review out of abstraction and into "what plugs into + what" (the abstraction-without-grounding failure mode a lens debate is prone + to). It earned its keep on a live run (#1111). + +## Why deadlock is not possible + +Neither this skill nor `/codex-debate` has a deadlock exit — both run until +consensus, as many rounds as it takes. But the *reason* convergence is safe to +rely on is even stronger here. In `/codex-debate` the asymmetry is reviewer vs +**author**: Claude wrote the code and carries an authorship stake, so in +principle it could dig in and dispute a finding round after round (the loop +trusts good-faith concession to break the tie, and aborts only on reviewer +*infrastructure* failure). + +Here both sides are **disinterested third-party lenses** applied to someone +else's diff. Neither authored the code; neither has anything to defend. Their +disagreements are not ego conflicts but framework-weighting differences ("is this +worth fixing in *this* PR?") about a shared question with a knowable answer. Two +good-faith analysts, each told to argue from the code and concede when the other +is right, **converge** — there is no fixed position to defend. So there is **no +deadlock exit**: the debate runs until consensus, as many rounds as it takes. + +Three mechanics make that real rather than hopeful: + +1. **Independent review** (above) removes the up-front framing bias. +2. **Settled findings lock.** The moment both lenses agree on a finding's + disposition, it leaves the active set. The contested set is monotonically + non-increasing — the debate can only shrink, never grow, so it can't oscillate + a settled point back open. +3. **Sequential reveal.** Within a round lowy posts first and hickey answers + lowy's *current* positions, so the two land together instead of chasing each + other's stale positions. + +`--max-rounds` (default **12**) is a pure safety backstop so a pathological +oscillation can't run unbounded — not a deadlock cap. Reaching it is reported as +`unresolved` (needs a human), never `deadlock`, and should essentially never +happen between two good-faith lenses. + +**This skill requires Claude Code's `Workflow` tool** (it is the engine). Under +codex/opencode runtimes the skill is inert. + +## Arguments + +Parse `[<pr-number>] [--base <branch>] [--max-rounds <n>] [--no-commit] [--no-apply] [--no-comment] [--with-police]`: + +- **`<pr-number>`** (optional): a PR to debate. If given, `gh pr checkout <n>` + first and default the base to that PR's base branch. If omitted, debate the + **current branch's** diff. +- **`--base <branch>`**: ref to diff against. Always a **remote-tracking ref**, + never a stale local branch. Default: `origin/<PR base>` when a PR number is + given, else the repo default branch via + `git symbolic-ref --short refs/remotes/origin/HEAD` (e.g. `origin/master`), + used **as-is**. Fallback `origin/master`. Step 1 runs `git fetch origin` first. + The workflow resolves this to the **merge-base** of `base` and HEAD and diffs + against that, so the base branch's drift since the fork isn't reviewed as ours. +- **`--max-rounds <n>`**: safety backstop on debate rounds. Default **12**. Not a + deadlock cap (see above) — raise it freely. +- **`--no-commit`**: still apply the agreed fixes to the working tree, but leave + them uncommitted for you to commit yourself. Default is to **commit each fix + individually** (see below). +- **`--no-apply`**: skip the Apply phase entirely — the debate still settles every + finding, but the agreed `fix` plans are **returned** (the `fixes` field) instead + of implemented. For callers that want to review or re-validate the change + requests against a different tree before applying them themselves. Implies + nothing about commenting; the comment then records the fixes as "handed off". + (`--no-commit` is moot under `--no-apply` — nothing is implemented, so nothing + is committed.) +- **`--no-comment`**: don't post the debate summary to the PR. By **default**, + when a PR exists, the summary IS posted as a PR comment (see step 3). +- **`--with-police`**: fold in `/code-police` as a third, **lower-weight voice**. + It runs in the parallel review and *seeds* findings into the debate, but does + **not** get a vote in consensus — only lowy ⇄ hickey decide agreement. Off by + default (in #1109 its findings largely duplicated the lens findings). + +## Steps + +### 1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- **`git fetch origin`** so the base remote-tracking ref is current. +- Resolve `base` per the rules above (a remote-tracking ref like `origin/master`). +- If a PR number was given, `gh pr checkout <n>` and confirm the branch. +- Confirm a non-empty diff: `git diff --stat <base>`. If empty, say there's + nothing to review and stop. + +### 2. Run the debate Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed script, passing +context through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/lens-debate/debate.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + base: "<base branch>", // a remote-tracking ref, e.g. origin/master + maxRounds: <n, default 12>, + commit: <false only if --no-commit>, + apply: <false only if --no-apply>, + withPolice: <true only if --with-police>, + rationale: "<optional author note on deliberate design decisions>", + model: "<optional model override; defaults to opus>" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It runs +three phases the user can watch via `/workflows`: + +- **Review** — `review:lowy`, `review:hickey` (and `review:code-police` with + `--with-police`) in parallel, each independent. +- **Debate** — alternating `lowy:roundN` / `hickey:roundN` until every finding is + agreed. Agreed findings drop out of each subsequent round. Agreement on a `fix` + means both lenses agree on the disposition *and* the plan — if they both say + `fix` but propose different changes, the finding stays open until the plans + converge too (so Apply never picks one lens's plan arbitrarily). +- **Apply** — a single `apply:all` agent implements **every** agreed `fix` in one + session and (unless `--no-commit`) commits each one individually, staging + **exactly** that fix's changed files with a message carrying the debate context. + One orientation for all fixes instead of a fresh implement+commit agent per + finding. Skipped wholesale under `--no-apply` — the plans come back in `fixes` + for the caller to apply. + +When `rationale` is set, pull it from the PR/issue description (the deliberate +design decisions the author wants the lenses to respect, e.g. a deliberate +fail-open) so the lenses don't flag intentional choices. + +Ephemeral scratch (commit-message files) lives under the gitignored, per-worktree +`<repoPath>/.lens-debate/`, so parallel debates in different worktrees never +collide and the scratch never shows up in the diff the lenses review. It returns: + +``` +{ status: "consensus" | "apply-incomplete" | "unresolved" | "clean", + rounds, base, withPolice, + settled, // per-finding: id, origin, title, location, agreed disposition, plan, both reasonings + unresolved, // findings still contested at the backstop (empty on consensus) + applied, // [{ id, title, files, commit }] (empty under --no-apply) + applyGaps, // [{ id, reason }] agreed fixes that didn't cleanly land — empty unless status is "apply-incomplete" + fixes, // the agreed `fix` findings with converged plans — the caller's change requests under --no-apply + reviews, // each lens's independent findings + history, // per-round dispositions + comment } // the deterministically rendered PR comment body — post it VERBATIM (step 3) +``` + +- **consensus** — every finding settled (the normal outcome). +- **clean** — every lens found nothing worth raising. +- **apply-incomplete** — the lenses *converged*, but the Apply phase didn't land + every agreed fix cleanly: a fix was **missing from the apply agent's output** + (so we can't confirm it was applied) or, in commit mode, was **changed but + returned no commit SHA** (its per-fix commit didn't land). The offending fixes + are in `applyGaps`. Any edits present stay in the working tree, but this is + **not** a clean consensus — surface the gap and reconcile it (re-apply or commit + the outstanding fix) before relying on the per-fix history. Do **not** report it + as a plain consensus. +- **unresolved** — the backstop was hit with findings still contested. Rare; + needs a human. This is NOT a deadlock — the lenses simply didn't converge in + the round budget; raise `--max-rounds` or adjudicate the listed findings. + +### 3. Present the result + +Report in chat (do **not** push or merge — the per-fix commits sit on the local +branch for the human to review): + +- The outcome (`status`) and round count. +- `git log --oneline <base>..HEAD` (the per-fix commits) and `git diff --stat + <base>` so the user sees what the debate changed. +- A per-finding table from `settled`: origin (lowy/hickey/police), title, + location, agreed disposition (fix/drop), and the applied commit SHA for fixes. +- On any **unresolved** finding, surface both lenses' final positions plainly so + the human can adjudicate — do not pick a winner yourself. +- **Post the debate summary to the PR (default).** When a PR exists and + `--no-comment` was NOT passed, post the workflow's **deterministically rendered + `comment`** verbatim — write it to a file and `gh pr comment <pr> -F <file>`: + + ```bash + mkdir -p "$repoPath/.lens-debate" # clean/all-drop/--no-commit runs never run the Apply commit step, so the dir may not exist yet + printf '%s' "$comment" > "$repoPath/.lens-debate/comment.md" + gh pr comment <pr> -F "$repoPath/.lens-debate/comment.md" + ``` + + The workflow returns `comment` already rendered — the + `## [⚖️ Lowy ⇄ Hickey lens debate](https://kolu.dev/blog/hickey-lowy/)` header + with the outcome badge and round count, the independent per-lens finding counts, + the applied fixes (with commit SHAs), the agreed no-change observations, and any + unresolved findings with both lenses' positions. Posting the returned string + (rather than re-improvising a table) keeps the comment a **deterministic** render + of the debate outcome. This mirrors `/codex-debate`; `--no-comment` suppresses it. + +## Safety & notes + +- **The lenses are read-only reviewers; only the Apply phase writes.** lowy and + hickey never edit code — they only emit dispositions. The sole writes to the + tree come from the single `apply:all` agent implementing the *agreed* fixes + (one session, one commit per finding) — not one agent per fix. +- **Commits, but never pushes or merges.** Each agreed fix is committed locally + (unless `--no-commit`) so the PR history reads as the debate's conclusions, but + the skill never pushes or merges. Consensus means "both lenses agree on the + disposition," not "ship it" — the human reviews the commits and pushes/merges. +- **No deadlock; bounded by a safety backstop.** The loop runs to consensus. + `--max-rounds` only prevents a pathological unbounded run; reaching it is + reported as `unresolved`, not deadlock. +- **Parallel-safe.** Ephemeral scratch lives under the gitignored, per-worktree + `<repoPath>/.lens-debate/`, so debates on many worktrees run at once without + clobbering each other. +- **Posts to the PR by default** (unless `--no-comment`) — the point is to leave + the structural-review trail on the PR. + +## Files + +- `debate.workflow.js` — the Workflow script (parallel review + the + lock-and-converge debate loop + the apply phase). + +The lenses read `.claude/skills/{lowy,hickey}/SKILL.md` (and +`.claude/skills/code-police/SKILL.md` with `--with-police`) at runtime for their +frameworks. + +This is generated from `agents/.apm/skills/lens-debate/`; edit the source there and run +`just ai::apm` to regenerate. + +ARGUMENTS: $ARGUMENTS diff --git a/.agents/skills/lens-debate/debate.workflow.js b/.agents/skills/lens-debate/debate.workflow.js new file mode 100644 index 000000000..48ed7f3b7 --- /dev/null +++ b/.agents/skills/lens-debate/debate.workflow.js @@ -0,0 +1,584 @@ +// The Workflow runtime requires `export const meta` to be the FIRST statement +// and a PURE LITERAL (no variable interpolation), so the primary model is +// inlined as 'opus' in the phase entries below. The only Apply-phase agent is a +// single `apply:all` on `model` (Opus) that implements and commits each agreed +// fix in-session. Those inlined 'opus' phase entries plus the `const MODEL` +// socket just after meta are the model bindings — every other model reference in +// this script reads MODEL lazily at input-resolution time, well after meta is +// evaluated. +export const meta = { + name: 'lens-debate', + description: + 'lowy + hickey review a diff independently in parallel, then debate every finding to consensus; apply the agreed fixes', + phases: [ + { title: 'Review', detail: 'lowy and hickey (and optionally code-police) review the diff independently, in parallel', model: 'opus' }, + { title: 'Debate', detail: 'lowy and hickey cross-examine every finding until they agree per-finding', model: 'opus' }, + { title: 'Apply', detail: 'implement each agreed fix as its own commit (skipped under apply:false)', model: 'opus' }, + ], +} + +// The model every lens/agent runs on. SKILL.md flags this as load-bearing +// (lenses run on Opus, overriding their `model: sonnet` frontmatter) and model +// migrations are a recurring change — keep it to one socket. Inlined into the +// phase entries above (meta must be a pure literal); the `model` input below +// defaults to it. +const MODEL = 'opus' + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so it arrives as a +// STRING even when the caller passed a real object; a bare `args.repoPath` would then +// be `undefined` and every input (repoPath/base/rationale/…) silently default. That's +// the cross-repo bug: `repoPath` degrades to `.` (the cwd), the lenses review the +// WRONG repo and the apply phase commits onto it. Parse a stringified `args` +// defensively (empty string → {}; object used as-is; malformed JSON throws loudly, +// fail-fast). See codex-debate/debate.workflow.js for the same fix and its evidence. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The diff base. Resolved to the MERGE-BASE of (rawBase, HEAD) just below, before +// DIFF is built, so the lenses review only what THIS branch changed — not commits +// the base branch gained since the branch forked (those would otherwise appear in +// `git diff base` as the base branch's drift, reviewed as ours). `let` because the +// resolution reassigns it. Idempotent when the caller already passed a merge-base +// SHA (e.g. /be-review). +let base = a.base || 'origin/master' +// Safety backstop only — NOT a deadlock cap. The debate runs until consensus; +// this just keeps a pathologically oscillating debate from running unbounded. +// Hitting it is reported as `unresolved` (needs human), never `deadlock`, and +// should essentially never happen between two good-faith lenses. Raise freely. +const maxRounds = a.maxRounds || 12 +// Apply agreed `fix` findings as individual commits (default on). `--no-commit` +// still applies the edits to the working tree, it just leaves them uncommitted. +// No-op when `apply` is false — the apply:false path returns plans in `fixes` +// and never commits; `commit` only gates the in-workflow Apply phase. +const commit = a.commit !== false +// Run the Apply phase at all (default on). `apply: false` skips Phase 3 entirely: +// the debate still settles every finding, but the agreed `fix` plans are RETURNED +// (the `fixes` field) instead of implemented — for callers that want the agreed +// fix plans returned so they can apply them against a tree of their choosing. +const apply = a.apply !== false +// Fold in /code-police as a third, lower-weight voice: it SEEDS findings into +// the debate set but does not get a vote in consensus (only lowy ⇄ hickey do). +const withPolice = a.withPolice === true +// Optional author note on deliberate design decisions, so the lenses don't flag +// intentional choices (e.g. a deliberate fail-open). Threaded into every prompt. +const rationale = (a.rationale || '').trim() +// Model every lens/agent runs on; defaults to MODEL (see top of file). Overridable +// via args to mirror the file's input pattern and to make a model bump a one-liner. +const model = a.model || MODEL +// Mechanical tier (Haiku). The lenses' reviews + the per-finding debate + applying +// an agreed fix all do real reasoning → `model` (Opus, load-bearing for the +// lenses). The merge-base resolver is pure git → run it on `mechModel`. +// Defaults match a direct invocation; /be-review passes it. +const mechModel = a.mechModel || 'haiku' +// Per-worktree scratch for commit-message files; gitignored so it never shows up +// in the diff the lenses review, and parallel debates in different worktrees +// never collide. Only the commit-message files land here. +const workDir = `${repoPath}/.lens-debate` + +// Löwy's "electricity" probe — a sharper version of the SAME volatility lens, NOT +// a second voting voice (a separate lens would double-count lowy and reintroduce +// the up-front framing bias this skill avoids). It forces the abstract "where's +// the boundary?" down to the concrete "what plugs into what?", which is exactly +// the abstraction-without-grounding failure mode a lens debate is otherwise prone +// to. Earned its keep on a live run (#1111). Baked into the lowy reviewer's output. +const ELECTRICITY_PROBE = `As a REQUIRED part of your output, apply Löwy's electricity test (Righting Software / The Method) to ground the boundary question in "what plugs into what": name the **receptacle** (the stable interface every consumer plugs into), name the **volatile implementations** that receptacle encapsulates (the interchangeable generators behind it), say whether this is "electricity" (a domain-agnostic utility) or an application concern, and call out where a consumer is forced to "expose the wires" — reach past the receptacle and depend on a specific implementation. If the diff has no such boundary, say so explicitly; do not invent one.` + +// The two structural lenses that debate to consensus. code-police, when enabled, +// is appended as a finding SOURCE only — it is not a debater. +const DEBATERS = ['lowy', 'hickey'] +const REVIEWERS = [ + { lens: 'lowy', framework: 'volatility-based decomposition — do boundaries encapsulate axes of change? (Lowy / Parnas)', probe: ELECTRICITY_PROBE }, + { lens: 'hickey', framework: 'structural simplicity — independent concerns complected, or one thing fragmented? (Simple Made Easy)' }, +] +if (withPolice) REVIEWERS.push({ lens: 'code-police', framework: 'code quality, correctness, and common-mistake review' }) + +// The result shape's empty collections, shared by the two EARLY returns +// (merge-base-error, clean) so adding a result field is one edit, not a mirror +// edit per return site. The final return carries real values and stays literal. +const EMPTY_RESULT = { settled: [], unresolved: [], applied: [], applyGaps: [], fixes: [], reviews: {}, history: [] } + +// Resolve the diff base to the merge-base of (base, HEAD) BEFORE building DIFF +// (which interpolates `base` eagerly), so the lenses review only what this branch +// changed, not the base branch's drift since the fork. A thin mechanical git +// agent (the workflow can't run git itself); grouped under the Review phase. +// Idempotent when `base` is already a merge-base SHA (caller resolved it). +const rawBase = base +const baseRes = await agent( + `You are a MECHANICAL RUNNER. Run \`git -C ${repoPath} merge-base ${base} HEAD\` and return ONLY the resulting commit SHA (hex) in \`sha\`. If the command FAILS (missing/typoed base, stale ref, unrelated history), return \`sha\`: "" and put the verbatim git error in \`error\` — do NOT fall back to the raw base ref. Do nothing else.`, + { label: 'resolve:merge-base', phase: 'Review', model: mechModel, schema: { type: 'object', additionalProperties: false, required: ['sha'], properties: { sha: { type: 'string', description: 'the merge-base SHA, or "" on failure' }, error: { type: 'string', description: 'the git error when sha is empty' } } } }, +) +// Fail loud on a bad base. Falling back to the raw `${base}` tip would make the +// lenses review the base branch's drift since the fork as if this change made it — +// the exact noise the merge-base removes — so a missing/typoed/stale base aborts. +if (!baseRes?.sha?.trim()) { + const err = (baseRes?.error || '').trim() + log(`Aborting: \`git merge-base ${rawBase} HEAD\` failed; the diff scope can't be trusted. Not falling back to the raw ${rawBase} tip.`) + return { + ...EMPTY_RESULT, + status: 'merge-base-error', + base: rawBase, + rounds: 0, + withPolice, + note: `merge-base of \`${rawBase}\` and HEAD could not be resolved (missing/typoed base, stale ref, or unrelated history), so the review scope is untrustworthy. Fix the base ref (e.g. \`git fetch\`) and re-run.${err ? `\ngit error:\n${err}` : ''}`, + } +} +base = baseRes.sha.trim() + +// How every agent is told to inspect the change. The lenses do NOT trust a +// curated finding list — they read the source themselves (the load-bearing +// lesson from #1109: curation biases the verdict). +const DIFF = `Inspect the FULL change in the repo at \`${repoPath}\` — your shell cwd may be a DIFFERENT worktree, so use \`git -C ${repoPath}\` and ABSOLUTE paths under \`${repoPath}\`: run \`git -C ${repoPath} diff ${base}\` (committed + unstaged) and \`git -C ${repoPath} status --short\` (untracked/new files do NOT appear in the diff), then Read every new/changed file plus enough surrounding code to judge it in context. Ignore the debate's own scratch dir \`.lens-debate/\` if it appears.` + +const rationaleBlock = rationale ? `\nAuthor's note on deliberate decisions (do not flag these as defects unless the reasoning is itself wrong):\n${rationale}\n` : '' + +// --------------------------------------------------------------------------- +// Schemas — the review and the per-finding debate position +// --------------------------------------------------------------------------- +const FINDINGS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['findings'], + properties: { + findings: { + type: 'array', + description: 'ALL your independent structural findings — every issue worth raising through your lens, no cap. An empty list is fine only for a genuinely clean diff.', + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'location', 'problem', 'suggestion', 'disposition'], + properties: { + title: { type: 'string' }, + location: { type: 'string', description: 'file:line' }, + problem: { type: 'string', description: "the problem in your lens's terms" }, + suggestion: { type: 'string', description: 'a concrete, implementable change' }, + disposition: { type: 'string', enum: ['fix', 'drop'], description: 'fix = worth changing in THIS PR; drop = observation only' }, + }, + }, + }, + }, +} + +const POSITION_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['positions'], + properties: { + positions: { + type: 'array', + description: 'one entry for EVERY contested finding id you were given', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'disposition', 'reasoning'], + properties: { + id: { type: 'string' }, + disposition: { type: 'string', enum: ['fix', 'drop'] }, + plan: { type: 'string', description: 'if fix: the exact change, implementable' }, + agreesWithPlan: { + type: 'boolean', + description: + "when disposition===fix, true only if you endorse the other lens's plan as-is; if false, your `plan` field is the amendment that must still converge", + }, + reasoning: { type: 'string', description: 'argue from the code (cite file:line); concede explicitly when the other lens is right' }, + }, + }, + }, + }, +} + +// One Apply agent implements every agreed fix and commits each in a single +// session, so it returns the full per-fix outcome (not one impl per agent). One +// entry per fix it was handed; `commit` is "" under `--no-commit` or when a fix +// turned out to need no change. +const APPLY_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['applied'], + properties: { + applied: { + type: 'array', + description: 'one entry for EVERY agreed fix you were given, in the same order', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'summary', 'filesChanged'], + properties: { + id: { type: 'string' }, + summary: { type: 'string', description: 'one line: what you changed for this fix' }, + filesChanged: { type: 'array', items: { type: 'string' } }, + commit: { type: 'string', description: 'this fix\'s commit SHA, or "" if nothing was committed' }, + }, + }, + }, + }, +} + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- +function reviewBrief(lens, framework, probe) { + const probeBlock = probe ? `\n${probe}\n` : '' + return `You are the **${lens}** reviewer. First Read \`.claude/skills/${lens}/SKILL.md\` for your framework, then ${DIFF} + +Review the change through the **${framework}** lens, INDEPENDENTLY — you are NOT seeing any other reviewer's findings. That independence is the whole point: being handed someone else's curated finding biases the verdict. +${rationaleBlock}${probeBlock} +Give ALL your findings — every structural issue you see through your lens, no cap, at every level (boundary, complecting, naming, duplication, …). Each: a title, a file:line location, the problem in your lens's terms, a concrete suggestion, and a disposition — \`fix\` (worth changing in THIS PR) or \`drop\` (observation only). Don't fabricate issues, but don't hold any back either; an empty list is fine only for a genuinely clean diff.` +} + +function findingLine(f) { + return `### ${f.id} (raised by ${f.origin}) — ${f.title}\n at ${f.location}; raiser's disposition: ${f.disposition}\n problem: ${f.problem}\n suggestion: ${f.suggestion}` +} + +function debateBrief(lens, opp, activeFindings, oppPos, settledList, roundNum) { + const settledNote = settledList.length + ? `\nALREADY SETTLED (you both agreed — do NOT relitigate, shown for context only):\n${settledList.map((s) => `- ${s.id}: ${s.disposition}`).join('\n')}\n` + : '' + const oppBlock = oppPos + ? `**${opp}'s positions to rebut or concede, point by point:**\n${JSON.stringify(oppPos, null, 2)}\n\nFor each finding you also call \`fix\`, set \`agreesWithPlan\`: true only if you endorse ${opp}'s \`plan\` as-is. If false, your \`plan\` field is the amended plan that must still converge — the finding stays open another round until the plans agree, just like the disposition.` + : `Round 1 — give your initial disposition on every contested finding below, including ${opp}'s and any from other reviewers.` + return `You are **${lens}**, cross-examining **${opp}** to reach agreement. First Read \`.claude/skills/${lens}/SKILL.md\` for your framework, then ${DIFF} Ground every call in the source. +${rationaleBlock} +CONTESTED findings — disposition EVERY one (yours, ${opp}'s, and any from other reviewers): +${activeFindings.map(findingLine).join('\n\n')} +${settledNote} +${oppBlock} + +Round ${roundNum}. For EVERY contested finding id above, output a disposition (\`fix\` = worth changing in THIS PR / \`drop\` = leave as-is, observation only), a concrete implementable plan if \`fix\`, and reasoning grounded in the code. **The goal is the correct answer for THIS PR, not winning** — concede explicitly ("conceding: …") when ${opp}'s code-grounded argument is right. A \`fix\` is worth it only if it genuinely improves the PR.` +} + +// ONE brief for ALL agreed fixes — implemented and committed in a single Apply +// session, so the agent orients on the repo once instead of paying that cost per +// fix (the old form spawned an implement agent AND a commit agent per finding, +// serially). The fixes are independent and their plans already converged in the +// debate, so there's no cross-fix reasoning to isolate; what we keep is one +// commit PER finding so the history still reads finding-by-finding. +function applyAllBrief(fixes, doCommit) { + const list = fixes + .map( + (f) => `### ${f.id} (raised by ${f.origin}) — ${f.title} + at ${f.location} + problem: ${f.problem} + original suggestion (context, not the agreed plan): ${f.suggestion} + agreed plan: ${f.plan}`, + ) + .join('\n\n') + const commitStep = doCommit + ? `After a fix's edits are done, COMMIT that fix on its own before moving to the next, so each finding maps to one commit and the history reads finding-by-finding. Stage ONLY the files you changed for that fix — never \`git add -A\` or \`git add .\`. Write the message to a file under \`${workDir}\` (run \`mkdir -p ${workDir}\` first) and commit with \`git -C ${repoPath} add -- <files> && git -C ${repoPath} commit -F <msgfile>\`, using EXACTLY this message shape: + + fix(lens): <the fix's title> + + <your one-line summary of the change> + + Agreed by the lowy ⇄ hickey lens debate (finding <id>, raised by <origin>). Not pushed or merged. + +Do NOT push. Record each fix's resulting commit SHA (\`git -C ${repoPath} rev-parse HEAD\`) in its \`commit\` field. If a fix turns out to need no change, leave its \`filesChanged\` empty and its \`commit\` "".` + : `Do NOT git add / commit / push — leave every change in the working tree and set each fix's \`commit\` to "".` + return `You are implementing the changes that two structural-review lenses (lowy and hickey) independently agreed should be fixed in THIS PR. Work in the repo at \`${repoPath}\` — your shell cwd may be a DIFFERENT worktree, so every file you Read/Edit MUST be an ABSOLUTE path under \`${repoPath}\` and every git command MUST use \`git -C ${repoPath}\`. + +Apply each agreed fix below, IN ORDER. The fixes are independent — keep each one tightly scoped to its finding and don't let one bleed into another. Read the surrounding code first so each edit fits the existing style. You may run the project's formatter on files you touched. + +${list} + +${commitStep} + +Return one \`applied\` entry per fix (same order): its id, a one-line summary, the exact files you changed, and the commit SHA (or "").` +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const posMap = (res) => Object.fromEntries((res?.positions ?? []).map((p) => [p.id, p])) + +// Render the PR comment deterministically from the debate outcome, returned as a +// string so the ORCHESTRATOR posts it verbatim (`gh pr comment -F`) — no agent +// re-improvises a table. Unlike codex-debate there are NO per-round files to +// assemble: the lenses don't read a ledger (feeding them prior reasoning would +// invite entrenchment against conceding), so the comment is the only artifact. +// +// The header chrome (the `## ` title, the badge, the `base.slice(0, 12)`) is +// deliberately kept STRUCTURALLY PARALLEL to codex-debate's ledgerHeader chrome. +// The no-module workflow runtime has no imports, so a truly shared renderer isn't +// available; the two are instead siblings that move together. A house-style change +// (badge emoji, base-slice length, a new metadata row) is a mechanical mirror edit +// — make it here and in codex-debate's ledgerHeader. If the runtime ever admits a +// shared helper file, lift this common chrome there. +// `outcome` is the single mode bit for what happened to the agreed fixes: +// { kind: 'applied', items } when this run implemented them, or +// { kind: 'handed-off', items } when apply:false returned the plans to the +// caller — one param, so "at most one of applied/handed-off" holds by +// construction instead of by convention. +// `applyGaps` (agreed fixes the Apply phase did not cleanly land) is rendered +// HERE, not just in the machine `status`: the SKILL posts this comment verbatim, +// so an apply-incomplete run must surface a warning badge and a dedicated gap +// section instead of advertising `✅ Consensus` and listing the gapped fix as +// `Applied`. Keep this consistent with the status downgrade in Phase 3. +function renderComment({ rounds, settledOut, unresolved, outcome, reviewByLens, withPolice, base, clean, applyGaps = [] }) { + const gapIds = new Set(applyGaps.map((g) => g.id)) + const badge = applyGaps.length + ? `⚠️ **Apply incomplete** — ${applyGaps.length} agreed fix(es) not cleanly applied` + : clean + ? '✅ **Clean** — every lens found nothing worth raising' + : unresolved.length === 0 + ? '✅ **Consensus**' + : `⚠️ **${unresolved.length} unresolved**` + const counts = Object.entries(reviewByLens) + .map(([lens, fs]) => `${lens}=${fs.length}`) + .join(', ') + // A clean diff never debated, so the "after N round(s)" clause is omitted; the + // base, the lens roster, and the (all-zero) per-lens counts still ride along so + // the comment carries the same audit metadata as a debated run. + const meta = `lowy + hickey${withPolice ? ' + code-police' : ''} · base \`${(base || '').slice(0, 12)}\`` + const lines = [ + '## [⚖️ Lowy ⇄ Hickey lens debate](https://kolu.dev/blog/hickey-lowy/)', + '', + clean ? `${badge} · ${meta}` : `${badge} after ${rounds} round(s) · ${meta}`, + '', + `Independent findings: ${counts}`, + ] + const drops = settledOut.filter((s) => s.agreed && s.disposition === 'drop') + if (outcome.kind === 'applied') { + // Only CLEANLY-landed fixes go under `Applied`; a fix in `applyGaps` (missing + // from the apply output, or changed-but-uncommitted) is NOT applied work and + // must not be advertised as such under what would otherwise be a consensus + // badge — it gets its own gap section below. + const cleanlyApplied = outcome.items.filter((a) => !gapIds.has(a.id)) + if (cleanlyApplied.length) { + lines.push('', `### Applied (${cleanlyApplied.length})`) + cleanlyApplied.forEach((a) => lines.push(`- \`${a.id}\` ${a.title}${a.commit ? ` — commit \`${a.commit.slice(0, 9)}\`` : ' — (uncommitted)'}`)) + } + if (applyGaps.length) { + lines.push('', `### Apply incomplete — needs reconcile (${applyGaps.length})`) + const reasonText = { 'missing-from-output': 'not confirmed applied (absent from apply output)', uncommitted: 'changed but not committed (per-fix commit missing)' } + applyGaps.forEach((g) => { + const item = outcome.items.find((a) => a.id === g.id) + const title = item?.title ? ` ${item.title}` : '' + lines.push(`- \`${g.id}\`${title} — ${reasonText[g.reason] ?? g.reason}`) + }) + } + } + // apply:false runs hand the agreed plans to the caller instead of implementing + // them; the comment records the handoff so the trail still shows what was agreed + // (the caller appends its own apply outcomes when it posts this). + if (outcome.kind === 'handed-off' && outcome.items.length) { + lines.push('', `### Agreed fixes — handed off to the caller (${outcome.items.length})`) + outcome.items.forEach((f) => lines.push(`- \`${f.id}\` ${f.title} (${f.location})`)) + } + if (drops.length) { + lines.push('', `### Agreed — no change (${drops.length})`) + drops.forEach((d) => lines.push(`- \`${d.id}\` ${d.title} (${d.location})`)) + } + if (unresolved.length) { + lines.push('', `### Unresolved — needs human (${unresolved.length})`) + // Surface BOTH lenses' full final positions (disposition + reasoning + any + // plan), not just the bare verdict — a human adjudicating needs the actual + // disagreement, which lives in each side's reasoning/plan text. + unresolved.forEach((u) => { + lines.push('', `- \`${u.id}\` ${u.title} (${u.location})`) + for (const lens of ['lowy', 'hickey']) { + const p = u[lens] + const verdict = p?.disposition ?? '?' + const reasoning = p?.reasoning ? ` — ${p.reasoning}` : '' + lines.push(` - **${lens}**: ${verdict}${reasoning}`) + if (p?.plan?.trim()) lines.push(` - plan: ${p.plan}`) + } + }) + } + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// Phase 1 — independent parallel review +// --------------------------------------------------------------------------- +phase('Review') + +const reviews = await parallel( + REVIEWERS.map((r) => () => + agent(reviewBrief(r.lens, r.framework, r.probe), { label: `review:${r.lens}`, phase: 'Review', model, schema: FINDINGS_SCHEMA }), + ), +) + +const reviewByLens = {} +const combined = [] +REVIEWERS.forEach((r, idx) => { + const findings = reviews[idx]?.findings ?? [] + reviewByLens[r.lens] = findings + findings.forEach((f, i) => combined.push({ id: `${r.lens}-${i + 1}`, origin: r.lens, ...f })) +}) +log(`Independent findings: ${REVIEWERS.map((r) => `${r.lens}=${reviewByLens[r.lens].length}`).join(', ')}`) + +if (combined.length === 0) { + // Route the clean outcome through the SAME renderer as a debated run so the + // comment carries the same audit metadata (base, lens roster, per-lens counts, + // whether code-police ran) instead of a bare one-liner. + const comment = renderComment({ rounds: 0, settledOut: [], unresolved: [], outcome: { kind: apply ? 'applied' : 'handed-off', items: [] }, reviewByLens, withPolice, base, clean: true }) + return { ...EMPTY_RESULT, status: 'clean', rounds: 0, base, withPolice, note: 'every lens found nothing worth raising', reviews: reviewByLens, comment } +} + +// --------------------------------------------------------------------------- +// Phase 2 — debate to consensus. NO deadlock exit: the loop runs until every +// finding is agreed. Agreed findings LOCK (leave the active set), so the +// contested set is monotonically non-increasing — the debate can only shrink. +// Sequential reveal (lowy posts, hickey answers lowy's CURRENT positions) lets +// the two land together rather than chase each other's stale positions. +// --------------------------------------------------------------------------- +phase('Debate') + +const settled = {} // id -> { disposition, plan, lowy, hickey } +let activeIds = combined.map((f) => f.id) +let lowyPrev = null +let hickeyPrev = null +const history = [] +let status = 'unresolved' +let rounds = 0 + +for (let r = 1; r <= maxRounds && activeIds.length > 0; r++) { + rounds = r + const activeFindings = combined.filter((f) => activeIds.includes(f.id)) + const settledList = Object.entries(settled).map(([id, s]) => ({ id, disposition: s.disposition })) + + const lowyRes = await agent(debateBrief('lowy', 'hickey', activeFindings, hickeyPrev, settledList, r), { + label: `lowy:round${r}`, + phase: 'Debate', + model, + schema: POSITION_SCHEMA, + }) + const lowyPos = posMap(lowyRes) + + const hickeyRes = await agent(debateBrief('hickey', 'lowy', activeFindings, lowyPos, settledList, r), { + label: `hickey:round${r}`, + phase: 'Debate', + model, + schema: POSITION_SCHEMA, + }) + const hickeyPos = posMap(hickeyRes) + lowyPrev = lowyPos + hickeyPrev = hickeyPos + + const per = [] + for (const id of [...activeIds]) { + const l = lowyPos[id] + const h = hickeyPos[id] + // For a `fix`, agreement requires the second poster (hickey, who has seen + // lowy's positions) to endorse lowy's plan as-is — otherwise the finding + // stays active so the plan converges the same way the disposition does. + // `plan` is optional in the schema, so a `fix` can only settle once lowy has + // actually supplied a non-empty plan: endorsing an absent plan is not + // consensus, and Apply must never run on a `plan: undefined` (it would fall + // back to a vague placeholder and commit an arbitrary edit as "agreed"). + const lowyHasPlan = !!(l && typeof l.plan === 'string' && l.plan.trim()) + const agreed = !!( + l && + h && + l.disposition === h.disposition && + (l.disposition !== 'fix' || (h.agreesWithPlan === true && lowyHasPlan)) + ) + per.push({ id, lowy: l?.disposition ?? '?', hickey: h?.disposition ?? '?', agreed }) + if (agreed) { + // Endorsement guarantees l.plan is the converged text; no arbitrary fallback. + settled[id] = { disposition: l.disposition, plan: l.disposition === 'fix' ? l.plan : undefined, lowy: l, hickey: h } + activeIds = activeIds.filter((x) => x !== id) + } + } + history.push({ round: r, per }) + log(`Round ${r}: ${per.map((p) => `${p.id} ${p.lowy}/${p.hickey}${p.agreed ? '✓' : '✗'}`).join(' ')} | settled ${Object.keys(settled).length}/${combined.length}`) + + if (activeIds.length === 0) { + status = 'consensus' + break + } +} + +// Final per-finding verdict: agreed ones carry the consensus disposition; +// any still-contested ones are surfaced (unresolved → human), never silently dropped. +const settledOut = combined.map((f) => { + const s = settled[f.id] + if (s) { + return { id: f.id, origin: f.origin, title: f.title, location: f.location, problem: f.problem, suggestion: f.suggestion, agreed: true, disposition: s.disposition, plan: s.plan, lowy: s.lowy, hickey: s.hickey } + } + return { id: f.id, origin: f.origin, title: f.title, location: f.location, problem: f.problem, suggestion: f.suggestion, agreed: false, disposition: 'unresolved', plan: undefined, lowy: lowyPrev?.[f.id], hickey: hickeyPrev?.[f.id] } +}) +const unresolved = settledOut.filter((s) => !s.agreed) +log(`Debate ended: ${status} after ${rounds} round(s); ${settledOut.length - unresolved.length}/${settledOut.length} settled, ${unresolved.length} unresolved.`) + +// --------------------------------------------------------------------------- +// Phase 3 — apply every agreed `fix` finding in a SINGLE session, one commit +// per finding. One agent orients on the repo once and applies all the fixes, +// rather than paying a fresh implement+commit agent (and its re-orientation +// cost) per finding; the fixes are independent and their plans already +// converged, so there's no cross-fix reasoning to isolate. Skipped wholesale +// under `apply: false`: the agreed plans are returned in `fixes` for the caller +// to implement against whatever tree it chooses. +// --------------------------------------------------------------------------- +const fixes = settledOut.filter((s) => s.agreed && s.disposition === 'fix') +let applied = [] +// Agreed fixes the Apply phase did not cleanly land. Two failure shapes, both of +// which would otherwise be rendered as "applied" and reported under a consensus: +// - missing: the agent dropped the fix from its output entirely (no entry, no +// files) — we can't tell if it was applied, so it must not be reported as done. +// - uncommitted: in commit mode the agent changed files for the fix but returned +// no SHA — its per-fix commit didn't land, breaking "one commit per fix". +// The edits (when present) stay in the tree, so this is a status downgrade, not a +// hard abort: the caller reconciles the gap rather than losing a converged debate. +const applyGaps = [] +if (apply && fixes.length) { + phase('Apply') + const res = await agent(applyAllBrief(fixes, commit), { label: 'apply:all', phase: 'Apply', model, schema: APPLY_SCHEMA }) + const byId = Object.fromEntries((res?.applied ?? []).map((a) => [a.id, a])) + // Re-key off the agreed `fixes` (not the agent's array) so a fix the agent + // dropped from its output still surfaces — as 0 files / uncommitted — instead + // of vanishing from `applied` and the PR comment. + applied = fixes.map((f) => { + const entry = byId[f.id] + const a = entry || {} + const sha = (a.commit || '').trim() + const files = a.filesChanged ?? [] + if (!entry) { + // The agent never reported this agreed fix. We can't confirm it was applied, + // so flag it rather than render a phantom 0-file "applied" row as success. + applyGaps.push({ id: f.id, reason: 'missing-from-output' }) + log(`Apply ${f.id}: agreed fix absent from apply-agent output — not confirmed applied`) + } else if (commit && !sha && files.length > 0) { + // Reported changed-but-uncommitted in commit mode: the per-fix commit the + // agent was told to make didn't land. Surface it as a gap, not a clean apply. + applyGaps.push({ id: f.id, reason: 'uncommitted' }) + log(`Apply ${f.id}: agent changed ${files.length} file(s) but returned no commit SHA`) + } + return { id: f.id, title: f.title, files, commit: sha || null } + }) + applied.forEach((a) => log(`Applied ${a.id}: ${a.files.length} file(s)${a.commit ? `, committed ${a.commit.slice(0, 9)}` : ' (uncommitted)'}`)) + // A converged debate whose fixes didn't cleanly land is NOT a clean consensus: + // downgrade so /be-review (which keys off this status) and the comment don't + // advertise success over an unconfirmed/uncommitted fix. Only touch a status + // that was otherwise clean ('consensus'/'clean'); 'unresolved' already signals + // the human must act. + if (applyGaps.length && (status === 'consensus' || status === 'clean')) { + const prior = status + status = 'apply-incomplete' + log(`Apply incomplete: ${applyGaps.map((g) => `${g.id} (${g.reason})`).join(', ')} — downgrading ${prior} to apply-incomplete.`) + } +} else if (fixes.length) { + log(`Apply skipped (apply: false) — returning ${fixes.length} agreed fix plan(s) to the caller.`) +} + +return { + status, + rounds, + base, + withPolice, + settled: settledOut, + unresolved, + applied, + // Agreed fixes that didn't cleanly land (missing from the apply output, or + // changed-but-uncommitted). Empty unless status is 'apply-incomplete'; lets the + // caller pinpoint which fix to reconcile. + applyGaps, + // The agreed `fix` findings with their converged plans — the caller's + // change-request payload under `apply: false` (redundant with `settled` when + // the Apply phase ran, but always present so consumers need not re-filter). + fixes, + reviews: reviewByLens, + history, + comment: renderComment({ rounds, settledOut, unresolved, outcome: apply ? { kind: 'applied', items: applied } : { kind: 'handed-off', items: fixes }, reviewByLens, withPolice, base, applyGaps }), +} diff --git a/.agents/skills/lowy/SKILL.md b/.agents/skills/lowy/SKILL.md index 954db8228..3253c32d9 100644 --- a/.agents/skills/lowy/SKILL.md +++ b/.agents/skills/lowy/SKILL.md @@ -103,6 +103,26 @@ Volatility-based building blocks are reusable because they encapsulate one axis Lowy observes that reuse increases downward through layers: infrastructure and data-access components should be highly reusable across contexts, business-logic orchestrators are reusable across multiple clients, and clients/UI are rarely reusable. If a lower-layer component is locked to a single consumer, the boundary likely tracks functionality rather than a genuine axis of change. +**Single in-tree consumer is not disqualifying when the interface is stable under the encapsulated axis.** §5's bar is whether the interface would survive the volatility it claims to encapsulate — not whether it currently has more than one importer. A receptacle with one wire plugged in is still a receptacle. The published precedent: [`@kolu/surface`](https://kolu.dev/blog/surface-framework/) (and its peers `@kolu/solid-pierre`, the seven `@kolu/*` packages graduated from the [kolu#998 ralph loop](https://github.com/juspay/kolu/pull/998)) extracted from single-in-tree-consumer code. Each encapsulates a stable volatility axis its README names explicitly. The reuse-count check would have killed all of them. The interface-stability check admits them — correctly. The shape that disqualifies is *"the interface mirrors the implementation"*, not *"only one place imports it today"*. + +### 6.5 Package Coherence + +When the extraction crosses a *package* boundary (not just a module within the same package), the reviewer's job is not done after naming one volatility axis. The package as a whole must read as a **coherent library** — one concept, one socket. If the package ships three exports for three internal aspects of what should be one primitive, you have shipped *partial wiring*, not a receptacle. + +Run this check whenever the extraction adds a new published-shape package (`@org/foo`): + +1. **Read the package's exports list as if you were a new consumer.** Does it suggest one coherent thing or a topic-bundle? + - `@kolu/surface` exports `defineSurface` → one entry, one concept (typed reactive layer). Coherent. + - `@kolu/solid-xterm@0.1` (kolu#998 cycle 3–5) exported `createXtermWebgl`, `attachXtermStyleSync`, `createScrollLock` → three entries, three internal aspects of "xterm lifecycle" leaked through three exports. Not a coherent SolidJS adapter for xterm; a topic-bundle of three xterm-adjacent helpers. The fix shipped in `@kolu/solid-xterm@0.2.0` (commit [`4af1c647`](https://github.com/juspay/kolu/commit/4af1c647)) is one `createSolidXterm({ container, theme, fontSize, addons, webgl, scrollLock, ... })` primitive that hides WebGL / style / scroll as internal submodules. + +2. **Apply §5's atomic-verb rule at the package level.** §5 already warns that an interface exposing `OpenPort` / `ClosePort` / `AdjustBeam` alongside `ReadCode` mixes axes. A package exporting `createX_webgl` / `attachX_style` / `createX_scroll` does the same thing one altitude up: the package's surface is three operations on three axes, not one atomic abstraction. + +3. **The Surface test.** If the package's exports list does not resemble Surface's shape — *one entry point per coherent concept, with internal submodules hidden* — the package is shaped around the implementation, not around a stable contract. Even if each individual export passes §5 in isolation, the *package* fails the test. + +4. **The "consumer wires it together" smell.** If the only in-tree consumer imports several of the package's exports and then composes them by hand — the way `Terminal.tsx` had to wire `createXtermWebgl` + `attachXtermStyleSync` + `createScrollLock` + a bare `XTerm` constructor + 8 addon imports in v0.1 — the missing primitive is the composition. The package is shipping submodules and asking the consumer to be the integrator. Wrap them. + +**Action when this fires.** Re-extract behind a single primitive that owns the integrated lifecycle; demote the current exports to internal submodules of that primitive. The Lowy verdict is not "don't extract" — it's "extract one socket, not three wires." + ### 7. The Almost-Expendable Test Lowy's litmus test for correct decomposition: when a change request arrives, the response should be *contemplative* — you think through how to adapt. If a module is *expensive* to change, it's too big (functional decomposition has coupled unrelated concerns). If a module is *expendable* (trivially thrown away), it's an unnecessary boundary. If a module is *almost expendable* — it encapsulates just enough to contain one axis of change, and replacing it is straightforward but not trivial — the decomposition is correct. @@ -128,6 +148,8 @@ After completing all steps, **invoke `/fact-check` on your own output**. The fac - _"The module encapsulates [domain entity]"_ — domain entities are not volatility axes. What *about* the entity changes? Name the specific volatility or it's domain decomposition. - _"This is variable, so we should encapsulate it"_ — variable is not volatile. Can you state the risk in terms of likelihood and effect? - _"this is a new kind of [picker / dialog / scheduler / error type] for a new domain"_ — "new domain, same kind" duplicates the receptacle, not the volatility axis. Run the prior-encapsulation check from §1: the canonical pattern (command palette, generic dialog, single tagged error, etc.) is already the receptacle for this volatility. A parallel encapsulation is duplicated encapsulation, which maximizes change blast radius the same way functional decomposition does. +- _"Fails Lowy's reuse test"_ (when based on import count alone) — reuse-count is a symptom, not a diagnosis. The diagnosis is §5's interface-stability check. An interface can have one importer today and a perfectly stable contract; ten importers and still be shaped around its implementation. Cite the axis, not the count. +- _"Each export passes §5 in isolation"_ (without checking the package surface) — §5 fires per interface; §6.5 fires per package. Three coherent helpers in one package can collectively fail the package-coherence check if their union suggests one thing the package doesn't actually deliver. Read the exports list as a consumer would and ask "what library is this?" — if the answer is a topic-bundle ("xterm-adjacent helpers") rather than a primitive ("SolidJS adapter for xterm"), §6.5 applies. If fact-check finds issues, revise before presenting to the user. diff --git a/.agents/skills/odu-mcp/SKILL.md b/.agents/skills/odu-mcp/SKILL.md new file mode 100644 index 000000000..1e27d736c --- /dev/null +++ b/.agents/skills/odu-mcp/SKILL.md @@ -0,0 +1,32 @@ +--- +name: odu-mcp +description: odu MCP server launcher — drive CI from a coding agent. `bin/serve` resolves odu via Nix and runs `odu mcp` in the cwd. See the repo README for the tools/resources and override knobs. +user-invocable: false +--- + +# odu-mcp + +The agent face of [odu](https://github.com/juspay/odu) — an MCP stdio server +that re-exposes a live CI run as agent tools (`run`, `node_rerun`, +`wait_for_settle`, `cancel`) and subscribable resources (`surface://streams/nodes`, +`surface://collections/logs/{id}`), so Claude Code / Codex / opencode / Gemini +CLI drive CI with structured calls instead of scraping terminal output. + +`cancel` stops the live run and waits until it's torn down; `run`'s `supersede` +cancels a run already live here before starting (the "stop this, run the fixed +commit" move), and `linger` keeps the coordinator serving past settle so a node +can be rerun afterwards. Together they let the agent loop call off or replace a +run instead of stranding it or hitting "a run is already in progress". + +`bin/serve` is self-contained — it resolves odu via `nix run` and serves over +stdio in the consumer's repo (dialing `.ci/odu.sock`). Set `ODU_FLAKE` to +override the odu flake-ref (default `github:juspay/odu`); a repo that +re-exports odu can point it at its own pinned output with `ODU_FLAKE=.#odu`. + +Full docs in the [repo README](https://github.com/juspay/odu/blob/master/README.md). + +This skill primitive exists for APM's deployment convention — it lands +`bin/serve` at `.agents/skills/odu-mcp/bin/serve` in the consumer's working +tree (APM's skills-convergence path), which keeps the launcher available even +before `apm install` runs on a fresh clone. The package is mechanically a +"skill" in APM's primitive vocabulary; semantically it's a tool launcher. diff --git a/.agents/skills/odu-mcp/bin/serve b/.agents/skills/odu-mcp/bin/serve new file mode 100755 index 000000000..519fbd6cb --- /dev/null +++ b/.agents/skills/odu-mcp/bin/serve @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Launch the odu MCP server — odu's agent face. Resolves odu via Nix and runs +# `odu mcp` in the current directory: the server dials `.ci/odu.sock` and +# starts / attaches to runs in the cwd, so an MCP host launching this from the +# repo root drives that repo's CI. Self-contained — no surrounding devshell +# required. Requires flakes (`experimental-features = nix-command flakes`). +# +# Override knobs (env vars): +# ODU_FLAKE — flake-ref for odu (default: github:juspay/odu). A consuming +# repo that re-exports odu can point this at its pinned output, +# e.g. ODU_FLAKE=.#odu +set -euo pipefail +exec nix run --accept-flake-config "${ODU_FLAKE:-github:juspay/odu}" -- mcp "$@" diff --git a/.agents/skills/perfection-review/SKILL.md b/.agents/skills/perfection-review/SKILL.md new file mode 100644 index 000000000..fbcb6ba17 --- /dev/null +++ b/.agents/skills/perfection-review/SKILL.md @@ -0,0 +1,53 @@ +--- +name: perfection-review +description: >- + Adversarial "perfection" review — hold a change to an *ideal* bar, not just a correct one, + assuming eternal time, unlimited energy, and no ship pressure. Use when the user asks to + review "for perfection", to make a defect "impossible to express", or to hunt where a defect + "relocates" across review rounds. Grounds every claim in the diff, fans out adversarial + verifiers via Workflow, and reports residual surfaces with a structural fix for each. ONLY + invoke when the user explicitly asks for a perfection / ideal-bar review. +argument-hint: "[<pr-number>] [--base <branch>] [--post]" +--- + +# Perfection review + +The bar is not **"closed"** but **"the defect can no longer be expressed."** Assume eternal +time, unlimited energy, no deadline. "Overridable", "acceptable for scope", and "documented +intent" are *still holes*. + +Most defects are **one shape in costumes**. Name the shape, then hunt **where it relocates** — +a fix that satisfies the literal ask but lets the defect resurface one seam over is not done. +Track it across rounds until it has nowhere left to go. + +## Method + +1. **Ground in the diff, not the story** — the PR body, commit messages, docs, and the + author's claims are assertions to falsify, not facts. Check them against the code. +2. **Letter vs effect** — a safety added but never exercised is inert. Require a real path + that uses it and a test that fails when it is removed. +3. **Unspellable > absent** — make the wrong thing impossible to write, not merely + discouraged. If a future author can still spell the defect, it isn't closed. Beware a + guarantee that proves *presence*, not *behaviour*. +4. **Reconcile claim and code** — an overclaiming comment or doc is itself a defect: make the + code earn the sentence, or soften the sentence. +5. **Finish the blast radius** — not done until every dependent and downstream is carried to + the same bar and verified against the final state. + +## Verify adversarially — use Workflow + +**Review with fresh context, separate from the author** — a reviewer carrying the author's +context rationalizes the author's intent; a fresh mind grounded only in the diff does not. +Fan out grounded verifiers (one per claim) **plus an adversary whose only job is to express +the defect anyway**, each citing the diff; then synthesize. Default to *refuted-if-uncertain*, +loop until nothing new surfaces, and re-verify the headline finding yourself. Keep agent +schemas **flat** and isolate the adversary so one failure can't abort the run. + +## Report + +Lead with **credit** for what's closed — don't move goalposts on done work — then the +**residual surfaces**, ranked, each with its structural fix. Frame each finding as the +**invariant it violates**, not a lone exploit: a single proof-of-concept trains a one-line +patch and the defect just relocates — give the property, and if you must show an example, +give a few from different angles and call it one costume of many. Separate "the product is +fine" from "the claim isn't yet true". Post to the PR only when asked (`--post`). diff --git a/.agents/skills/pu/SKILL.md b/.agents/skills/pu/SKILL.md new file mode 100644 index 000000000..40b392dbb --- /dev/null +++ b/.agents/skills/pu/SKILL.md @@ -0,0 +1,77 @@ +--- +name: pu +description: >- + Provision and drive a `pu` box — an Incus container used as + a clean Linux host for CI, builds, and evidence capture. Use when you need to + run something on a fresh remote box instead of the user's machine: `nix run` a + build, run CI against a real host, capture screenshots/video off-machine, or + reproduce on a pristine environment. Covers create/connect/scp/destroy, running + remote commands, copying artifacts back, and the no-egress failure mode. + Triggers on "pu box", "spin up a box", "run this on a box", "ephemeral host", + "pu create/connect/destroy". +--- + +# pu — on-demand Incus boxes + +`pu` hands out Linux containers. Each box is a clean NixOS host with Nix ++ flakes, reachable over SSH through `pu`'s own proxy. Use one whenever work should +run **off the user's machine** — a CI run, a `nix run` build, evidence capture — +so nothing local is at risk and the environment is reproducible. A box can be short-lived +(spin up, use, `destroy`) or kept around long-term — the lifetime is yours to choose. + +## Lifecycle + +```sh +pu create "$host" # create; writes ~/.pu-state/$host/ssh_config +pu list # NAME + LOCATION (the physical host it landed on) +pu connect "$host" # interactive ssh +pu connect "$host" -- CMD # run CMD on the box and return +pu destroy "$host" # tear down — always do this when finished +``` + +Name is positional. Pick a descriptive, collision-free name (e.g. `app-pr-42-evidence`). + +`pu connect` is the reliable way in — it reads `~/.pu-state/$host/ssh_config` itself, so it +needs no setup. Bare `ssh "$host"` works **only** if you've added `Include +~/.pu-state/*/ssh_config` to `~/.ssh/config` (optional, often not set up); otherwise use +`pu connect`, or pass the config explicitly: `ssh -F ~/.pu-state/$host/ssh_config "$host"`. + +## Run commands on the box + +```sh +# One-shot +pu connect "$host" -- 'uname -a' + +# Background a long-running server (nohup so it survives the SSH session) +pu connect "$host" -- "nohup nix run github:owner/app -- --port 8080 >/tmp/app.log 2>&1 &" + +# Poll until it's healthy +pu connect "$host" -- 'until curl -sf http://127.0.0.1:8080/health; do sleep 2; done' +``` + +The box has its **own loopback** — bind servers to `127.0.0.1` on whatever port you +like; there is no clash with anything on the user's machine. + +## Copy artifacts back + +`pu connect` is SSH, so `scp` works against the box's generated config: + +```sh +scp -F ~/.pu-state/"$host"/ssh_config "$host":/tmp/out.png /tmp/out.png +``` + +## Failure mode: no outbound network + +A box occasionally lands on a host with broken egress — DNS and even raw-IP TCP +time out, so `nix run github:...` hangs on "Resolving timed out". This is +host-specific, not your fault. **Probe egress first; if it fails, destroy and +recreate** (a fresh box usually lands on a healthy host): + +```sh +pu connect "$host" -- 'timeout 15 curl -sS -o /dev/null -w "%{http_code}\n" https://api.github.com' \ + || { echo "no egress — recreating"; pu destroy "$host"; pu create "$host"; } +``` + +If retries keep landing on dead hosts, capture diagnostics for the admin — the box's +`LOCATION` from `pu list`, `/etc/resolv.conf`, `ip route`, and a `/dev/tcp` connect +test to the gateway and to a raw IP — and hand them over (e.g. a gist). diff --git a/.agents/skills/surface/SKILL.md b/.agents/skills/surface/SKILL.md new file mode 100644 index 000000000..e74adf07b --- /dev/null +++ b/.agents/skills/surface/SKILL.md @@ -0,0 +1,58 @@ +--- +name: surface +description: >- + How a downstream app consumes the shared @kolu/surface stack (@kolu/surface · + surface-app · surface-nix-host · surface-mcp) — declaring a typed reactive surface, + serving it, consuming it (SolidJS hooks or a CLI), and mirroring a remote surface over + ssh. Grounded in the real consumers: kolu, pulam-web, drishti, odu, and the TUIs. Load + when wiring a surface server/client/mirror, or reaching for getHostSession / a link / + the `.use()` hooks. CHANGING the framework is gated separately — + `.claude/rules/surface.md` (a paired, CI-green drishti PR pinned to final kolu HEAD). +--- + +# Using @kolu/surface (downstream consumer guide) + +Declare a typed reactive surface once; the framework derives the oRPC contract, wires the +server, and binds the Solid client hooks. **This is the consumer guide** — *changing* the +framework needs a paired drishti PR (`.claude/rules/surface.md`). + +## Who uses it — match the closest consumer, don't hand-roll + +| Consumer | Shape | Notable | +| --- | --- | --- | +| **kolu** (`client`+`server`) | one browser ⇄ one Node server, ONE ws | single-tier; two sibling surfaces; uses `surface`+`surface-app` only (**not** `-nix-host`) | +| **pulam-web** | browser ⇄ Node ⇄ ssh fleet mirror | one ws per host (`/rpc/ws?host=`); `connectSurface`; re-serves `terminalWorkspaceSurface` | +| **drishti** (`srid/drishti`) | browser ⇄ Node ⇄ ssh agent mirror | the canonical twin; 3 workspaces (common/agent/app) | +| **odu** (`juspay/odu`) | CI runner: stdio lanes → unix-socket fan-in → CLI/MCP | serve+consume+mirror over every transport at once; `surface-mcp` projection | +| **pulam-tui / kaval-tui** | one-shot CLI/TUI, no browser | transport-blind `{client,dispose}`; unix-socket local, ssh remote; **no `.use()` hooks** | + +## The spine (real import paths) + +- **Define** — `defineSurface({cells,collections,streams,events,procedures})` (`@kolu/surface/define`). Many surfaces over one transport: `composeSurfaceContracts(map)` + sibling clients, never merged. +- **Serve** — `implementSurface(surface, deps)` / `implementSurfaces(map, fwDeps, perKeyDeps)` (`@kolu/surface/server`; `inMemoryStore` / `inMemoryChannelByName` back the cells/channels). **Always flatten before serving:** `implement(surface.contract).router({ ...fragment.router })` — else oRPC double-prefixes `/surface/surface/…` and every call 404s. +- **Consume (SolidJS)** — `surfaceClient(surface, link)` / `surfaceClients(link, map)` (`@kolu/surface/solid`) → `client.cells.X.use({authority,initial,onError})`, `.collections.X.use({keys,onError})` then `.byKey(id)?.()` / `.keys()`, `.streams.X.use(inputFn,{onError})` with `.pending()`/`.error()`, `.events.X.use(inputFn,handler)`. +- **Consume (CLI/TUI)** — no reactive hooks; raw awaited `conn.client.surface.<verb>(…)` + async-iterator iteration; a live board uses `mirrorRemoteSurface(spec, client, {collections,streams}, {log})` (`@kolu/surface/mirror`) into plain callbacks. + +## Links (transport, swappable) + +`websocketLink(ws)` (`/links/websocket`) · `stdioLink` (`/links/stdio`) · `unixSocketLink({socketPath})` (`/links/unix-socket`) · `directLink(router)` (`/links/direct`, in-process identity, for tests). Serve side: `serveOverStdio` (`/peer-server`), `serveOverUnixSocket` (`/unix-socket`), oRPC `RPCHandler` (`@orpc/server/ws`, `.upgrade(ws)`) for browsers. CLIs keep ONE transport-blind `Connection = {client, dispose}` so every command is written once across local vs ssh. + +## Mirror a remote surface (drishti / pulam-web / odu) + +1. **Dial the host** — `getHostSession<contract>({host, binary, resolveDrvPath})` (`@kolu/surface-nix-host`): long-lived, `nix copy`s the agent closure, runs `<bin> --stdio` over ssh, reconnects. `buildHostRegistry` fans out N hosts; one-shot CLIs use `dialAgentOnce` instead. +2. **Mirror inward** — `pumpRemoteSurface({source, session, makeSink, …})` (`-nix-host`) folds the remote agent's frames into a local `implementSurface` re-serve via a `SurfaceSink` (`makeSink`, `@kolu/surface/mirror`). The parent implements the *same* surface; a remotely-unobservable cell (e.g. connection state) is parent-authoritative. +3. **Re-serve** — the local fragment served on `/rpc/ws`, accepted via `acceptSurfaceSocket` (`@kolu/surface-app/server`). Browsers connect with `connectSurface` (`@kolu/surface-app/solid`), which bundles socket + `websocketLink` + `surfaceClient` + a default-on liveness heartbeat. + +## Gotchas (hard-won, all real) + +- **Procedures call off the FULL link, not the scoped client** — `surfaceClients` per-key `.rpc` is typed `unknown`; reach the root link for raw procedures. +- **Raw streaming** — `unenrolledStreamCall(client.X, input, {signal, onRetry})` (`@kolu/surface/client`) carries the reconnect (`STREAM_RETRY`) context; a bare `client.X(…)` silently loses it. There is **no `stream` namespace** (`.claude/rules/streaming.md` is stale on that point). +- **Consume streams fine-grained** — value-bearing → `.streams.use()` (replace-each-frame); delta-accumulate → `mirrorRemoteSurface` / `createSubscription`+`reduce`. Never coarse-read-and-copy: same-shape frames coalesce and the view freezes. +- **Snapshot-then-deltas + fail-fast** — a cell always opens with a snapshot; `firstFrameOrThrow` (`@kolu/surface/first-frame`) treats an empty stream as a link failure, never a silent empty. +- **Liveness is on by construction** — framework-reserved `surface.system.live`; `connectSurface` / `HostSession` / `createServerLifecycle` default their watchdog to it (`probeSurfaceLive`). Don't nominate your own probe unless you mean to (pulam-tui's version-cell probe is the rare, deliberate exception). +- **Version skew** — gate on `isContractVersionCompatible` (major.minor), never a string `==`. +- **Nix-baked deps** — odu declares no `@kolu/*` in `package.json`; they're symlinked at Nix build (the bake-in-via-Nix convention). A bare `pnpm install` won't resolve them. + +## Reference + +Runnable end-to-end: `packages/surface/example/` (its `mini-ci` stdio example is odu's seed). Full API + rationale: each package's `README.md`. Match the closest consumer above; don't reinvent a primitive the table already shows how to use. diff --git a/.agents/skills/talk/SKILL.md b/.agents/skills/talk/SKILL.md index 8c9d14979..eede44adc 100644 --- a/.agents/skills/talk/SKILL.md +++ b/.agents/skills/talk/SKILL.md @@ -10,7 +10,7 @@ You are now in **talk mode**. Have a conversation with the user — discuss idea ## Rules -- **Do NOT edit or mutate the current repo.** No `Edit`, `Write`, `NotebookEdit` tool calls against workspace files, and no Bash commands that create, modify, or delete files in the checked-out repo. (Sole exception: `--html` mode below, which permits writing a single `.html` artifact to `$PWD`.) +- **Do NOT edit or mutate the current repo.** No `Edit`, `Write`, `NotebookEdit` tool calls against workspace files, and no Bash commands that create, modify, or delete files in the checked-out repo. (Sole exception: `--html` mode below, which permits writing a single `.html` artifact to the repo root or an existing `docs/plans/`.) - **Do NOT run destructive repo commands.** No `git commit`, `git push`, `git add`, `git rm`, or anything else that mutates the current repo. - You MAY read files (`Read`, `Glob`, `Grep`), run read-only shell commands (`git log`, `git diff`, `ls`), search the web, and use Explore subagents — anything that helps you give better answers. - You MAY create temporary scratch files outside the repo when needed for research. Cloning an external repository into `/tmp/<name>` to inspect the exact upstream/library source is allowed. Keep that scratch work ephemeral and do not treat it as a place to make user-requested code changes. @@ -114,14 +114,16 @@ Laconic mode trims the *output*, not the *investigation*. Do the same reading yo ## HTML artifact mode (`--html`) -If `ARGUMENTS` contains `--html` (strip the flag before treating the rest as the topic), respond by writing a self-contained `.html` file to `$PWD` instead of replying in chat. Print only the file path — the HTML *is* the response. +If `ARGUMENTS` contains `--html` (strip the flag before treating the rest as the topic), respond by writing a self-contained `.html` file instead of replying in chat. Print only the file path — the HTML *is* the response. + +- **Output directory**: write to `docs/plans/` if that directory already exists; otherwise write to the repo root (`$PWD`). Do not create `docs/plans/` — only use it when it's already there. The point is to pair with a runner that can render the artifact and let the user select text on it to queue comments back (e.g. [juspay/kolu#922](https://github.com/juspay/kolu/pull/922)). The user reads the rendered HTML, replies with their selected comments as text, you re-emit the updated HTML. The artifact stays the conversation's single source of truth. -- **Filename**: stable for the session — `talk-<short-slug>.html` derived from the topic (lowercase, dashes, no spaces), or `talk.html` if there's no obvious slug. Follow-up turns update the **same** file; do not spawn a new artifact per turn. +- **Filename**: stable for the session — `talk-<short-slug>.html` derived from the topic (lowercase, dashes, no spaces), or `talk.html` if there's no obvious slug, in the output directory above. Follow-up turns update the **same** file; do not spawn a new artifact per turn. - **File contents**: self-contained — embedded `<style>` block, no external assets, no JavaScript, no remote fonts. Plain semantic markup that renders legibly inside an iframe preview. Carry the same `file:line` citations you would put in a text response; the research/citation rules above are unchanged. - **UI prototypes for UI work**: if the topic involves UI changes, embed *rendered* HTML/CSS prototypes of the proposed components inside the artifact — not ASCII mockups, not prose descriptions of what the UI would look like. The runner renders the file, so the user sees the proposed UI alongside the rationale and can comment on the visual itself. Approximate the target visual style (colors, spacing, typography); the prototype is static (no JS), but layout and hierarchy should be representative enough to react to. -- **Repo-write exception**: writing that one `.html` file in `$PWD` is the only mutation `--html` permits. No `Edit` on pre-existing repo files, no `git` writes, no destructive ops — the rest of talk mode's read-only posture holds. +- **Repo-write exception**: writing that one `.html` file in the output directory above is the only mutation `--html` permits. No `Edit` on pre-existing repo files, no `git` writes, no destructive ops — the rest of talk mode's read-only posture holds. - **Follow-up loop**: when the user replies with comments (typically pasted from a select-and-queue surface as a Markdown list), re-emit the **full** revised HTML and print the file path again. Do not narrate the diff in chat; the updated artifact is the reply. - **Interaction with `hickey` + `lowy`**: when the artifact is a design sketch, run the reviewers as usual and fold their findings into the HTML body **before** printing the file path — same "post-review proposal, not original sketch + critique appended" rule as text-mode responses. - **Interaction with laconic mode**: laconic trims the HTML body the same way it would trim a text response — brief prose, no preamble, no needless bullets, no heading scaffolding unless the answer is genuinely structured. `--html` picks the medium; `--no-laconic` picks the verbosity. UI-prototype markup is the substance of the answer, not prose filler, so it's not what laconic trims. diff --git a/.claude/skills/be-review/SKILL.md b/.claude/skills/be-review/SKILL.md new file mode 100644 index 000000000..7115e0047 --- /dev/null +++ b/.claude/skills/be-review/SKILL.md @@ -0,0 +1,265 @@ +--- +name: be-review +description: Run /be's review gauntlet SERIALLY — /lens-debate (lowy ⇄ hickey), then /codex-debate, then /simplify, then code-police, each editing and committing on the live branch in turn. Use from /be §4, or when the user asks to "run the review gauntlet". Requires Claude Code's Skill tool. +argument-hint: "[--base <branch>] [--rationale <note>] [--context <note>] [--tracks lens,codex,simplify,police]" +--- + +# Review gauntlet (serial) + +Run four reviewers **one after another** on the live branch, each the **sole +editor while it runs**. Collisions are an *edit* problem: two reviewers writing +the same worktree at once see torn, half-edited state. Running serially makes +that impossible without any snapshot machinery — when a step starts, the previous +step has already committed, so every reviewer reads a clean, settled tree and +applies its own fixes directly: + +1. **`/lens-debate`** — lowy + hickey debate boundaries/simplicity to consensus, + then **apply** the agreed fixes (each its own commit). Pass the change + **`rationale`** so the lenses don't flag deliberate decisions. +2. **`/codex-debate`** — codex (`xhigh`) ⇄ claude author, debating to consensus. + Its author rounds edit and each round auto-commits `fix(…)` on the branch. +3. **`/simplify`** — the self-applying reuse / simplification / efficiency pass + over the changed code. Now that nothing runs concurrently, it runs as itself + (it could not against the old read-only snapshot). +4. **code-police** — its rule-checklist and fact-check passes, applying their + fixes. Run with `--no-elegance` so its elegance pass is skipped: that pass + re-invokes `/simplify`, which step 3 already ran over this same tree. + +Each step runs to completion before the next begins. Wall-clock is +`lens + codex + simplify + police` — slower than the old parallel form, but with +no snapshot, no change-request handoff, and no separate apply pass: every step is +its own editor and commits its own work. + +**PR comments come after the push, never before.** Each step commits locally but +be-review pushes only once, after all selected steps finish. A comment that names +a commit SHA must never be posted while that SHA is local-only — if a later step +failed or the run were interrupted, the PR would advertise commits that were +never pushed. So the debate skills run with their self-commenting **suppressed** +(`--no-comment`); be-review captures each comment body (the lens skill returns one +ready; the codex body it assembles from `commentHeader` + the section files — +step 2), pushes once at the end, and only then posts the lens comment, the codex +comment, and its own police summary. No PR comment can reference a local-only +commit. + +## Preflight + +- **Non-empty diff.** `git diff --stat <base>` (default: the repo default via + `git symbolic-ref --short refs/remotes/origin/HEAD`). If empty, stop. +- **Commit first.** Reviewers review *committed* code — commit/stash any + outstanding work before starting (in `/be` this is automatic: §2/§3 commit and + push before §4). +- **Resolve the scope once.** `git fetch origin`, then + `MB=$(git merge-base <base> HEAD)` and `START=$(git rev-parse HEAD)`. Pass `MB` + as the `base` to every step (their own merge-base resolution is idempotent on a + SHA) so each reviews the change against the identical fork point. Note that each + step sees the *commits the previous step added* as part of the diff — that is + intended: a later reviewer reviews the earlier reviewer's fixes too. Run every + `git` here with `git -C "$repoPath"` (below) so a cross-repo run resolves the + *target* repo's base, not the cwd's. +- **Pin `repoPath` — the repo under review may NOT be the cwd.** A `/be` run can + carry the work in a *companion repo* (e.g. the drishti PR a `@kolu/surface` + change requires per `/be` §5) while the session is rooted in a kolu worktree. + Set `repoPath` to that target repo's absolute path (default: the cwd worktree + root) and thread it into **every** step. Pass `args` as a real object — + `Workflow({ scriptPath, args: { repoPath, base: MB, … } })`. **Note the harness + JSON-ENCODES `args` before the workflow script sees it, so `args` arrives as a + *string* regardless of what you pass.** The debate scripts now parse a stringified + `args` defensively (`const a = typeof args === 'string' ? JSON.parse(args) : args`), + so `repoPath`/`base`/`rationale`/`context` thread through correctly and malformed + `args` throws *loudly* instead of degrading. This fixed a real cross-repo failure: an + earlier run's scripts did the bare `const a = args || {}`, so the stringified `args` + had no `.repoPath`, `repoPath` silently degraded to `.`, and a cross-repo lens-debate + re-reviewed the **cwd** repo and committed five fixes onto the wrong repo (same-repo + runs only "worked" by cwd coincidence). If a cross-repo step still returns `clean` + with `rounds: 0` against a non-empty *target* diff, suspect the `repoPath` didn't + take effect before trusting it. +- **codex login** (unless `--tracks` excludes it): `codex login status`. If not + logged in, tell the user to run `codex login` (suggest the `!` prefix) and + continue with the remaining steps. + +## Run the steps in order + +`--tracks lens,codex,simplify,police` selects which steps run (default all four), +in the listed order. Run each to completion, then move to the next. Preflight +already ran `git fetch origin` and resolved the base, so pass `MB` straight into +each step and **skip the per-skill step-1 fetch / base resolution** — don't redo +it once per step. + +**How to "wait for the Workflow" — let its own settle notification resume you.** +The debate skills run as a backgrounded `Workflow` ("launched in background; Task +ID: …"); a debate can legitimately take 20–30 min. When it settles it fires its +own task-notification that resumes this run automatically — that is the wait. So +after dispatching a step, go to rest and let that notification wake you; **do not +schedule redundant `ScheduleWakeup` polls** and there is nothing to babysit. (A +prior run scheduled 4-min wakeups *and* the user wired a 5-min `/loop` to nudge a +gauntlet that was simply mid-debate — both were unnecessary churn.) Only act when +the workflow's notification arrives or it has provably errored. + +1. **lens** — follow `/lens-debate` (Skill tool). `repoPath` = the live worktree, + `base` = `MB`, **apply mode** (the default — do *not* pass `--no-apply`), + **`--no-comment`** (so it doesn't advertise its local-only commits before + be-review pushes — defer the comment until after the push), and thread the + `rationale` through. It applies the agreed fixes as commits and **returns** its + rendered comment body for be-review to post after the push. Wait for its + `Workflow` to finish before starting the codex step. + + `/lens-debate` returns a `status` of `clean`, `consensus`, + `apply-incomplete`, `unresolved`, or `merge-base-error`: + - `clean` / `consensus` — the lenses agreed per-finding and applied the fixes. + - `apply-incomplete` — the lenses agreed, but the Apply phase didn't land every + fix cleanly (see `applyGaps`: a fix was missing from the apply output or + changed-but-uncommitted). **Reconcile before moving on:** for each gap, apply + or commit the outstanding fix yourself (staging only its files), then fold the + reconciliation into the deferred lens comment. Never report "lens consensus" + for an `apply-incomplete` run. + - `unresolved` — the debate hit its round backstop with findings still + contested. `/be` §4 requires you to **adjudicate every unresolved lens + finding yourself before moving on**: surface them in the report, decide drop + or apply for each, and apply the survivors before continuing. Fold your + adjudication into the deferred lens comment you post after the push (the lens + skill ran `--no-comment`, so there is no self-posted comment to follow up + on). Never report "lens consensus" for an `unresolved` run. + - `merge-base-error` — the scope couldn't be trusted; report it and move on. + +2. **codex** — follow `/codex-debate` (Skill tool). `repoPath` = the live + worktree, `base` = `MB`, **`--no-comment`** (so it doesn't advertise its + local-only round commits before be-review pushes), and thread both `context` + (the task / main-agent context, so the codex **author inherits what you know — + not just the diff** — every round) and `rationale` (so codex doesn't flag + deliberate decisions at the source) straight through. **When the diff makes an + API-facing change to the shared surface stack** (`packages/surface{,-app,-nix-host}` + per `.claude/rules/surface.md`), it trips the drishti companion-repo gate, which is + satisfiable **only against the *final* post-gauntlet kolu HEAD** — never mid-review, + by construction. Seed that into the `rationale` explicitly (e.g. *"the surface.md + drishti ship-gate is deferred to §ship; it is not a blocking code finding"*) so codex + defers it **from round 1**. `/codex-debate` also defers such a gate reactively once + the author flags it mid-round, but the up-front rationale is what converges the debate + *fast*: on this skill's originating `@kolu/surface` run, the debate without it spun 32 + rounds to a weekly-usage-limit kill (131 agents, 2.69M tokens); the very next surface + debate, with it, converged in 2 rounds (8 agents). Its step-2 `Workflow` runs + in the background; **wait for it to finish** before starting the simplify step. + It commits its rounds and returns a `commentHeader` plus the per-round section + files under `workDir` (it no longer returns a single pre-rendered comment string). + **Assemble the comment body now and hold it** to post after the final push — + capture it immediately so a later step can't disturb the scratch: + + ```bash + { + printf '%s\n' "$commentHeader" + for f in "$workDir"/section-*.md; do printf '\n'; cat "$f"; printf '\n'; done + } > "$workDir/comment.md" # hold this path for the post-after-push step + ``` + + This **freezes** the body now, so any reconciliation a later branch performs + (a `commit-incomplete` or `section-incomplete` fix-up below) is **not** in this + file yet — **append** that note to `$workDir/comment.md` after you reconcile, or + it won't reach the posted comment. + + (On `merge-base-error` the workflow aborted before any debate ran, so there is + **no** `commentHeader`/`workDir`/`section-*.md` to assemble — do **not** run the + block above. Per `/codex-debate`, report the scope failure from the return's + `note`, fix the base ref (e.g. `git fetch`), and re-run; there's nothing to post. + On persistent `reviewer-error` there is likewise **no body to post** — an + unresolved reviewer error is not a consensus to report; skip the codex comment in + that case.) + + **Retry codex on `reviewer-error` (up to 3 attempts).** `/codex-debate` ends + in `consensus`, `commit-incomplete` / `section-incomplete` (see below), + `reviewer-error`, or `merge-base-error` — `reviewer-error` meaning codex never + produced a structured verdict even after `codex-review.sh`'s built-in + per-`codex exec` retries. That is an *infrastructure hiccup, not a debate + outcome*: re-launch it immediately with the same args. Stop the moment an + attempt reaches `consensus`. Only if **all 3** come back `reviewer-error` do + you give up on codex — report the persistent reviewer-error honestly (no false + consensus comment) and move on to the simplify step. + + **On `commit-incomplete`,** the debate converged but a round's author left its + edits uncommitted (round numbers in `commitGaps`). The edits are still in the + tree, but the per-round commit didn't land — **commit the outstanding tree + yourself** (staging only the files that round changed, message + `fix: codex review — debate round N`) before the simplify step, then **append** + the reconciliation note to the already-frozen `$workDir/comment.md` (the body was + captured above *before* this fix-up, so editing the section files wouldn't reach + it). Don't report it as a clean consensus. + + **On `section-incomplete`,** the debate converged but a round's author **skipped + or under-filled its disposition section file** (missing, empty, or omitting a + marker for an open finding; round numbers in `sectionGaps`), so the per-round + trail — and thus `$workDir/comment.md` — has a gap for that round. The tree edits + and commits are intact; the missing piece is the record. **Append** a note to the + already-frozen `$workDir/comment.md` naming the round(s) whose disposition record + is missing, and report it as **converged-but-not-clean** in your gauntlet summary. + Don't report it as a clean consensus. + +3. **simplify** — invoke `/simplify` (Skill tool), scoped to the change vs `MB`. + It applies its fixes to the working tree. When it finishes, **commit** what it + changed (`refactor: simplify <area>`, staging only the files it touched). If it + changed nothing, note that and move on. + +4. **police** — invoke `/code-police` (Skill tool), passing **`--no-elegance` + whenever the simplify track (step 3) ran this gauntlet**. That flag skips + Pass 3 (elegance), which would otherwise re-invoke `/simplify` over the tree + step 3 already simplified — a full skill invocation to re-derive a + near-guaranteed no-op. Pass 1 (rules) and Pass 2 (fact-check) still run. + _Only omit the flag when `--tracks` excluded `simplify`_ — then no standalone + simplify ran, and the elegance pass is the run's one simplify, not redundant. + Its embedded pass prompts diff against + `origin/HEAD...HEAD` by default, which is *wrong* whenever `--base` isn't the + repo default: before invoking, **tell the police passes to scope to `MB`** — + pass the merge-base explicitly so every pass runs `git diff <MB>...HEAD`, not + the default ref. **Apply** the fixes it surfaces, committing each + `fix(police): <title>` with the finding in the message (stage only the files + changed). + +## Push, then comment + +First settle whether there is anything to push: `git log --oneline $START..HEAD` +(`$START` was captured in Preflight). Then: + +- **New commits exist** and **a PR exists for this branch** + (`gh pr view --json number -q .number`) → **`git push`**. **Only after the push + succeeds** do you post the deferred comments — the lens and codex bodies from + steps 1–2 are now safe to publish because the SHAs they name are on the remote. +- **No new commits** (every step was clean or applied nothing) but **a PR + exists** → there is nothing to push, and HEAD is already remote-visible, so + post the deferred comments **immediately**. The local-only-SHA invariant is + about never advertising an *unpushed* commit; with no new commit there is no + such risk. +- **No PR** → there is nothing to push to and nothing to comment on. Skip both; + the local commits (if any) and their findings live in chat and the local log + for the human. +- **A required push fails** → do **not** post the comments (the SHAs are still + local-only); report the push failure instead. + +**Never merge** — pushing updates the open PR; the human reviews the commits and +merges when satisfied. + +When you do post, post **one comment per track that produced a body** — skip any +track `--tracks` excluded, and skip a track that ran but yielded no postable +comment (lens on `merge-base-error`, codex on persistent `reviewer-error`): the +lens body and the codex body verbatim (`gh pr comment -F` — the codex body is the +`$workDir/comment.md` you assembled in step 2 from `commentHeader` + the section +files), and the police summary (the +`## [👮 Code-police](https://agency.srid.ca/)` comment described in Report). + +## Report + +Summarize in chat — reporting **only the selected tracks**, and naming any track +`--tracks` **skipped** so the absence is explicit, not silent: + +- **lens** — status (**consensus** + fixes applied, or **unresolved** + how many + findings still need human adjudication and how you adjudicated each, or + `merge-base-error`); its PR comment landed (posted after the push) — except on + `merge-base-error`, which has no comment body to post. +- **codex** — consensus / reviewer-error (note how many attempts if retried); on + consensus its PR comment landed (posted after the push, per "Push, then + comment") — on persistent reviewer-error there is no comment to post. +- **simplify** — whether it changed anything and what it committed. +- **police** — findings and how each was actioned; the + `## [👮 Code-police](https://agency.srid.ca/)` summary comment landed (posted + after the push, alongside the lens and codex comments). +- whether the fixes were pushed; +- `git log --oneline <base>..HEAD` + `git diff --stat <base>` so the combined + result is visible. + +ARGUMENTS: diff --git a/.claude/skills/be/SKILL.md b/.claude/skills/be/SKILL.md new file mode 100644 index 000000000..dd6af4040 --- /dev/null +++ b/.claude/skills/be/SKILL.md @@ -0,0 +1,169 @@ +--- +name: be +description: Modern, interactive alternative to `/do` — clarify intent up front, then take a task end-to-end with a serial AI review gauntlet (lens debate (lowy ⇄ hickey) → codex debate → simplify → code-police, each editing the branch in turn) → CI → evidence. ONLY invoke when the user explicitly types `/be` or `$be`; never auto-select from a natural-language request. +argument-hint: "<issue-url | prompt>" +--- + +# Be + +Take a task to a shipped, reviewed PR. Unlike `/do` (autonomous start to finish), `/be` **opens with a short interview** — and is then **fully autonomous**, exactly like `/do`, from §1 onward. The interview is the *only* place `/be` asks the user anything; after it, make sensible defaults and keep moving — no further `AskUserQuestion`, no stopping between steps. The single exception is the optional plan-review pause in §1, and only when "plan first" was chosen. Concise by design — defer mechanics to the skills it calls. + +**Autonomy doesn't inherit — propagate it to every subagent you delegate to.** When you hand work to a fresh subagent (a §2 package build, a §5 "finish the ship" CI+gate+cleanup pass), its prompt must say *execute now; do not wait for confirmation, do not ask me to "say go"* — a subagent starts without your interview's "no stopping between steps" contract, so a prompt that merely lays out a plan gets a plan **back** (zero tool uses) instead of done work, and you're the one who has to type "go." Bake the directive into the delegation, and if a subagent still returns a plan-and-waits with no tool uses, resume it with "execute now" rather than surfacing the stall to the user. + +**Requires Claude Code's `Skill` tool** (the debate reviewers it calls are `Workflow`-backed). + +## 0. Interview (the differentiator) + +Before any work, ask the user via **`AskUserQuestion`** (one call, batched): + +- **Plan first?** — write the plan as an **Atlas note** (`docs/atlas/src/content/atlas/<slug>.mdx`) for review *before* implementing, or implement straight. Default: straight, unless the task is large/ambiguous. *(If the prompt already points at an existing Atlas note or legacy `docs/plans/*.html`, skip this question — that file is the plan of record; reuse it.)* +- **Task kind** — bug fix · feature/new behavior · refactor/chore. This sets the test strategy (see §2). +- **Ultracode?** — include this question *only when no system-reminder says ultracode is on*. Remind the user that `/be` runs richer with ultracode (deeper review fan-out, adversarial verification of each finding) and ask whether to proceed on the standard pass or pause so they can enable it. Options: *Proceed (standard pass)* / *I'll enable ultracode first*. If they pick the latter, stop and let them turn it on, then re-run. + +Add a question only when something material is genuinely unclear — don't pad. Honor anything the user already pinned in the prompt instead of re-asking. **This single `AskUserQuestion` call is your one and only chance to ask** — surface every clarification you need now (including the ultracode check above), because everything after this is autonomous. + +## 1. Set up + +- `git fetch origin`; branch off `origin/<default>` (`git symbolic-ref --short refs/remotes/origin/HEAD`). Feature branches only — never commit to master. +- Read `.agency/do.md` for the project's **check / fmt / test / ci** commands and its **`## PR evidence`** section. Reuse them throughout. +- **If "plan first" (or working off an existing plan):** the plan of record is an **Atlas note** (`docs/atlas/src/content/atlas/<slug>.mdx`). **Load `/atlas` (Skill tool)** for the note mechanics — frontmatter, the component kit, `just atlas::build` + staging `dist/`, and the Code-tab + htmlpreview share links. Set `kind:` to match the §0 task (`bug`/`feature`; else `analysis`/`reference`) and `status: proposed`. The plan itself must: **(a)** stay **high-level** — user- and architecture-focused (what changes + the *shape*: seam, data flow, trade-offs and alternatives), with **no implementation dump** (no line-level code, file-by-file lists, or signatures; the *how* is §2's job); **(b)** carry a **UI prototype** (`<AtlasMockup>` or inline JSX) if the change has any on-screen surface, so the user judges look-and-feel before code; **(c)** **ground every load-bearing low-level fact against the installed code before asserting it** — staying high-level (a) does not license *guessing*. A pinned **dependency version** (read the lockfile, not the `^range`), a third-party library's **emitted markup / attribute / API shape**, a **test-environment strategy** (a unit env or a needed dep), a **framework runtime behavior** (e.g. *does a coarse SolidJS store reader coalesce same-shape deltas, or does Solid flush every write?* — a load-bearing reactivity/coalescing fact you **reproduce empirically against the installed source**, never deduce from first principles) — each is a fact the *how* in §2 will be built on, so verify the few the plan leans on the same way §2 gets ground truth (read the lockfile / the package's `vitest.config.ts` / the actual emitted DOM / a throwaway repro of the reactive path), don't recall it from training. A plan that asserts `marked-footnote@1.2.4 emits class="footnote-ref", test it under happy-dom` when the lockfile says `1.4.0`, the marker is a bare `data-footnote-ref`, and the package keeps a deliberate node-only env with no happy-dom is *wrong*, not merely detailed — it forces an implementation-time reconciliation and ships a false published note. **Self-check before presenting** — rework until all hold; don't make the user be the linter: high-level ✓, prototype-if-visual ✓, facts-grounded ✓, renders clean ✓. Then **push the branch** and **hand it over** for review via the Code tab *and* the htmlpreview link — do *not* use plan mode; wait for the user's reply, incorporate feedback (rebuild + push each round), and resume only on their go. This is the one sanctioned pause. **The plan ships in the PR.** *(A legacy `docs/plans/*.html` plan stays HTML — edit it in place.)* + +## 2. Implement + +**Honor the design philosophy first.** Before writing code, re-read `.claude/rules/conventions.md` → **Design philosophy** (fail-fast / no-fallbacks · electricity boundaries · reuse the existing source of truth) and state in the plan or PR body how this change honors each. A fallback path, a new override knob, a domain-agnostic helper folded into an app module, or a hand-rolled mechanism that duplicates an existing one (`.gitignore`, an extension/MIME table, a library) is a defect to fix now — not a follow-up the review gauntlet should have to catch. + +- **Bug:** reproduce *before* you theorize or fix — start from facts, not a story about the bug. **Where it runs: pu box, not locally** — building, running the repro (`just test-quick`/`just dev-auto`/a scripted repro), and any "let me SEE it" check are **heavy work**, and reproduction is the §5 venue gate fired early. Whenever `systemctl --user is-active kolu` is `active` (the normal case) that work belongs on an ephemeral pu box, never on the user's machine: a pile-up of local builds + e2e runs OOM-killed production `kolu.service` once, and a broad `pkill -f <substring>` to clean up OOM'd processes killed it again — its nix-store process matched the substring. **Load `/dev-server` §0 before launching/building/repro-ing anything**, and never `pkill -f` by any command substring — resolve PIDs by remembered port, or just let the pu box go. **(1)** Get ground truth from the running system; observe the real symptom, don't trust a description of it. **(2)** Pin the one hard, observable fact the bug produces — a wrong value, an error, a state that can't legally happen (e.g. "the client SHA stays `7deb397` across reloads"). **(3)** Build a reproduction that exhibits *that exact fact* and is **red on the current code** — a **failing e2e test** via the `/test` harness when it can express the bug, otherwise a scripted repro. A repro that *passes / converges / "works"* is **not** a reproduction: if it doesn't show the symptom the **repro** is wrong — fix the repro, never conclude "no bug" from it. **(4)** Only now fix, until that same repro flips green. No fix without a reproduction that was first red for the real reason. The fix must make the feature *work*, not disappear: disabling it, defaulting it off, or routing the affected platform onto a degraded path is the no-fallbacks violation from §2's design-philosophy clause wearing a bug-fix hat — a *mitigation*, not a fix, and a defect to reject now, never to ship or post as "verified." If the only remedy you can find removes or degrades the behavior, you haven't understood the bug yet — keep digging (fork the upstream dependency if that's what a real fix needs) before you settle. +- **Feature / new behavior:** write the covering test (e2e/integration/unit as fits) before or alongside the change. +- **Refactor/chore:** no test-first requirement; rely on existing coverage. + +**Sync the docs.** Read `.agency/do.md` for its **`## Documentation`** section — a *principle* (discover the stale docs, don't recall a checklist), **not** a fixed file list. Updating the README + Atlas and stopping there is the exact pattern-match-a-couple-and-skip-the-rest trap it warns against. So **grep every doc surface for the term you touched** — the command, flag, type, or word — across `README.md`, every `packages/*/README.md`, **`website/`** (the kolu.dev marketing pages, e.g. `src/pages/*.astro`, which hand-list commands and carry "next up is X" prose that goes false), and `docs/atlas/`. For **each** hit, either edit it or record why it's still accurate — "I updated the README" is not a doc-sync until the changed package's README and every user-facing marketing surface were each *grepped and resolved*. The docs commit rides the same review gauntlet as the code. Skip only when the change is genuinely doc-neutral. + +**Add a changelog entry.** For any **user-facing** change, append one line to `website/src/content/changelog/unreleased.mdx` under the right `###` heading — `Added` / `Fixed` / `Changed` / `Heads-up` (the editorial home for disruptive changes: a removed feature, a changed default, a migration). Create the heading if a freshly-reset section doesn't have it yet. Write it as prose a *user* reads, not a commit subject — no PR link yet (the PR doesn't exist until §3; you backfill the link there). Skip only when the change has no user-visible effect (pure refactor/chore/internal). The file is `merge=union`, so a plain append (or a new heading) never conflicts. + +Run **check** and **fmt**, then commit (conventional message) and push the feature branch. **`just check` (tsc + biome) green is not proof the shipped artifact *builds*** — when the change adds or edits a bundler/server entrypoint (a `vite.config.ts`, a `nix run` server wrapper, any module the real build loads) that **imports a workspace package**, tsc resolves extensionless imports that native ESM / the bundler will *reject*, so a clean typecheck can sit on top of a `vite build` / `nix run .#<pkg>` that doesn't build at all. For that kind of change the §5 venue gate fires early: actually run the real build on a pu box (`nix run .#<pkg>` / `vite build`), don't infer it from the typecheck. Leaving it for CI/evidence to surface is how a non-building entrypoint reaches the gauntlet. **The same is true of a dependency change**: the moment the change touches `package.json` / `pnpm-lock.yaml` (a `pnpm add`/`remove`/`update`), the recorded `fetchPnpmDeps` FOD hash in `nix/modules/typescript.nix` goes stale and **every** linux nix-build CI lane (`ci::pnpm-hash-fresh`, `ci::nix`, `ci::smoke`, …) reds at once — a guaranteed wasted CI cycle if it's left for §5 to surface. **Load `/nix-typescript` (Skill tool) and refresh the hash the instant the lockfile changes**, in the **background** (`nix build` takes minutes — kick it off and keep coding, per that skill), so the corrected hash rides this same commit. `just check` never catches this; only a real `nix build` does. + +## 3. Open the PR + +**Before any review** — so every reviewer's findings land as comments on a real PR. Load **`/forge-pr`** (Skill tool) and `gh pr create --draft` with a genuine title/body covering the scope so far. The PR exists for the rest of the run; later steps push commits and post comments to it. + +**Backfill the changelog PR link.** If §2 added a changelog entry, fill in its PR now that the number exists — set the **`pr={<n>}`** prop on the entry's `<Change title="…" pr={<n>}>…</Change>` (auto-injected into changelog MDX, so no import; it renders the GitHub-style PR chip). Then commit and push so the link rides this PR. Skip if §2 added no entry. + +**If there's a plan of record, finalize it now.** Once the PR URL exists, **finalize the Atlas note via `/atlas`**: set `status: implemented`, link the PR with `<PrLink pr={<n>} />`, rebuild + stage `dist/`, commit (`docs(atlas): link PR #<n>`) and push so it's part of this PR. *(A legacy `docs/plans/*.html` plan stays HTML — edit its status/PR link in place.)* + +## 4. Review gauntlet + +Run **`/be-review`** (Skill tool) — it runs four reviewers **serially**, each the +sole editor while it runs: `/lens-debate` applying the agreed fixes, then +`/codex-debate` (its per-round commits are the debate), then `/simplify`, then +code-police. Each step reads a clean tree (the previous step has committed) and +applies its own fixes directly — no snapshot, no apply pass. be-review pushes once +at the end and *then* posts the PR comments (lens, codex, and a code-police +summary), so no comment advertises a local-only commit. + +**This phase is non-negotiable, and it costs you almost nothing:** the reviewers run +OFF your context, as backgrounded `Workflow`s that notify you when they settle. So +"this would balloon my context / budget" is **never** grounds to skip a reviewer, run +fewer than all four, or substitute a hand-rolled review for the real gauntlet — that +excuse doesn't survive ten seconds of scrutiny, and dropping a step you were told to +run is the single worst gauntlet failure. `/be`'s autonomy means *don't ask permission +for each step*, NOT *decide which steps matter*. If a mandatory step is genuinely +infeasible, **STOP and ask the user** at that moment — never silently substitute and +disclose it later in the wrap-up. + +- Pass `base`, the change **`rationale`** (so the lenses don't flag deliberate + decisions), and **`context`** — the task intent and key decisions you hold from + this run, so the codex author **inherits what you know instead of re-deriving it + from the diff**. Preflight is a non-empty diff and (since codex runs) `codex login + status`. +- Lens-debate commits its agreed fixes; codex's rounds commit `fix(…)`; simplify + and code-police commit `refactor:` / `fix(police):`. Confirm the post-push PR + comments landed: lens, codex, and — when the police track ran — the code-police + summary. +- On an **unresolved** lens finding, adjudicate it yourself before moving on. + +**Performance pass.** If the diff touches a perf-sensitive surface (SolidJS +reactivity, the surface wire, the terminal/canvas render loop, timers/listeners, +the client bundle, or kaval), review it against the performance map — +`docs/atlas/src/content/atlas/performance.mdx` +([published](https://kolu.dev/atlas/performance.html)): don't regress a *banked* +win, and don't add a catalogued anti-pattern (an unstable memo reference or +coarse reactive dep, a visibility-blind timer, a full-set wire broadcast, an +eager heavy import). When the change **banks** an opportunity or **surfaces** a +new one, update that note via `/atlas` so the map stays current — measured, not +guessed (a faithfully-reproduced negative counts too). + +## 5. Ship — CI and evidence in parallel + +**Heavy work runs on a pu box, never locally — production kolu lives on this +machine.** Builds, the dev server, and evidence capture all go on an ephemeral pu +box whenever `systemctl --user is-active kolu` is `active` (the normal case). A +prior run piled local `just dev-auto` + nix builds beside a live production kolu +and the **OOM-killer `SIGKILL`ed production**; random ports dodged its *ports* but +not its *RAM*. Load **`/dev-server`** §0 for the local-vs-pu venue gate before +launching the app for *any* reason — including an interactive "let me SEE it" +check during §2. `/ci` and `/evidence` already run on pu; keep it that way. + +`/ci` and `/evidence` are independent — one exercises the build/test pipeline, the +other captures on-screen behavior — so **run them concurrently**; don't wait for +green before capturing. + +1. **Kick off `/ci` first, backgrounded** — start the pipeline so it churns while + you capture evidence. **Drive it through the odu MCP face, not a shelled-out + `nix run .#odu`:** when an odu MCP server is wired (the `mcp__odu__*` tools — + check before shelling out), every run *and every status/log check* goes through + it — `run` → `wait_for_settle` (fail-fast) → read the red node's log via + `ReadMcpResourceTool` on `surface://collections/logs/{id}` → `node_rerun`, per + the `/ci` skill. Reaching for `nix run .#odu -- run/status` while that server is + present is the fallback path, not the default. React to `failed`/`errored` nodes + the moment they land: fix→fmt→commit→retry on real failures, confirm green on + the final `HEAD`. + - **macOS (`aarch64-darwin`) CI host — pick by availability, in this order: + `nix-infra@rasam.tail12b27.ts.net`, then `sincereintent`.** Both are Apple-Silicon darwin builders; + `nix-infra@rasam.tail12b27.ts.net` is the primary and `sincereintent` the fallback. Before pinning the + darwin lane, probe them **in that order** — `tailscale status` (skip a host + shown `offline` / `last seen Nh ago`) plus a quick `ssh -o ConnectTimeout=8 + <user>@<host> true` — and pin the **first that answers** in `mcp__odu__run + hosts=["aarch64-darwin=<user>@<host>", …]`, noting in the report which host + served the lane. An unreachable host is an infra fault, never a lane to park + or call green: if `nix-infra@rasam.tail12b27.ts.net` is down, fall through to `sincereintent` and run the + lane yourself; only if **neither** answers is the darwin lane genuinely + blocked (report it as blocked — never silently drop the platform or report + green on a lane that never ran; an unreachable host is the no-fallbacks rule's + "a caught error must surface"). This live availability order is what to apply + even where `.agency/do.md`'s steady-state note still reads "rasam, not + sincereintent / sincereintent retired": that line is the default pin, this + ordering supersedes it the moment the primary is dark. + - **The same `nix-infra@rasam.tail12b27.ts.net → sincereintent` order governs *every* darwin lane this + run starts — including a downstream/companion repo's CI** (e.g. the drishti + PR a `@kolu/surface` change requires per `surface.md`). A consuming repo's + own `hosts.json` may name a *different*, possibly-dark darwin host (drishti's + `zest`); when it's offline you fall through to the **same** working + fallback. But that repo's CI is the shelled-out `nix run … odu -- run` + path, not `mcp__odu__run`, so pin the override with **`--host + aarch64-darwin=srid@sincereintent`** (per the `/ci` skill) — **never** by + exporting inline JSON into `$ODU_HOSTS`, which odu reads as a *file path*, + not a value: an inline `$ODU_HOSTS='{…}'` is **silently ignored**, the lane + falls back to the repo's on-disk `zest`, and you burn a full CI run on the + dead host. If you must set `$ODU_HOSTS`, write a real hosts *file* and point + at it; otherwise reach for `--host`. +2. **Concurrently, run `/evidence`** while CI runs — follow the **`## PR + evidence`** section of `.agency/do.md` for the capture procedure, then post the + result under `## Evidence`. For bug fixes, demonstrate the now-fixed behavior + even when there's no visual diff. Skip only if that section says to (or is + absent). +3. **Join before Done** — confirm CI is green on the final `HEAD` **and** evidence + is posted. If a CI fix-commit changed visible behavior *after* capture, + re-capture so the evidence matches what actually merges. **Tearing down any + daemon you spawned for capture (a local kaval / pulam dialer, an ssh tunnel) is + governed by `/dev-server` §5** — kill the PID you captured at spawn (`$!`), + **never** `pgrep -f`/`pkill -f` a socket-path/port substring: it matches the + production kaval/kolu daemon, not your dialer. Cheaper still: leave the ephemeral + test daemon for the user / OS rather than guess a PID. + +## Done + +Report the PR URL, the gauntlet outcome (lens-debate consensus + fixes applied, codex consensus or reviewer-error, police findings actioned), and CI status. Never merge — the human reviews the commits and merges when satisfied. + +**Then close the loop — run `/self-improve` (Skill tool), passing this run's `$CLAUDE_CODE_SESSION_ID`** so it can mine this session for recurring friction and turn it into a sharper skill-set. It runs **forked** (`context: fork`) so the whole analysis stays off your context — hence the explicit session id. It produces nothing unless a lesson durably recurs, ships any fix on its own draft PR (never this branch, never merged), and restores this branch — a clean, no-PR run is the common outcome. + +ARGUMENTS: $ARGUMENTS diff --git a/.claude/skills/ci/SKILL.md b/.claude/skills/ci/SKILL.md new file mode 100644 index 000000000..ec4196798 --- /dev/null +++ b/.claude/skills/ci/SKILL.md @@ -0,0 +1,195 @@ +--- +name: ci +description: Reference for the `odu` runner — how to invoke a full pipeline, a single recipe, or a platform-pinned node, and how to attach to a live run, from a project whose CI odu runs. Trigger when the user asks to "run CI", "run the pipeline", "re-run a check", to run named lanes or recipes (e.g. "run fmt and nix", "just the e2e lane", bare selectors like `fmt`/`nix`/`e2e`), or names a recipe by `<recipe>@<platform>`. This skill — not a repo's local `just ci` / `just <recipe>` — is how an odu-run request is served. +--- + +# odu + +[`odu`](https://github.com/juspay/odu) (Tamil ஓடு — "run") runs the `just` +recipe DAG tagged `[metadata("ci")]` across platforms and posts GitHub +commit statuses per `<recipe>@<platform>` context. Unlike batch runners, +the run is **live state you attach to**: the coordinator serves a typed +surface on `.ci/odu.sock`, so `status`/`logs`/`attach` are in-band — no +process-compose, no separately-versioned socket client. + +> **A request to run CI is a request to run `odu` — never `just ci`.** Many +> consuming repos expose a `just ci` (or `just <recipe>`) target that runs a +> pipeline locally. Do **not** shell out to it: it is a parallel, non-attachable +> path that bypasses everything odu gives you — the live surface, per-node GitHub +> statuses, structured results, fail-fast, `cancel`/`supersede`, and the log +> resources below. "run CI", "run fmt and nix", "re-run the e2e lane" all mean +> *drive an odu run*, by the MCP face first and the `odu` CLI otherwise. +> +> **Prefer the MCP face for runs.** When the `odu-mcp` skill is present (the +> `mcp__odu__*` tools — check for an odu MCP server before shelling out), drive +> runs through it — `run` (pass `selectors` for named lanes/recipes) → +> `wait_for_settle` (fail-fast) → read the red node's log → `node_rerun`, with +> `cancel` / `run({supersede})` to call off or replace a run. It spawns the same +> coordinator but gives you structured results and the fail-fast loop instead of +> scraping terminal output. The `nix run … -- run` CLI below is the reference and +> the fallback when no MCP server is wired. +> +> **Logs are a resource, not a tool.** Don't look for a log-tail tool — there +> isn't one. A node's output is the MCP **resource** `surface://collections/logs/{id}` +> (`{id}` is the node, e.g. `ci::unit@aarch64-darwin`), read with +> `ReadMcpResourceTool`: the live buffered tail while the run is up, else the +> durable per-SHA log on disk. So when `wait_for_settle` returns a red node, the +> "read the log" step is `ReadMcpResourceTool` on that node's +> `surface://collections/logs/{id}` — subscribe for push updates, or just re-read +> to poll. (`surface://streams/nodes` is the pipeline snapshot resource alongside +> it.) + +## Invoking + +```sh +nix run github:juspay/odu -- <subcommand> [args] +``` + +Pin a ref for reproducibility, or — if the consuming repo npins-pins odu +and re-exports it (kolu does) — prefer its own flake output so the version +is repo-controlled: + +```sh +nix run .#odu -- <subcommand> [args] +``` + +## Modes + +**Strict by default** — `odu run` refuses a dirty tree, pins `HEAD` via +`git worktree`, posts commit statuses, and splits per-recipe logs into +`.ci/<sha>/<plat>/<recipe>.log`. Three flags relax that policy: + +| Flags | Tree | HEAD pin | Status posts | Use for | +| --- | --- | --- | --- | --- | +| _(none — default)_ | clean (refuses dirty) | `git worktree` at HEAD | posted | "real" CI runs | +| `--no-post` | clean | `git worktree` at HEAD | _none_ | non-GitHub strict consumers; debugging strict without writing the PR's check list | +| `--no-snapshot` (implies `--no-post`) | live working tree | none | _none_ | strict-mode dev iteration without clean-tree refuse | +| `--no-strict` (meta — same as `--no-snapshot --no-post`) | live working tree | none | _none_ | dev iteration; the one-flag opt-out for "just run the pipeline" | + +Every mode ends with the same `── ci run summary @ <sha7> ──` verdict block +(the sha reads `<sha7>+dirty` for a live-tree run on uncommitted changes) +and exits non-zero if any node failed or errored. + +## Common invocations + +```sh +# Full pipeline (the [metadata("ci")] root, every configured platform). +nix run github:juspay/odu -- run + +# Dev iteration on a dirty tree: no clean-tree refuse, no HEAD pin, no posts. +nix run github:juspay/odu -- run --no-strict + +# Re-run a single failed recipe on one lane — overwrites the same GitHub +# commit-status context the full run wrote (closes the red check). +nix run github:juspay/odu -- run e2e@x86_64-linux + +# One recipe across every pipeline platform; selectors compose. +nix run github:juspay/odu -- run e2e lint + +# Restrict the WHOLE fanout to one platform (repeatable). +nix run github:juspay/odu -- run --platform x86_64-linux + +# Skip the dependency closure; run ONLY the named nodes (_ci-setup still rides). +nix run github:juspay/odu -- run --no-deps e2e@aarch64-darwin + +# A different DAG root instead of the [metadata("ci")] recipe. +nix run github:juspay/odu -- run --root ci::e2e + +# One-shot redirect of a platform's host (how a pool-lease wrapper pins a box). +nix run github:juspay/odu -- run --host x86_64-linux=my-build-box + +# One NDJSON line per node transition, for agents/tools driving CI: +# {"node":"ci::e2e@x86_64-linux","recipe":"ci::e2e","platform":"x86_64-linux", +# "status":"running|success|failed|skipped|errored","exit_code":1, +# "log":".ci/<sha7>/x86_64-linux/ci::e2e.log"} +nix run github:juspay/odu -- run --progress json +``` + +Without `--progress json`, output adapts to where stdout points: a live +colour lane-matrix with a log-tail footer on a TTY; quiet transition lines +plus a once-a-minute "… still running" heartbeat when piped. + +## Inspection subcommands (no side effects) + +```sh +nix run github:juspay/odu -- dump # resolved pipeline as JSON +nix run github:juspay/odu -- graph # dependency graph (Mermaid) +nix run github:juspay/odu -- protect --dry-run # the (recipe × platform) contexts +nix run github:juspay/odu -- protect # PATCH branch protection to them +``` + +## Live introspection (attach to a run in progress) + +While `odu run` is live in a checkout, these attach to its surface over +`.ci/odu.sock`: + +```sh +nix run github:juspay/odu -- status # snapshot; -o json for tooling +nix run github:juspay/odu -- attach # live TUI dashboard on a tty + # (digits attach · n/p cycle · + # r rerun · q quit); -o json + # = transition stream +nix run github:juspay/odu -- logs -f e2e@x86_64-linux +nix run github:juspay/odu -- cancel # stop the live run, cleanly +``` + +No run in progress ⇒ exit non-zero with `no run in progress in this +checkout (no live socket at .ci/odu.sock)`. One run per checkout — a +second `odu run` refuses while the socket is live. + +**Cancel / supersede / linger.** `odu cancel` drives the live run's teardown +from a second process (finalize posted statuses, close lanes, drop the socket) +and waits until it's gone — no need to wait out a doomed run or `pkill` the +coordinator. `odu run --supersede` cancels whatever's live here first, then +starts ("stop this, run the fixed commit"). By default a run exits the instant +it drains; `odu run --linger` keeps it serving past settle so a node can be +rerun later (retry a flake), self-reaping after an idle period or on `cancel`. + +## Hosts config + +`$ODU_HOSTS` (a file path) → `~/.config/odu/hosts.json` → fallback +`~/.config/justci/hosts.json` (zero-config migration from justci): + +```json +{ + "x86_64-linux": "my-linux-builder", + "aarch64-darwin": "me@mac-mini.local" +} +``` + +Keys are Nix system tuples; values are anything ssh dials, or `localhost` +(runs directly against the snapshot, no closure copy). Missing platforms +silently drop from the fanout. `--host PLAT=ADDR` overrides per run. + +A lane host needs only **ssh + Nix + outbound https**: the runner ships as +a Nix closure (`nix copy` → realise on the host), and the source arrives by +`git fetch` of the **pushed** SHA — remote lanes cannot test unpushed +commits (no git-bundle transport; push first). The lane host's own nix is +used on the runner's PATH (never a pinned client — version skew against the +host daemon corrupts CA-derivation handling). + +## Semantics worth knowing + +- **Lanes are one-shot**: a lane whose ssh link dies mid-run fails as + `errored` (GitHub state `error`, `Errored (<dur>)` description); live + state does not survive a runner restart — the per-SHA log files do. +- **Skipped nodes post no status**: an absent required context is what + blocks the merge. +- The coordinator resolves the **generic lane runner from odu's own flake**, + not the repo under test: + `nix eval $ODU_RUNNER_FLAKE#packages.<platform>.odu-runner.drvPath`, where + `ODU_RUNNER_FLAKE` is baked onto the `odu` wrapper from `self.outPath` at + build time. A consuming repo no longer re-exports `odu-runner`. There is no + override or fallback — the runner is the exact build that shipped the + coordinator (they share an RPC contract); a binary built without the baked + flake refuses to run. + +## When NOT to use this skill + +- Questions about odu's internals or design history — read the + [README](https://github.com/juspay/odu/blob/master/README.md) and the + kolu Atlas note + [*A CI runner you attach to*](https://github.com/juspay/kolu/blob/master/docs/atlas/dist/mini-ci-vs-justci.html). +- Project-specific CI operations (warm pools, host leases, banned flags) + — that's the consuming repo's operational docs, layered on top of this + reference. diff --git a/.claude/skills/code-police/SKILL.md b/.claude/skills/code-police/SKILL.md index 4883b148c..7455f51ae 100644 --- a/.claude/skills/code-police/SKILL.md +++ b/.claude/skills/code-police/SKILL.md @@ -3,12 +3,17 @@ name: code-police description: Review code for quality, simplicity, and common mistakes before declaring work complete. context: fork model: sonnet +argument-hint: "[--no-elegance]" --- # Code Police Review the current changes (scoped to the current branch/PR) against the rules below **plus any additional rules from the project**. The three passes — rule checklist, fact-check, elegance — run as parallel sub-agents on fresh contexts; the implementer's main context just wrote the diff and is biased to rationalize it, so reviewing inline laundered violations through. Sub-agents start cold, which is the point. After they return, the orchestrator stitches their findings into a single summary. +## Arguments + +`--no-elegance` — skip Pass 3 (elegance) entirely. Pass 1 (rules) and Pass 2 (fact-check) still run. Use this when the elegance pass would be redundant because `/simplify` already ran over this same tree — e.g. a caller that invokes `/simplify` standalone and then `/code-police`. Without it, Pass 3 re-invokes `/simplify` on an already-simplified tree, paying a full skill invocation (agent spawn, diff re-read, model tokens) to re-derive a near-guaranteed no-op. When the flag is set, report Pass 3 as `Elegance | – | Skipped (--no-elegance)` in the summary. + ## Project rules Before spawning the pass sub-agents, read `.agency/code-police.md` if it exists. Treat any rules declared there — whether inline or as a pointer to another file (`See ./code-police-rules.md`) — as additions to the built-in rules below. They appear as separate rows in the Pass 1 checklist with the project's chosen rule IDs. @@ -82,7 +87,7 @@ Spawn Pass 1 and Pass 2 as **two parallel sub-agents** via the harness's agent t Each sub-agent inherits no context from the implementer's main thread; the prompts below are self-contained and reference the rules-of-record by file path so a single source of truth stays in this skill. -Pass 3 runs **after** Pass 1 and Pass 2 return. It applies fixes (via `/simplify`) and would race against Pass 1/2's grep-and-read work if run in parallel; sequential is the safer ordering. See "Pass 3: Elegance" below. +Pass 3 runs **after** Pass 1 and Pass 2 return. It applies fixes (via `/simplify`) and would race against Pass 1/2's grep-and-read work if run in parallel; sequential is the safer ordering. See "Pass 3: Elegance" below. If `--no-elegance` was passed, skip Pass 3 — only Pass 1 and Pass 2 run. Once all three pass outputs are in hand, stitch them into the summary table in the **Output** section. @@ -138,6 +143,8 @@ Sub-agent prompt: ### Pass 3: Elegance +**Skip if `--no-elegance` was passed.** Do not run this pass and do not invoke `/simplify`; report `Elegance | – | Skipped (--no-elegance)` in the summary. The caller asserted `/simplify` already ran over this tree, so a second run is redundant. Pass 1 and Pass 2 are unaffected. + **Skip on tiny diffs.** Run `git diff origin/HEAD...HEAD --shortstat` (or the appropriate base-branch ref). If the diff is **under 10 lines**, skip this pass and report `Elegance | 0 | Skipped (tiny diff)` in the summary. The elegance pass's three-lens fan-out has overhead that's disproportionate to a few-line change; Pass 1 and Pass 2 still run. If the diff exceeds the threshold, proceed below. Review the changes for elegance and simplicity. diff --git a/.claude/skills/codex-debate/SKILL.md b/.claude/skills/codex-debate/SKILL.md new file mode 100644 index 000000000..b54081444 --- /dev/null +++ b/.claude/skills/codex-debate/SKILL.md @@ -0,0 +1,572 @@ +--- +name: codex-debate +description: 'Run an automated codex⇄Claude debate to consensus — no round cap, no deadlock exit. Two explicit subcommands. `review` (also the bare/back-compat default) — codex (reviewer) critiques the current diff and a Claude subagent (author) fixes/disputes, looping until they agree. `answer` — Claude and codex each answer a freeform prompt in parallel, then cross-check until they agree, and a unified answer is returned. Use when the user types `/codex-debate`, asks to "have codex review this", "run the codex debate", "review this PR with codex", "argue this with codex until you agree", or passes a question to "have Claude and codex debate/answer until they agree".' +argument-hint: "review [<pr-number>] [--base <branch>] [--no-commit] [--no-comment] [--rationale <note>] [--context <note>] | answer \"<prompt>\"" +--- + +# Codex ⇄ Claude debate + +This skill runs an automated debate between **codex** and **Claude** that loops to +consensus with no round cap and no deadlock exit. It has **two modes**, selected by +an **explicit leading subcommand** — never by guessing from the argument's shape: + +- **`review`** — codex reviews the current diff, a Claude author fixes/disputes, + round after round until they agree, and the trail is **committed + posted to the + PR** (a mutating, outward-facing mode). This is everything from + [Review mode](#review-mode) down. +- **`answer`** — Claude and codex **each answer a freeform prompt in parallel**, + then **cross-check each other** until both agree, and a **unified answer** is + returned to you (read-only; plus a saved transcript). See + [Answer mode](#answer-mode). + +The two modes have **different side-effect contracts** (review mutates + writes to +the PR; answer is read-only), so the mode is chosen **explicitly**, not inferred +from whether the argument looks like a PR number or like prose. Inferring a +mutating action from prose shape is exactly the coupling this design avoids. + +## Mode detection (do this first) + +Look at the **first whitespace-delimited token** of `$ARGUMENTS`: + +- **`answer`** → **answer mode**. The prompt is everything after the `answer` + token. Jump to [Answer mode](#answer-mode); the review-mode steps do not apply. +- **`review`** → **review mode**. The remaining args are the review grammar + (`[<pr-number>] [--base …] [--no-commit] [--no-comment] [--rationale <note>] + [--context <note>]`). Continue with [Review mode](#review-mode). +- **No args, OR the first token is a number (a PR number) or a `--flag`** → + **review mode** (the backward-compatible bare alias for the original + `/codex-debate [<pr>] [flags]`, so existing callers like `/be-review` keep + working). Continue with [Review mode](#review-mode). +- **Anything else** (freeform prose with no recognized subcommand) → **ambiguous**. + Do **not** guess — ask the user to pick an explicit mode and stop, e.g.: "Did you + mean `/codex-debate answer \"<your prompt>\"` (read-only) or `/codex-debate review + [<pr>] [flags]` (mutating)?" Only the safe, backward-compatible review grammar + auto-routes; prose never silently triggers a mode. + +Both modes require Claude Code's **`Workflow` tool** (the engine). Under +codex/opencode runtimes the skill is inert. + +<a id="review-mode"></a> +# Review mode — Codex ⇄ Claude review debate + +Automate the back-and-forth you'd otherwise courier by hand: **codex** (the +reviewer) critiques the current change, a **Claude subagent** (the author) +fixes what it agrees with and disputes what it doesn't, codex re-reviews, and so +on — round after round, **until they reach consensus**. codex reviews from a +**warm session**: round 1 cold-starts the reviewer, and every later round +*resumes that same codex session* (`codex exec resume`), so codex carries its own +prior review and reasoning forward instead of reconstructing it from the diff + +rebuttal each round — when Claude disputes a finding, codex argues from its +original rationale. There is no round cap and +no "deadlock" surrender: a debate that quits without agreement defeats the +purpose, so the two sides keep arguing until one concedes. You stay out of the +middle: each round lands as its own commit whose +message carries the debate context (codex's findings + Claude's dispositions) so +the PR history reads as the debate, and the summary is **posted to the PR** as a +comment at the end. + +## Why this shape + +The two sides are asymmetric, and that asymmetry is the whole design: + +- **codex** is CLI-invokable headlessly (`codex exec`, authed via ChatGPT), so it + runs from a shell command. +- **Claude on a Max plan is *not* headless** — `claude -p` doesn't work with Max + auth. But the **Workflow tool's `agent()` spawns Claude subagents through the + harness**, not `claude -p`, so it works. That subagent is the author side. + +So the debate runs as a Workflow: `agent()` is Claude, a Bash-invoked +`codex exec` is the reviewer, and the script couriers structured verdicts +between them and decides when they agree. Both sides are forced to emit +schema-constrained JSON, so consensus is detected in code, not by vibes. + +**This skill requires Claude Code's `Workflow` tool** (it is the engine). Under +codex/opencode runtimes the skill is inert. + +## Arguments + +A leading `review` subcommand token, if present, is consumed by mode detection; +what remains is `[<pr-number>] [--base <branch>] [--no-commit] [--no-comment] +[--rationale <note>] [--context <note>]` (the bare alias passes the whole argument +string through unchanged). Parse: + +- **`<pr-number>`** (optional): a PR to debate. If given, `gh pr checkout <n>` + first and default the base to that PR's base branch. If omitted, debate the + **current branch's** working-tree diff. +- **`--base <branch>`**: ref to diff against. Always a **remote-tracking ref**, never + a stale local branch. Default: `origin/<PR base>` when a PR number is given, else + the repo default branch as `git symbolic-ref --short refs/remotes/origin/HEAD` + (e.g. `origin/master`) — used **as-is**, NOT stripped to local `master` (which + can lag the remote). Fallback `origin/master`. Step 1 runs `git fetch origin` + first so the ref is current. The workflow then resolves this to the **merge-base** + of `base` and HEAD and diffs against that, so commits `base` gained since the + branch forked aren't reviewed as part of this change. +- **`--no-commit`**: don't commit per round — leave all agreed changes + uncommitted in the working tree for you to commit yourself. Default is to + **commit each round** (see below). +- **`--no-comment`**: don't post the debate summary to the PR. By **default**, when + a PR exists, the debate summary IS posted as a PR comment (see step 3). Pass + this to suppress the outward-facing write and report in chat only. +- **`--rationale <note>`** (optional): the author's note on **deliberate** design + decisions. Threaded into **both** sides — codex's round-1 review prompt (so it + doesn't flag intentional choices as defects; its warm session carries the note + across later rounds) and the Claude author's prompt **every round** (so it + *disputes*, rather than "fixes", a finding that contradicts a deliberate choice). + Mirrors `/lens-debate`'s `rationale`. Pull it from the PR/issue description, or the + caller (`/be-review`) passes the change rationale straight through. +- **`--context <note>`** (optional): the **main-agent context** the Claude author + should **inherit** — what this change is FOR (its task/intent and key decisions the + orchestrator already holds). Injected into the author's prompt **every round** so it + no longer reconstructs intent from the diff alone (`agent()` is one-shot and can't + be resumed the way codex is, so re-injection is how it inherits at all). Given to + the **author only**, not codex — codex stays an independent reviewer of the code, + not the author's narrative. `/be-review` passes the task context through. + +## Steps + +### 1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- **`git fetch origin`** so remote-tracking refs are current — the base is an + `origin/...` ref, and a stale one would diff against the wrong tree. +- Resolve `base` per the rules above (a remote-tracking ref like `origin/master`). +- If a PR number was given, `gh pr checkout <n>` and confirm the branch. +- Confirm there is a non-empty diff: `git diff --stat <base>`. If empty, tell the + user there's nothing to review and stop. +- **Preflight codex**: `codex login status`. If not logged in, stop and tell the + user to run `codex login` (suggest the `!` prefix to do it in-session). + +### 2. Run the debate Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed script, passing +context through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/codex-debate/debate.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + base: "<base branch>", + commit: <false only if --no-commit>, + skillDir: ".claude/skills/codex-debate", + context: "<main-agent context the author inherits; omit/'' if none>", + rationale: "<author's note on deliberate decisions; omit/'' if none>" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It +alternates `codex:roundN` and `claude:roundN` agents under a **Debate** phase — +the user can watch live via `/workflows`. Each Claude round edits the working +tree and (unless `--no-commit`) **commits exactly that round's changed files in +the same session** — one commit per round, with a message embedding the round's +codex findings and Claude's dispositions — never pushing or merging. (The commit +is no longer a separate `commit:roundN` agent: the author already has the tree +open, so it commits its own round.) + +Ephemeral scratch (verdicts, the debate ledger) lives under the gitignored, +per-worktree `<repoPath>/.codex-debate/`, so **parallel debates in different +worktrees never collide** and the scratch never shows up in the diff codex +reviews. It returns: + +``` +{ status: "consensus" | "commit-incomplete" | "section-incomplete" | "reviewer-error", + rounds, base, finalVerdict, filesChanged, commitGaps, sectionGaps, transcript, + commentHeader, // the comment's small deterministic header (badge + round count + effort + base) + workDir, sectionGlob } // where the per-round section files live; cat them under the header (step 3) +``` + +(each `transcript[]` round also carries a `commit` SHA when that round committed; +`commitGaps` lists the round numbers whose author edited files but returned no +commit SHA — empty unless `status === "commit-incomplete"`; `sectionGaps` lists the +round numbers whose author left no disposition section file — empty unless +`status === "section-incomplete"`.) + +There is one more, **earlier** terminus the workflow can return **before** the +debate loop even starts — `merge-base-error`. If `git merge-base <base> HEAD` +fails (a missing/typoed/stale base, or unrelated history), the diff scope can't be +trusted, so the workflow **aborts up front** rather than review the base branch's +drift as if this change made it. It returns a **different, smaller shape** — no +`commentHeader`, `workDir`, or `sectionGlob`, because no debate (and so no section +files) ever ran: + +``` +{ status: "merge-base-error", base, rounds: 0, transcript: [], finalVerdict: null, + note } // human-readable: which base failed and how to fix it (e.g. `git fetch`) +``` +The debate is recorded as small Markdown section files under `<workDir>` — **two +per round**: `section-NNN-1-codex.md` (codex's verdict + findings) and +`section-NNN-2-claude.md` (the author's per-finding dispositions), zero-padded so +the `section-*.md` glob sorts in chronological order. **Each file is written by the +party that owns its content**: a Haiku writer renders codex's small *structured* +verdict to disk, and the **author writes its own dispositions directly** — the same +way codex writes its verdict to a path. That author-writes-its-own-file design is +deliberate: it keeps the author's *structured* return a **minimal ack** +(`filesChanged`/`commitSha`/`done`), so a big multi-finding narrative can never +overflow the structured-output encoding (the failure that used to crash the debate +on large diffs). The author's disposition file does triple duty — it's the author's +cross-round **memory**, the **rebuttal** codex reads next round (`codex-review.sh` +cats it straight into codex's prompt), and the **published comment** (step 3 cats +the section files under `commentHeader`). So the comment is still a **deterministic** +render — a shell `cat`, never re-improvised through an agent, nothing weak ever +retyping a large blob. codex is *not* a memory reader — it keeps its own warm +session and only ever reads the one rebuttal file. + +- **consensus** — every finding codex raised is resolved (any severity — Claude + fixed it or codex conceded the dispute). This is the *only* way the debate ends + *normally*: it keeps running rounds until codex and Claude agree on every point, + with no round cap and no deadlock exit. (The harness's own + per-workflow agent backstop is the sole hard ceiling; if you ever need to stop + a debate by hand, interrupt it via `/workflows` or `TaskStop`.) +- **commit-incomplete** — the debate *converged* (codex approved, nothing open), + but a round's author edited files yet returned **no commit SHA**, so its + in-session commit didn't land and the "one commit per round" contract broke for + the round(s) in `commitGaps`. The edits are **not lost** — they stay in the + working tree and the next reviewer diffs them against the base — but this is + **not** a clean consensus: a human must reconcile the uncommitted round(s) + (e.g. commit the outstanding tree) before relying on the per-round history. Do + **not** report it as a plain consensus (see step 3). +- **section-incomplete** — the debate *converged*, but a round's author **skipped + or under-filled its disposition section file** (`section-NNN-2-claude.md` missing, + empty, or missing a backticked marker for an open finding, for the round(s) in + `sectionGaps`). That file is the hole-free trail everyone draws on — + the author's memory, the rebuttal codex reads next round, and part of the posted + comment — so a miss means the published record has a gap. The code guards against + feeding an empty rebuttal to codex (it warns and keeps the prior pointer), and the + tree edits are still present, but this is **not** a clean consensus: a human must + fill in the missing round(s) before trusting the per-round record. Do **not** + report it as a plain consensus (see step 3). +- **merge-base-error** — an *up-front* abort, **before** any debate round runs: + `git merge-base <base> HEAD` failed (missing/typoed/stale base, or unrelated + history), so the review scope can't be trusted. The return carries a human-readable + `note` (which base failed, how to fix it — e.g. `git fetch`) and **none** of the + comment-assembly fields (`commentHeader`/`workDir`/`sectionGlob`), because no + section files were ever written. Report the scope failure and **skip comment + assembly/posting entirely** (see step 3); fix the base ref and re-run. +- **reviewer-error** — the one *abnormal* terminus: codex itself failed to + produce a verdict (broken/unavailable CLI), so the workflow synthesized an + error verdict and aborted rather than spin forever on a dead reviewer. This is + **infrastructure failure, not a debate outcome** — `finalVerdict.summary` + carries the failure detail (including how many attempts were made). Do **not** + treat it as consensus (see step 3). **Transient failures are retried first:** + `codex-review.sh` retries the `codex exec` invocation with linear backoff + (default 3 attempts; tune via `CODEX_REVIEW_RETRIES` / `CODEX_REVIEW_BACKOFF`) + and only synthesizes the reviewer-error verdict once every attempt comes back + empty — so a single codex hiccup no longer sinks the round. + +### 3. Present the result + +**First branch on `status`.** If `status === "merge-base-error"`, the workflow +**aborted before any debate ran** — `git merge-base <base> HEAD` failed, so the +review scope couldn't be trusted. This return has **no** `commentHeader`, +`workDir`, or `sectionGlob` (no section files exist), so there is **nothing to +assemble**: do **not** run the posting block below. Report the scope failure — +surface the return's `note` (it names the failing base and the fix) — tell the +user to repair the base ref (e.g. `git fetch`, fix a typo'd/stale ref) and re-run, +and **skip the rest of this section**. + +If `status === "reviewer-error"`, the debate did +**not** reach consensus — codex never produced a real verdict. Report it as a +**failure**, not a success: surface `finalVerdict.summary` (and the workflow log) +so the user sees codex was broken/unavailable, and tell them to fix codex (e.g. +`codex login`, check the CLI) and re-run. Do **not** post a consensus badge or a +`## Codex ⇄ Claude debate` PR comment for this path — there is no agreement to +report. Skip the rest of this section. + +If `status === "commit-incomplete"`, the debate converged but at least one round +left its edits **uncommitted** (the round numbers are in `commitGaps`). Report it +as **converged-but-not-clean**: assemble and post the comment (see the posting +block below — `commentHeader` already shows a `⚠️` badge, not the consensus +check), then tell the user which round(s) are uncommitted and that the outstanding +tree must be committed before the per-round history can be trusted. Do **not** call +it a clean consensus. + +If `status === "section-incomplete"`, the debate converged but at least one round's +author **skipped or under-filled its disposition section file** (missing, empty, or +omitting a marker for an open finding; round numbers in `sectionGaps`). +Report it as **converged-but-not-clean**: assemble and post the comment as usual +(the `⚠️` badge is already set), then tell the user which round(s) are missing +their disposition record and that the per-round history has a gap a human should +fill before trusting it. Do **not** call it a clean consensus. + +Otherwise (`status === "consensus"`) report in chat (do **not** push or merge — +the per-round commits sit on the local branch for the human to review): + +- The outcome — **consensus** — and how many rounds it took to get there. +- **The reviewer's reasoning effort** — sourced from the workflow's single + `REASONING_EFFORT` constant (`xhigh` today), which is passed down to + `codex-review.sh`'s `-c model_reasoning_effort` and into the comment header, so + the published value and the config codex actually ran at share one home. Read + it off the header rather than asserting it independently. State it so the depth + of the review is on the record. +- `git log --oneline <base>..HEAD` (the per-round debate commits) and + `git diff --stat <base>` so the user sees what the debate changed. +- A compact per-round summary — read it straight from the section files + (`cat <workDir>/section-*.md`: each round's codex verdict, then the author's + dispositions and commit SHA) so the convergence reads round by round. No need to + re-derive it from `transcript`; the sections already render it. +- The agreed changes are committed per round on the local branch (or, under + `--no-commit`, uncommitted in the working tree). The user reviews, then pushes + / merges (or runs `/do --from post-implement`) when satisfied. +- **Post the debate summary to the PR (default).** When a PR exists and + `--no-comment` was NOT passed, **assemble** the comment from the workflow's + return — the small `commentHeader`, then the per-round section files `cat`-ed in + glob order — and `gh pr comment <pr> -F <file>`: + + ```bash + mkdir -p "$workDir" # reviewer-error/--no-commit runs may not have created it + { + printf '%s\n' "$commentHeader" + for f in "$workDir"/section-*.md; do printf '\n'; cat "$f"; printf '\n'; done + } > "$workDir/comment.md" + gh pr comment <pr> -F "$workDir/comment.md" + ``` + + (`$workDir` is the returned `workDir`, i.e. `<repoPath>/.codex-debate`; the + `for`-loop guarantees a blank line between sections regardless of each file's + trailing newline.) The result is the `## Codex ⇄ Claude debate` header (consensus + badge, round count, the **reasoning-effort** note from the workflow's + `REASONING_EFFORT` constant) followed by the per-round breakdown of codex's + findings and the author's dispositions — the **same** section files the author + read as memory and codex read as the rebuttal. So the comment is a + **deterministic** shell concat of the record everyone drew on, not an + LLM-improvised table — nothing weak ever retypes a blob. This is an outward-facing + write — on by default because the whole point is to leave the review trail on the + PR; `--no-comment` suppresses it. + +<a id="answer-mode"></a> +# Answer mode — Codex ⇄ Claude answer debate + +When the argument is a **freeform prompt** (not a PR number/flags), the skill +generalizes the same debate machinery from *reviewing a diff* to *answering a +question*. The shape is **symmetric**, not author⇄reviewer: **Claude and codex are +two equal peers**. They each answer the prompt **independently and in parallel**, +then **cross-check each other's answer** round after round — conceding where the +other is right, holding firm (with evidence) where it isn't — **until both agree**. +A final pass **synthesizes their two converged answers into one unified reply**, +which you present to the user along with a saved transcript. + +Both peers are **codebase-aware but read-only**: each may read this repo (`git +diff/log`, read files, grep) to ground its answer, but neither edits anything — +codex stays under `--sandbox read-only` (kernel-enforced), and the Claude peer is +instructed not to write. Consensus is **schema-detected in code**: each side emits +a structured answer with an `agreesWithOther` boolean and an `objections` list, and +the loop ends only when **both** sides report no remaining disagreement. There is +**no round cap and no deadlock exit** — same as review mode. + +## Steps + +### A1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- Capture the **prompt**: everything **after the `answer` subcommand token** (strip + surrounding quotes). If it's empty, ask the user what they want answered and stop. +- **Preflight codex**: `codex login status`. If not logged in, stop and tell the + user to run `codex login` (suggest the `!` prefix to do it in-session). +- No `git fetch` / base resolution / `gh pr checkout` here — answer mode doesn't + diff a branch. + +### A2. Run the answer Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed answer script, +passing the prompt through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/codex-debate/answer.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + prompt: "<the user's freeform prompt, verbatim>", + skillDir: ".claude/skills/codex-debate" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It runs an +**Answer** phase (round 1: `claude:round1` and `codex:round1` in parallel), a +**Reconcile** phase (rounds 2+: each side cross-checks the other, in parallel, +round after round), and a **Synthesis** phase that merges the two agreed answers. +Watch live via `/workflows`. Ephemeral scratch (per-side answers, cross-check +files, per-round sections, the saved transcript) lives under the gitignored, +per-worktree `<repoPath>/.codex-debate/`, so parallel debates never collide. It +returns: + +``` +{ status: "consensus" | "reviewer-error" | "agent-error" | "synthesis-error" | "no-prompt", + rounds, prompt, finalAnswer, transcriptPath, reasoningEffort, codexError } +``` + +- **consensus** — the only normal terminus: both sides agreed and then both + **approved the synthesized candidate** (see the convergence note), and + `finalAnswer` is that approved unified answer. `transcriptPath` points at the saved + Markdown transcript (`.codex-debate/answer-<slug>.md`). +- **reviewer-error** — codex itself failed to produce an answer (broken/unavailable + CLI) after retries; `codexError` carries the failure detail. Infrastructure + failure, not a debate outcome. +- **agent-error** — one side died on a terminal API error after retries. +- **synthesis-error** — both sides DID agree, but the final synthesis pass produced + no answer (the synthesis agent died or returned empty). Not a successful answer — + report it as a failure (there is agreement on record, only the merge failed). +- **no-prompt** — the prompt was empty (shouldn't happen if A1 guarded it). + +### A3. Present the result + +- If `status === "consensus"`: present **`finalAnswer`** to the user as the answer + — this is the unified reply both Claude and codex agreed on. State **how many + rounds** it took to converge and that **codex answered at `reasoningEffort`** + (read it off the return value). Point the user at the saved transcript + (`transcriptPath`) for the full convergence trail; optionally `cat` the + `.codex-debate/answer-section-*.md` files to show a compact per-round summary + (each side's answer, what changed, remaining objections). This mode makes **no + outward-facing writes** — no PR comment, no commits — it just answers. +- If `status !== "consensus"`: report it as a **failure**, not an answer. Surface + `codexError` (for `reviewer-error`) or the workflow log so the user sees what + broke, and tell them how to fix it (e.g. `codex login`) and re-run. Do **not** + present a half-debate as if it were an agreed answer. + +## Answer-mode safety & notes + +- **Both peers read-only — but enforced ASYMMETRICALLY.** codex runs under + `--sandbox read-only` (kernel-enforced, belt-and-suspenders with the prompt text — + it reads arbitrary repo files and could be prompt-injected). The **Claude peer is + only prompt-enforced**: the harness's `agent()` exposes no sandbox/tool restriction + (the same is true of every Claude reviewer in `/lens-debate` and review mode), so + Claude's read-only behaviour rests on instruction, not a kernel guard. A + prompt-injected or mistaken Claude agent *could* in principle edit files or run a + git write — answer mode does not, and cannot here, harden against that the way it + does for codex. If that risk matters for a given prompt, run the debate in a + disposable/read-only worktree. Treat the read-only guarantee as **hard for codex, + best-effort for Claude.** +- **Warm codex session.** Round 1 cold-starts `codex exec`; every later round + resumes the same session (`codex exec resume`) so codex cross-checks from its own + prior answer rather than reconstructing it. The session id lives in the + gitignored per-worktree `.codex-debate/` (a distinct `codex-answer-session.id`, + so it never collides with review mode's session), degrading gracefully to a cold + start if capture ever fails. +- **Symmetric convergence, schema-detected, candidate-confirmed.** Each side emits + `agreesWithOther` + `objections`; a side counts as agreeing only when it sets + `agreesWithOther:true` AND leaves no objection, so a stray objection can't be + papered over by an over-eager boolean. Because the two run in parallel each round, + a single mutually-agreeing round can be a **swap false positive** (Claude adopts + codex's prior answer while codex adopts Claude's — both report agreement, but their + current outputs are swapped and still differ), and they can keep swapping back and + forth, so counting consecutive parallel agreements does **not** prove the current + outputs match. The only sound test is to make both sides judge **one shared piece + of text**. So when a round shows mutual agreement, the workflow synthesizes a single + **candidate** from the two agreed answers and runs a **confirmation phase**: both + sides review that *identical* candidate (without rewriting their own answer) and + either approve it or object. Approval is on one fixed text both actually saw, so no + swap is possible; if both approve, that candidate is the converged answer — already + signed off by both debaters (which is also why `finalAnswer` is never unapproved + synthesized text). If either objects, the candidate is dropped and the cross-check + loop resumes with the objections folded in. No round cap, no deadlock exit. +- **Chat + saved transcript, no outward writes.** The unified answer is presented + in chat and the full transcript is saved to the gitignored + `.codex-debate/answer-<slug>.md`. Unlike review mode, answer mode never commits + or posts to a PR. + +## Safety & notes (review mode) + +- **codex runs read-only — enforced, not just asked.** codex is invoked with + `--sandbox read-only`, so the kernel sandbox blocks file writes and other + state-mutating syscalls; the prompt's "don't write" instruction is belt-and- + suspenders, not the only guard. This matters because codex reviews arbitrary + diffs and could be prompt-injected by file contents. The only writes to the + tree come from the Claude author rounds. (codex auto-falls-back to its bundled + bubblewrap when the system one is absent, so read-only works in containers.) + Resume rounds enforce the same read-only policy via `-c sandbox_mode=read-only` + (the `resume` subcommand has no `--sandbox` flag) — same kernel guard, set + through config instead of the flag. +- **Warm reviewer session.** Round 1 cold-starts `codex exec`; the runner records + codex's session id (its `thread_id`, captured from the `--json` event stream) + under the scratch dir and every later round `codex exec resume`s it, so codex + retains its own prior review across rounds. The session id lives in the + gitignored per-worktree `.codex-debate/`, so parallel debates never resume each + other's sessions. If the id is ever missing (round-1 capture failed), a later + round transparently cold-starts with the full prompt + rebuttal — graceful + degradation, never a wedge. +- **Warm author (context, not session).** The Claude author can't be resumed the + way codex is — `agent()` is one-shot and Claude isn't headless under Max auth, + so there's no session id to carry forward. The equivalent is context, not state: + each follow-up round the author **reads the per-round section files** + (`cat .codex-debate/section-*.md`) — every prior round's codex findings and its + own dispositions — so it builds on its last round rather than re-deriving the + whole diff, and won't re-fix or re-litigate findings already settled. Each round + writes two small files (a Haiku-rendered codex section and the author's own + disposition section), so round N>1 always sees rounds 1..N-1; round 1 has none + yet, so it's byte-identical to a cold start (and if no sections exist, the author + falls back to the diff + verdict). The *same* sections compose the PR comment step + 3 posts and the rebuttal codex reads, so the author's memory, the published + summary, and codex's rebuttal are one record. Crucially, the author **writes its + own disposition section directly** — it never has to pour that narrative through a + structured field — so its structured return stays a minimal ack and can't overflow + on a large, many-finding diff (the failure this design replaced). The Haiku writer + only ever renders codex's small *structured* verdict, so nothing weak retypes a + large blob. codex stays on its own warm session and never reads the sections — + only the one rebuttal file each round. +- **Inherited context, not just diff.** On top of that cross-round memory, when the + caller passes `context` and/or `rationale` the author **inherits them in EVERY + round's prompt** — the main-agent intent (what the change is FOR) and the + deliberate-decision note. So even **round 1** reasons from the change's purpose + rather than the diff alone, and the author *disputes* a finding that contradicts a + deliberate choice instead of dutifully "fixing" it. The `rationale` also rides + codex's round-1 prompt (see `codex-review.sh`), so the reviewer doesn't raise those + intentional choices at the source; `context` is the author's alone (codex stays an + independent reviewer of the code, not the narrative). +- **Commits, but never pushes or merges.** Each round is committed locally (unless + `--no-commit`) so the PR history reads as the debate, but the skill never + pushes or merges. Consensus means "both AIs agree on the committed code," not + "ship it" — the human reviews the commits and pushes/merges. +- **Parallel-safe.** Ephemeral scratch (verdicts and the per-round section files, + the author-written ones doubling as the rebuttal) lives under the gitignored, + per-worktree `<repoPath>/.codex-debate/`, so debates on many worktrees run at once + without clobbering each other — no shared `/tmp` paths, and each worktree's section + files are its own. +- **Posts to the PR by default.** When a PR exists, the debate summary — the + `commentHeader` followed by the per-round section files `cat`-ed together (step 3) + — is posted as a PR comment (outward-facing write) unless `--no-comment` is passed + — the point is to leave the review trail on the PR. +- **Runs to consensus — no cap, no deadlock exit.** The loop ends only when codex + and Claude agree; it does not bail out at a round cap or declare a "deadlock," because + a debate that quits without agreement is pointless. The two sides keep arguing + until one concedes. The harness's own per-workflow agent backstop is the sole + hard ceiling; interrupt via `/workflows` or `TaskStop` if you ever need to stop + one by hand. **The one carve-out is *not* a deadlock exit:** a finding that is *not a + code edit for this worktree* but a downstream / ship-phase / process gate (a companion + repo pinning this repo's final post-review HEAD, a CI/release step, a cross-repo PR) + cannot be satisfied during the review — it targets the *post*-gauntlet HEAD. When + CLAUDE shows a finding is such a gate, codex marks it **resolved-and-deferred** + (acknowledged, handed to the ship phase) instead of holding it open forever. The CODE + debate still converges to consensus the normal way; this only stops the loop spinning + on a process gate neither side can land mid-review. It is narrow by design — a genuine + code defect CLAUDE simply dislikes is still argued to consensus, no exit. (This is the + loop that once spun until a human killed it on a `@kolu/surface` cross-repo run.) + +## Files + +Shared: + +- `scripts/codex-exec-lib.sh` — the sourced core both modes share: the read-only + `codex exec`/`resume` invocation, warm-session resolve/persist, retry/backoff, + thread-id capture, and the synthesized error-verdict fallback (via a caller hook). + The two mode scripts source this and add only their own prompts + verdict shape. + +Review mode: + +- `debate.workflow.js` — the Workflow script (the loop + consensus logic). +- `scripts/codex-review.sh` — the review-specific invocation (arg parsing, the + review prompts, the verdict schema/session file, the verdict-shaped error). +- `scripts/codex-verdict.schema.json` — the JSON Schema codex's verdict is constrained to. + +Answer mode: + +- `answer.workflow.js` — the Workflow script for the symmetric answer-debate + (parallel answers → cross-check loop to agreement → synthesis). +- `scripts/codex-answer.sh` — the answer-specific invocation (arg parsing, the + answer prompts, the answer schema/session file, the answer-shaped error). +- `scripts/codex-answer.schema.json` — the JSON Schema codex's answer is constrained to. + +These are generated from `agents/.apm/skills/codex-debate/`; edit the source there and +run `just ai apm` to regenerate. + +ARGUMENTS: $ARGUMENTS diff --git a/.claude/skills/codex-debate/answer.workflow.js b/.claude/skills/codex-debate/answer.workflow.js new file mode 100644 index 000000000..09ca6fdb2 --- /dev/null +++ b/.claude/skills/codex-debate/answer.workflow.js @@ -0,0 +1,488 @@ +export const meta = { + name: 'codex-answer-debate', + description: 'Have Claude and codex each answer a prompt in parallel, then cross-check until they agree, and synthesize one unified answer (no round cap, no deadlock exit)', + phases: [ + { title: 'Answer', detail: 'claude + codex answer the prompt independently, in parallel' }, + { title: 'Reconcile', detail: 'each cross-checks the other, round after round, until both agree' }, + { title: 'Synthesis', detail: 'merge the two agreed answers into one unified reply' }, + ], +} + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so it arrives as a +// STRING even when the caller passed a real object; a bare `args.repoPath`/`.prompt` +// would then be `undefined` and every input silently default. Parse a stringified +// `args` defensively (empty string → {}; object used as-is; malformed JSON throws +// loudly). See debate.workflow.js for the full cross-repo failure this fixes. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The user's freeform prompt — the question both assistants answer and then +// cross-check toward one agreed reply. Required; the orchestrator passes it. +const prompt = (a.prompt || '').trim() +// Where the generated skill lives, so the codex runner can find codex-answer.sh. +const skillDir = a.skillDir || '.claude/skills/codex-debate' +// Per-worktree scratch dir, shared with the review mode. Gitignored, derived from +// repoPath (the worktree root === $PWD) so parallel debates in DIFFERENT worktrees +// never collide on shared /tmp paths and these files never pollute the repo. +const workDir = `${repoPath}/.codex-debate` + +// Model tiers. The claude-answer round does real reasoning (answering, then +// cross-checking codex) → `model` (Opus). The final synthesis is also user-facing +// prose, so it runs on `model` too. The codex RUNNER and the transcript writer must +// relay text faithfully (a verbatim copy, not a paraphrase — the weakest tier +// corrupts it silently) → `copyModel` (Sonnet). Defaults match a direct invocation. +const model = a.model || 'opus' +const copyModel = a.copyModel || 'sonnet' + +// The reasoning effort codex runs at, scoped to the debate. This JS constant is +// the SINGLE home for the value: it is passed script-ward (a 4th positional arg to +// codex-answer.sh, which sets `-c model_reasoning_effort`) and read by the +// transcript header, so the `-c` flag and the header both derive from here. +const REASONING_EFFORT = 'xhigh' + +// POSIX single-quote a path for safe interpolation into a shell command (spaces, +// globs, metacharacters inert; embedded single quotes escaped via '\'' ). Used for +// the destructive scratch reset (`rm -f`) below. +const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'` + +// A filesystem-safe slug for this prompt, so the saved transcript has a readable, +// deterministic name (Date.now()/Math.random() are unavailable in workflow scripts, +// so the name is derived purely from the prompt text). Falls back to 'answer'. +const slug = + (prompt.toLowerCase().match(/[a-z0-9]+/g) || []).slice(0, 8).join('-').slice(0, 60) || 'answer' +const answerDocPath = `${workDir}/answer-${slug}.md` + +// --------------------------------------------------------------------------- +// Schema — shared by both debaters (codex's runner mirrors +// scripts/codex-answer.schema.json). `reviewerError` is set ONLY by the codex +// runner script when codex itself failed; the claude side never sets it. +// --------------------------------------------------------------------------- +const OBJECTION = { + type: 'object', + additionalProperties: false, + properties: { point: { type: 'string' }, reason: { type: 'string' } }, + required: ['point', 'reason'], +} +const ANSWER_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + answer: { type: 'string' }, + keyPoints: { type: 'array', items: { type: 'string' } }, + agreesWithOther: { type: 'boolean' }, + objections: { type: 'array', items: OBJECTION }, + changedMind: { type: 'string' }, + reviewerError: { type: 'boolean' }, + }, + required: ['answer', 'keyPoints', 'agreesWithOther', 'objections', 'changedMind'], +} +const FINAL_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { answer: { type: 'string' } }, + required: ['answer'], +} + +if (!prompt) { + return { + status: 'no-prompt', + rounds: 0, + prompt, + transcriptPath: null, + finalAnswer: null, + note: 'No prompt was passed to the answer-debate workflow; nothing to answer.', + } +} + +// --------------------------------------------------------------------------- +// The two debaters (symmetric: each answers, then cross-checks the other) +// --------------------------------------------------------------------------- +// CLAUDE answers/cross-checks via a harness subagent (Claude isn't headless under +// Max auth, so agent() is the only way to run it). Read-only: it may inspect the +// repo to ground its answer but must not edit anything. +async function claudeAnswers(round, other, myPrev) { + const block = + round === 1 + ? `Answer the question below thoroughly and honestly — your best, most defensible answer. You are in a debate with another assistant ("CODEX") answering the SAME question independently; you'll cross-check each other afterward, so make this strong.` + : `This is a CROSS-CHECK round. You and CODEX each answered the question; now reconcile toward ONE agreed answer. + +Your OWN previous answer (build on it — don't re-derive from scratch): +${JSON.stringify(myPrev, null, 2)} + +CODEX's LATEST answer (and its objections to your previous answer) (JSON): +${JSON.stringify(other, null, 2)} + +Weigh CODEX's answer against yours: + - Where CODEX is right and you were wrong or incomplete, UPDATE your answer to match and say what you changed in changedMind. + - Where CODEX is wrong or has a gap, hold your position and record it under objections with a specific, evidence-backed reason.` + const prompt_ = `You and CODEX were asked the SAME question. ${block} + +You may inspect the repo at \`${repoPath}\` to ground your answer — your shell cwd may be a different worktree, so use \`git -C ${repoPath}\` and absolute paths under it. READ-ONLY: read files, \`git -C ${repoPath} diff/log\`, grep; do NOT edit, create, delete, or run any git write command. Cite file:line for claims about this codebase. If the question isn't about this repo, answer from your own knowledge. + +The question: +${prompt} + +Return the schema: + - answer: your complete, self-contained answer as it stands now (on a cross-check round, your UPDATED unified answer). + - keyPoints: the core claims your answer rests on, one per item. + - objections: your remaining disagreements with CODEX's latest answer (empty on round 1, and empty once you fully agree). + - changedMind: what CODEX convinced you to change this round (empty on round 1 or if nothing changed). + - agreesWithOther: true ONLY when CODEX's latest answer is correct and complete and you have NO objection left — your two answers say the same thing. false on round 1.` + return agent(prompt_, { + label: `claude:round${round}`, + phase: round === 1 ? 'Answer' : 'Reconcile', + model, + schema: ANSWER_SCHEMA, + }) +} + +// A confirmation/approval turn for ONE side, judging a SINGLE shared candidate +// answer (the synthesized merge) WITHOUT rewriting its own. Both sides judge the +// IDENTICAL text, so no swap/oscillation is possible (the swap hazard exists only +// because each parallel round adopts the OTHER's separate answer). Returns +// `agreesWithOther` + `objections` against that candidate. Claude runs it directly +// (it's read-only reasoning); codex runs it through the same cross-check machinery +// (the candidate is handed to it as "CLAUDE's latest answer" to approve). +async function claudeConfirms(round, candidate) { + const prompt_ = `You and CODEX were asked the SAME question, debated, and AGREED. A unified candidate answer has been synthesized from your two agreed answers. Your ONLY job now is to APPROVE it or object — do NOT rewrite it, do NOT produce a new answer of your own. + +You may inspect the repo at \`${repoPath}\` to verify (READ-ONLY — \`git -C ${repoPath} diff/log\`, read files, grep; do NOT edit/create/delete or run any git write). + +The question: +${prompt} + +The candidate unified answer to approve: +${candidate} + +Return the schema: + - answer: echo the candidate VERBATIM (you are approving it, not rewriting it). + - keyPoints: the core claims the candidate rests on. + - objections: anything the candidate gets wrong, drops, or overstates relative to what you agreed — empty if you approve it as-is. Be specific (file:line for repo claims). + - changedMind: empty (you are confirming, not revising). + - agreesWithOther: true ONLY if you approve the candidate as a correct, complete unified answer with NO objection left.` + return agent(prompt_, { + label: `claude:confirm${round}`, + phase: 'Synthesis', + model, + schema: ANSWER_SCHEMA, + }) +} + +// CODEX answers/cross-checks via codex-answer.sh (warm session across rounds). The +// agent here is a MECHANICAL RUNNER: it writes the prompt + cross-check files, +// shells out to the script, and relays codex's JSON answer verbatim — it does NOT +// answer the question itself. The user's prompt and CLAUDE's latest answer carry +// arbitrary characters, so they're written with the Write tool, never a heredoc. +// On a CONFIRM turn (`confirming`), `other` is the SINGLE shared synthesized +// candidate STRING; the runner writes it to the cross-check file and passes the +// script's `confirm` token so codex plugs into the same verbatim/approve-or-object +// contract as the workflow's claudeConfirms turn (NOT the ordinary cross-check +// contract, which would tell codex to UPDATE its own answer instead of approving). +async function codexAnswers(round, other, confirming) { + const answerPath = `${workDir}/answer-codex-${round}.json` + const promptPath = `${workDir}/answer-prompt.txt` + const crossPath = `${workDir}/answer-crosscheck.json` + // The cross-check argument to the script: `-` on round 1 (codex answers + // independently), else the file holding either CLAUDE's latest answer (ordinary + // cross-check) or the synthesized candidate to approve (confirm turn). + const crossArg = round === 1 ? '-' : crossPath + // On a confirm turn the cross-check file holds the candidate VERBATIM (a plain + // string); on an ordinary cross-check it holds CLAUDE's answer JSON. + const crossContent = confirming ? other : JSON.stringify(other, null, 2) + const crossStep = + round === 1 + ? `2. (No cross-check this round — codex answers independently.)` + : `2. Using the Write tool (NOT a shell heredoc — the content has special characters), create \`${crossPath}\` with EXACTLY this content (overwriting any existing file): + +${crossContent}` + // Pass the script's `confirm` token on a confirm turn so it selects the + // approve-the-candidate prompt shape rather than the cross-check shape. + const confirmArg = confirming ? ` ${shq('confirm')}` : '' + // Every path below is spliced into a shell command the runner agent executes, so + // POSIX-quote each one (worktrees or skill dirs with spaces/metacharacters would + // otherwise break the command or misdirect it). The Write-tool file CONTENTS + // (${prompt}, ${crossContent}) are not shell-parsed and stay verbatim. + const runnerPrompt = `You are a MECHANICAL RUNNER for one round of an automated answer-debate. Do exactly the steps below and nothing else. Do NOT answer the question yourself, do NOT edit repository files, do NOT add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${shq(workDir)}\`. Using the Write tool (NOT a heredoc), create \`${promptPath}\` with EXACTLY this content (overwriting any existing file): + +${prompt} + +${crossStep} + +3. Run (cd into the repo root so the script's internal \`git\` targets THIS worktree — your shell cwd may be a different worktree): + \`cd ${shq(repoPath)} && bash ${shq(`${skillDir}/scripts/codex-answer.sh`)} ${shq(promptPath)} ${crossArg === '-' ? '-' : shq(crossArg)} ${shq(answerPath)} ${shq(REASONING_EFFORT)}${confirmArg}\` + + This shells out to the codex CLI as a read-only peer; it can take 1-3 minutes. It prints a JSON answer as its final stdout and also writes it to \`${answerPath}\`. + +4. Read \`${answerPath}\` and return its exact contents as your structured output. Copy the values faithfully; do not paraphrase or "improve" them.` + return agent(runnerPrompt, { + label: confirming ? `codex:confirm${round}` : `codex:round${round}`, + phase: confirming ? 'Synthesis' : round === 1 ? 'Answer' : 'Reconcile', + model: copyModel, // must relay codex's answer JSON faithfully + schema: ANSWER_SCHEMA, + }) +} + +// --------------------------------------------------------------------------- +// Transcript rendering — deterministic, in-process (no agent retypes the blob) +// --------------------------------------------------------------------------- +function renderObjections(objs) { + if (!objs || objs.length === 0) return ' - _(none)_' + return objs.map((o) => ` - **${o.point}** — ${o.reason}`).join('\n') +} + +function renderSide(name, ans) { + if (!ans) return `**${name}** — _(no turn this round)_` + const lines = [ + `**${name}** — agrees with other: \`${!!ans.agreesWithOther}\``, + '', + ans.answer, + ] + if (ans.changedMind && ans.changedMind.trim()) lines.push('', `_changed mind:_ ${ans.changedMind}`) + lines.push('', 'Objections to the other side:', renderObjections(ans.objections)) + return lines.join('\n') +} + +// A confirm round is not a normal answer round: both sides judged ONE shared +// synthesized candidate (approve-or-object), so rendering each side's `answer` +// would misleadingly show two texts for a round whose whole point was that both +// judged the IDENTICAL one. Show the candidate ONCE, then each side's verdict +// (agrees + objections) against it. +function renderConfirmVerdict(name, ans) { + if (!ans) return `**${name}** — _(no turn this round)_` + return [ + `**${name}** — approved: \`${!!ans.agreesWithOther}\``, + 'Objections to the candidate:', + renderObjections(ans.objections), + ].join('\n') +} + +function roundSection(entry) { + if (entry.confirming) { + return [ + `### Round ${entry.round} — confirmation`, + '', + 'Both sides judged this single synthesized candidate (approve or object):', + '', + entry.candidate, + '', + renderConfirmVerdict('claude', entry.claude), + '', + renderConfirmVerdict('codex', entry.codex), + ].join('\n') + } + return [ + `### Round ${entry.round}`, + '', + renderSide('claude', entry.claude), + '', + renderSide('codex', entry.codex), + ].join('\n') +} + +function transcriptHeader(meta) { + const badge = meta.status === 'consensus' ? '✅ **Agreed**' : `⚠️ **${meta.status}**` + return `# Codex ⇄ Claude answer-debate + +> **Prompt:** ${meta.prompt} + +${badge} after ${meta.rounds} round(s) · codex answered at \`${meta.reasoningEffort}\` reasoning effort` +} + +function renderTranscript(transcript, meta, finalAnswer) { + const parts = [transcriptHeader(meta)] + if (finalAnswer) parts.push('## Final unified answer', '', finalAnswer) + parts.push('## Convergence trail', ...transcript.map(roundSection)) + return parts.join('\n\n') +} + +phase('Answer') + +const transcript = [] +let status = 'consensus' +let claudeAns = null +let codexAns = null + +// A side "agrees" only when it BOTH set agreesWithOther AND left no objection. The +// boolean and the objections list must be consistent (the schema says so), but a +// model can set the flag while still listing a disagreement; honour the objections +// too so a leftover objection can't be papered over by an over-eager boolean. +const sideAgrees = (a) => a.agreesWithOther === true && (a.objections || []).length === 0 + +// Synthesize a SINGLE candidate answer from the two agreed answers. This is the +// user-facing unified reply, but it is NOT returned until BOTH sides approve it (the +// confirmation phase below), so the synthesized text is never reported as consensus +// without both debaters having signed off on it. Returns the candidate string, or +// null if the synthesis agent died / returned empty. +async function synthesize(claudeFinal, codexFinal) { + const synth = await agent( + `Claude and codex were each asked the question below and, after cross-checking, AGREED. Merge their two (now-equivalent) answers into ONE clean, unified, self-contained answer for the user — no "Claude said / codex said" framing, no meta-commentary about the debate, just the best single answer. Preserve every substantive point both kept; where they used different wording for the same idea, pick the clearest. Keep any file:line citations. + +The question: +${prompt} + +Claude's final answer (JSON): +${JSON.stringify(claudeFinal, null, 2)} + +Codex's final answer (JSON): +${JSON.stringify(codexFinal, null, 2)} + +Return the unified answer in \`answer\`.`, + { label: 'synthesis', phase: 'Synthesis', model, schema: FINAL_SCHEMA }, + ) + return synth && synth.answer ? synth.answer : null +} + +// --------------------------------------------------------------------------- +// The loop — round 1 is the independent answer (parallel); rounds 2+ are +// cross-checks. Runs until BOTH sides agree, then a CONFIRMATION phase on a single +// synthesized candidate. No round cap, no deadlock exit: each side keeps +// cross-checking until they converge (the harness's per-workflow agent backstop is +// the only hard ceiling — interrupt via /workflows or TaskStop). +// +// Both sides run in PARALLEL each round, so in round N each cross-checks the OTHER's +// round-(N-1) answer. That parallelism creates a SWAP/OSCILLATION hazard: if Claude +// adopts codex's prior answer while codex simultaneously adopts Claude's prior +// answer, both can report agreesWithOther:true in the SAME round even though their +// CURRENT outputs are swapped and still differ — and they can keep swapping back and +// forth, so counting consecutive parallel agreements does NOT prove the current +// outputs match. The only sound test is to make BOTH sides judge ONE shared piece of +// text. So when a round shows mutual agreement, we synthesize a single candidate +// from the two agreed answers and run a CONFIRMATION phase: both sides review that +// IDENTICAL candidate (without rewriting their own answer) and either approve it or +// object. Approval is on one fixed text both actually saw, so no swap is possible. If +// both approve, that candidate IS the converged answer (already debater-approved). If +// either objects, its objections fold back into the cross-check loop and we continue. +// --------------------------------------------------------------------------- +let finalAnswer = null +// On a confirmation turn, each side judges the SAME synthesized candidate STRING. +// Carried across iterations so a rejected confirmation feeds the candidate + the +// objector's complaints back into the next ordinary cross-check round. +let pendingCandidate = null +for (let round = 1; ; round++) { + const confirming = pendingCandidate !== null + const prevClaude = claudeAns + const prevCodex = codexAns + // On a confirm turn BOTH sides run their dedicated approve-a-fixed-candidate + // interface (claudeConfirms / codexAnswers(..., confirming)), so both plug into + // one verbatim/approve-or-object contract instead of the ordinary cross-check. + const [claude, codex] = await parallel([ + () => + confirming + ? claudeConfirms(round, pendingCandidate) + : claudeAnswers(round, round === 1 ? null : prevCodex, prevClaude), + () => + confirming + ? codexAnswers(round, pendingCandidate, true) + : codexAnswers(round, round === 1 ? null : prevClaude, false), + ]) + + // codex infrastructure failure — terminal. The runner could not get an answer + // out of codex (broken/unavailable CLI), so it synthesized reviewerError:true. + // Retrying a dead reviewer just spins, so abort and surface the failure. This is + // deliberately separate from the "no deadlock exit" rule for real disagreement. + if (codex && codex.reviewerError) { + status = 'reviewer-error' + log(`Round ${round}: codex error — aborting. ${codex.answer}`) + transcript.push({ round, claude, codex }) + break + } + // A side died on a terminal API error after retries (agent() returned null). + // We can't reconcile half a debate, so abort loudly rather than loop on nulls. + if (!claude || !codex) { + status = 'agent-error' + log(`Round ${round}: ${!claude ? 'claude' : 'codex'} produced no answer (agent error) — aborting.`) + transcript.push({ round, claude, codex }) + break + } + + claudeAns = claude + codexAns = codex + // On a confirm round both sides judged the ONE shared candidate; tag the entry + // (with the candidate itself) so the transcript renders it as an approve/object + // verdict on a single text rather than as two separate answer rounds. + const entry = confirming + ? { round, claude, codex, confirming: true, candidate: pendingCandidate } + : { round, claude, codex } + transcript.push(entry) + + // CONFIRMATION phase: both sides judged the SAME synthesized candidate. If both + // approve it (agreesWithOther:true + no objections), that candidate is the agreed, + // debater-approved unified answer — converge. If either objects, drop the candidate + // and continue the cross-check loop (their objections are already in `claudeAns` / + // `codexAns` and feed the next round). + if (confirming) { + if (sideAgrees(claude) && sideAgrees(codex)) { + finalAnswer = pendingCandidate + log(`Round ${round}: both sides approved the synthesized candidate — converged.`) + break + } + log(`Round ${round}: candidate rejected (claude agrees=${sideAgrees(claude)}, codex agrees=${sideAgrees(codex)}) — resuming cross-check.`) + pendingCandidate = null + continue + } + + // From round 2 on (round 1 has no cross-check), if BOTH sides report no remaining + // disagreement, synthesize one candidate and enter the confirmation phase next + // round. A single parallel-agreeing round can be a swap false positive, so we do + // NOT converge here — we converge only after both sides approve the SAME candidate. + const bothAgree = round >= 2 && sideAgrees(claude) && sideAgrees(codex) + if (bothAgree) { + phase('Synthesis') + pendingCandidate = await synthesize(claude, codex) + if (!pendingCandidate) { + // The merge itself failed (synthesis agent died / returned empty). The sides + // DID agree; only the merge broke — surface that explicitly rather than spin. + status = 'synthesis-error' + log('Synthesis produced no candidate — both sides agreed but the merge failed; reporting synthesis-error.') + break + } + log(`Round ${round}: both sides agree — synthesized a candidate; confirming it next round.`) + phase('Reconcile') + } else { + log( + `Round ${round}: claude agrees=${sideAgrees(claude)} (objections=${(claude.objections || []).length}), codex agrees=${sideAgrees(codex)} (objections=${(codex.objections || []).length})`, + ) + } +} + +log(`Answer-debate ended: ${status} after ${transcript.length} round(s).`) + +// Persist the full transcript to a single readable file the user can revisit +// (chat + saved transcript). Rendered deterministically in-process, then handed to +// one mechanical writer — the payload can be large, so use copyModel for faithful +// reproduction (the same tier the codex relay uses). +const transcriptText = renderTranscript( + transcript, + { status, rounds: transcript.length, prompt, reasoningEffort: REASONING_EFFORT }, + finalAnswer, +) +await agent( + `You are a MECHANICAL WRITER. Do exactly these steps and nothing else — do not edit any other file, do not run git, do not add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${shq(workDir)}\`. +2. Using the Write tool, create \`${answerDocPath}\` with EXACTLY this content, overwriting any existing file: + +${transcriptText}`, + { label: 'transcript:write', phase: 'Synthesis', model: copyModel }, +) + +return { + status, + rounds: transcript.length, + prompt, + transcriptPath: answerDocPath, + finalAnswer, + reasoningEffort: REASONING_EFFORT, + // The error terminus carries codex's failure detail in the synthesized verdict's + // answer text. Sourced from the transcript (not codexAns) because the + // reviewer-error branch breaks BEFORE assigning codexAns — the failing round is + // recorded in the transcript, so read the detail back from there. + codexError: + status === 'reviewer-error' + ? transcript.find((e) => e.codex && e.codex.reviewerError)?.codex.answer || null + : null, +} diff --git a/.claude/skills/codex-debate/debate.workflow.js b/.claude/skills/codex-debate/debate.workflow.js new file mode 100644 index 000000000..519d4a706 --- /dev/null +++ b/.claude/skills/codex-debate/debate.workflow.js @@ -0,0 +1,671 @@ +export const meta = { + name: 'codex-debate', + description: 'Run a codex<->claude review debate on the current diff until they reach consensus (no round cap, no deadlock exit)', + phases: [ + { title: 'Debate', detail: 'codex reviews -> claude responds, round after round' }, + ], +} + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so `args` arrives as +// a STRING even when the caller passed a real object — a bare `args.repoPath` is then +// `undefined` and EVERY input below silently falls back to its default. That's the +// cross-repo bug: `repoPath` degrades to `.` (the cwd), the debate runs `git -C .` +// against the WRONG repo, and it reports a vacuous "clean" (or, worse, commits fixes +// onto the cwd repo). It also means `base`/`model`/`rationale`/`context` never thread +// through; same-repo runs only "work" by cwd coincidence. So parse a stringified +// `args` defensively here: an empty string means "no args" → {}; an already-parsed +// object is used as-is; malformed JSON THROWS loudly (fail-fast) rather than degrading +// to a silent default. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The diff base. Resolved to the MERGE-BASE of (rawBase, HEAD) just before the +// debate (see phase 'Debate') so commits rawBase gained since the branch forked +// aren't reviewed as if this change made them. `let` because that resolution +// reassigns it; every prompt reads the resolved value. (Idempotent when the +// caller already passed a merge-base SHA, e.g. /be-review.) +let base = a.base || 'origin/master' +// Where the generated skill lives, so the codex runner can find codex-review.sh. +const skillDir = a.skillDir || '.claude/skills/codex-debate' +// Per-worktree scratch dir for rebuttal/verdict files. Derived from repoPath +// (the worktree root === $PWD) so parallel debates in DIFFERENT worktrees never +// collide on shared /tmp paths, and `.codex-debate/` is gitignored so these +// files never pollute the diff codex reviews. +const workDir = `${repoPath}/.codex-debate` +// POSIX single-quote a path for safe interpolation into a shell command. Wraps +// in single quotes (so spaces, globs, and shell metacharacters are inert) and +// escapes any embedded single quote via the '\'' idiom. Used for the one +// DESTRUCTIVE command (the ledger `rm -f` below); the benign `mkdir -p` prompts +// elsewhere can tolerate an unquoted path, but a mistargeted `rm -f` cannot. +const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'` +// The debate is recorded as small Markdown SECTION FILES under the gitignored +// scratch dir — TWO per round: `section-NNN-1-codex.md` (codex's verdict) and +// `section-NNN-2-claude.md` (the author's dispositions). Each is written by the +// party that OWNS its content, never forced through a structured payload: +// * codex's section — a Haiku writer renders the small STRUCTURED verdict +// (approval + findings) to disk; faithful, nothing to bloat. +// * claude's section — the AUTHOR writes its OWN per-finding dispositions +// directly (it's already editing the tree), exactly the way codex writes its +// verdict to a path. This is the STRUCTURAL fix for the old crash: the author +// used to pour its whole narrative into a structured field, which overflowed +// the StructuredOutput encoding and silently dropped the required array until +// the retry cap tripped. Now the narrative goes to the file and the author's +// structured return is a MINIMAL ACK (filesChanged/commitSha/done) whose size +// is decoupled from the finding count entirely, so it can never overflow. +// These section files serve THREE roles at once, no copy ever re-typed by a weak +// agent: (1) the author's cross-round memory (it cats them for full history); +// (2) the REBUTTAL codex reads next round — codex-review.sh `cat`s the author's +// section file straight into its prompt, so codex still sees every disposition; +// (3) the published PR comment, which the orchestrator assembles by `cat`-ing the +// section files after a small in-process header (see ledgerHeader / commentHeader) +// — a deterministic shell concat, never re-rendered through an agent. codex is NOT +// a memory reader: it keeps its own warm session, so it only ever reads the one +// rebuttal file, not the whole ledger. +// Commit each round's changes individually (default on). The author commits its +// OWN round in-session — it already edits the tree, so it stages exactly what it +// changed and writes a message carrying the debate context (codex's findings + +// its dispositions). Never pushes or merges — that stays the human's call. +const commit = a.commit !== false +// Model tiers. The claude-author round does real reasoning (fixing/disputing +// codex's findings, and committing its own round) → `model` (Opus). Everything +// else here is mechanical — the codex runner just shells out to codex-review.sh +// and copies the verdict, the codex-section writer dumps a rendered verdict to a +// file, the merge-base resolver runs one git command → `mechModel` +// (Haiku). (The CLAUDE section is written by the author itself, on `model`, as part +// of its round — not by a mechanical writer.) Defaults match a direct invocation; +// /be-review passes both explicitly. +const model = a.model || 'opus' +const mechModel = a.mechModel || 'haiku' +// Fidelity tier (Sonnet). One "mechanical" job isn't a trivial command but a +// faithful COPY: the codex runner reads codex's verdict JSON off disk and must +// return it byte-for-byte. A paraphrase silently corrupts the debate (and schema +// validation checks the verdict's SHAPE, not its wording), and Haiku is the +// weakest tier for verbatim reproduction — so the verdict relay runs a notch up. +// Still far cheaper/faster than Opus; the real reviewing is codex's, not this +// agent's. The small per-round codex-section writes stay on Haiku (tiny payloads). +const copyModel = a.copyModel || 'sonnet' + +// --- Context the Claude implementor INHERITS -------------------------------- +// Two optional notes the CALLER threads in so the implementor (the Claude author) +// no longer reasons from the diff alone — the gap that made it re-derive the +// change's intent every round and re-litigate deliberate choices codex (rightly, +// on a bare diff) flags. +// +// `context` (#1): the MAIN-AGENT context — what this change is FOR (the task/intent +// and key decisions the orchestrator already holds). Injected into the implementor +// EVERY round: agent() is one-shot and Claude isn't headless under Max auth, so it +// can't be resumed the way codex is — re-injection is how it "inherits" at all. +// Deliberately NOT given to codex, which stays an independent reviewer of the +// actual code rather than the author's narrative. +const context = (a.context || '').trim() +// `rationale` (#2): the author's note on DELIBERATE decisions — the same note +// /lens-debate already accepts, now threaded here too. Given to BOTH sides: codex +// (its round-1 prompt, via codexReviews → codex-review.sh — so the reviewer doesn't +// raise them at the source; codex's warm session carries the note across rounds) +// AND the implementor (so it DISPUTES, rather than "fixes", a finding that +// contradicts a deliberate choice). +const rationale = (a.rationale || '').trim() +// The two notes as ready-to-interpolate implementor-prompt blocks. Empty when the +// note is absent, so the prompt stays byte-identical to the contextless form then. +const contextBlock = context + ? `\nContext you INHERIT from the main agent — what this change is FOR (its task/intent and key decisions). Weigh codex's findings against it: a finding that contradicts this intent is a candidate to DISPUTE, not blindly fix.\n${context}\n` + : '' +const rationaleBlock = rationale + ? `\nAuthor's note on DELIBERATE decisions (chosen on purpose — do NOT "fix" them away; dispute the finding unless codex shows the decision itself is wrong):\n${rationale}\n` + : '' +// codex reads the rationale from a file (it's constant across rounds, written once +// before the loop); `-` means "no rationale" to codex-review.sh. +const rationaleFile = `${workDir}/rationale.md` +const rationaleFileArg = rationale ? rationaleFile : '-' + +// The reasoning effort codex runs at, scoped to the debate. This JS constant is +// the SINGLE home for the value: it is passed script-ward (a 4th positional arg +// to codex-review.sh, which sets `-c model_reasoning_effort`) and read by +// ledgerHeader for the published comment, so the `-c` flag and the header both +// derive from here via the one-directional invocation channel — no literal +// repeated across files held together by "remember to update all of them". +const REASONING_EFFORT = 'xhigh' + +// --------------------------------------------------------------------------- +// Schemas — the codex verdict schema mirrors scripts/codex-verdict.schema.json +// so the runner agent returns the same shape codex was constrained to. +// --------------------------------------------------------------------------- +const FINDING = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string' }, + severity: { type: 'string', enum: ['blocking', 'major', 'minor', 'nit'] }, + location: { type: 'string' }, + issue: { type: 'string' }, + suggestion: { type: 'string' }, + status: { type: 'string', enum: ['open', 'resolved'] }, + }, + required: ['id', 'severity', 'location', 'issue', 'suggestion', 'status'], +} + +const CODEX_VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + approved: { type: 'boolean' }, + summary: { type: 'string' }, + findings: { type: 'array', items: FINDING }, + responseToRebuttal: { type: 'string' }, + // Set by scripts/codex-review.sh ONLY when codex itself failed to produce a + // verdict (broken/unavailable reviewer). It is the machine-detectable fatal + // signal the loop aborts on — infrastructure failure, not a debate outcome. + reviewerError: { type: 'boolean' }, + }, + required: ['approved', 'summary', 'findings', 'responseToRebuttal'], +} + +// The author's structured return is a MINIMAL ACK by design — no `summary`, no +// per-finding `actions`. The full narrative (the per-finding dispositions AND the +// round summary) is written by the author to its section file instead (see +// claudeResponds), exactly the way codex writes its verdict to a path. This is the +// structural fix for the old crash: a large structured payload (the author pouring +// its whole F1/F2/F3 narrative into `summary` + one `detail` per finding) +// overflowed the StructuredOutput encoding, which silently dropped the required +// array and tripped the retry cap. With only these few small, fixed fields the +// payload size is decoupled from the finding count entirely, so it can never +// overflow. `filesChanged` is bounded by the files touched (not narrative); +// `commitSha` is one hash; `done` is a flag. +const CLAUDE_RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + filesChanged: { type: 'array', items: { type: 'string' } }, + // The author commits its own round (it already edits the tree), so it returns + // the resulting SHA here. "" when it changed nothing or ran under --no-commit. + commitSha: { type: 'string' }, + done: { type: 'boolean' }, + }, + required: ['filesChanged', 'done'], +} + +// Consensus = no finding left open, any severity. The loop runs until codex +// resolves every one (CLAUDE fixed it, or codex conceded a dispute). No cap. +function openFindings(verdict) { + return (verdict.findings || []).filter((f) => f.status !== 'resolved') +} + +// --------------------------------------------------------------------------- +// The two debaters +// --------------------------------------------------------------------------- +async function codexReviews(round, rebuttalPath) { + const verdictPath = `${workDir}/verdict-${round}.json` + // The rebuttal codex reads is the author's PRIOR-round disposition section file + // (it wrote it itself; codex-review.sh `cat`s it straight into codex's prompt). + // No inline blob, no separate rebuttal-file write step — the file already exists. + // `-` on round 1 (no prior author turn yet). + const rebuttalArg = rebuttalPath || '-' + const rebuttalNote = rebuttalPath + ? ` (\`${rebuttalPath}\` is the author's prior-round disposition section — codex reads it as the rebuttal. If it's somehow missing, the script proceeds with no rebuttal and warns; that's fine.)` + : ` (No prior rebuttal this round — the \`-\` argument tells the script there's none.)` + + const prompt = `You are a MECHANICAL RUNNER for one round of an automated code-review debate. Do exactly the steps below and nothing else. Do NOT review the code yourself, do NOT edit any repository files, do NOT add commentary. + +First ensure the scratch dir exists: \`mkdir -p ${workDir}\`. + +1. Run (cd into the repo root so the script's internal \`git diff\`/\`git status\` target THIS worktree — your shell cwd may be a different worktree): + \`cd ${repoPath} && bash ${skillDir}/scripts/codex-review.sh ${base} ${rebuttalArg} ${verdictPath} ${REASONING_EFFORT} ${rationaleFileArg}\` +${rebuttalNote} + + This shells out to the codex CLI as a read-only reviewer; it can take 1-3 minutes. It prints a JSON verdict as its final stdout and also writes it to the \`-o\` path. + +2. Read \`${verdictPath}\` and return its exact contents as your structured output. Copy the values faithfully; do not paraphrase or "improve" them.` + + return agent(prompt, { + label: `codex:round${round}`, + phase: 'Debate', + model: copyModel, // not trivial: must relay codex's verdict JSON faithfully + schema: CODEX_VERDICT_SCHEMA, + }) +} + +async function claudeResponds(round, verdict, doCommit) { + // WARM AUTHOR. We can't truly resume the Claude author (agent() is one-shot, + // and Claude isn't headless under Max auth, so there's no session to resume the + // way `codex exec resume` carries codex's reasoning forward). The achievable + // equivalent is context, not state: every follow-up round the author reads the + // per-round section files — the record of every prior round's findings (codex's + // section, Haiku-written) and its OWN dispositions (the claude section it wrote + // itself last round) — and builds on them instead of re-deriving the diff. codex's + // section is written each round in the loop and the author writes its claude + // section as the LAST step of its own turn, so on round N>1 the files already hold + // rounds 1..N-1. Round 1 has none yet, so its prompt is byte-identical to a cold start. + const priorBlock = + round > 1 + ? `This is a FOLLOW-UP round. Every prior round is recorded as a small Markdown file under the debate's scratch dir — read them FIRST for the full history (codex's past findings and YOUR own dispositions): + \`cat ${workDir}/section-*.md\` (or Read them individually; if none exist, fall back to the diff + the verdict below) +Build on what you already did; don't re-derive the diff from scratch, and don't re-fix or re-litigate anything already settled. For any finding you DISPUTED, check codex's \`responseToRebuttal\` in the verdict below: if codex conceded, you're done with it; if codex held firm, weigh its reasoning and either fix it or hold with a sharper argument. Spend this round on findings still \`open\` plus any new ones. + +` + : '' + const prompt = `You authored the changes on this branch. CODEX reviewed them and returned the verdict below — what do you think? Fix what you agree with, push back (with reasons) on what you don't. + +Work in the repo at \`${repoPath}\` — your shell cwd may be a different worktree, so use ABSOLUTE paths under it and \`git -C ${repoPath}\`. See the change with \`git -C ${repoPath} diff ${base}\`. +${contextBlock}${rationaleBlock} +${priorBlock}CODEX's verdict (JSON): +${JSON.stringify(verdict, null, 2)} + +Address EVERY finding, any severity (don't skip minors/nits): + - agree → fix it in the working tree; disposition "fixed". + - disagree → leave the code, dispute it with a specific technical reason (cite file:line); disposition "disputed". Concede when codex is right. + - partly → fix the valid part, explain the rest; disposition "partial". + - NOT a code edit for this worktree — a downstream / ship-phase / process gate (a companion repo pinning this repo's FINAL post-review HEAD, a CI/release step, a cross-repo PR) that cannot be satisfied mid-review → disposition "disputed", and SAY EXPLICITLY it is a ship-phase gate, not a code change, so codex marks it resolved-and-deferred rather than holding the debate open on something neither side can land here. Use this ONLY for a genuine non-code/process gate, never to dodge a code change you'd rather not make. + +You may run the formatter on files you touched. SELF-VERIFY before you claim "fixed": a fix you didn't run isn't a fix. Run the project's own fast static-check gate (its lint + typecheck task — e.g. \`just check\`/\`npm run lint\`; discover it from the repo, don't hand-roll one) over your edits and make it pass; only mark a finding "fixed" once the gate is green. Never claim a lint won't fire without running it — that's the exact "I watched it work" gap that lands a red tree on the next step. If the gate stays red after a genuine attempt, say so in the disposition rather than reporting a clean "fixed". ${ + doCommit + ? `Once you've addressed every finding AND you actually changed files (and the static-check gate is green), COMMIT this round's work yourself — the debate records one commit per round. Stage ONLY the files you changed (never \`git add -A\` or \`git add .\`) and commit with \`git -C ${repoPath}\`, subject \`fix: codex review — debate round ${round}\` and a body that summarizes your changes plus, briefly, codex's findings and how you dispositioned each. Do NOT push. You'll return the resulting SHA (\`git -C ${repoPath} rev-parse HEAD\`) in \`commitSha\`.` + : `Edit the working tree only — do NOT git add/commit/push; you'll leave \`commitSha\` empty.` + } + +RECORD your dispositions to a file — this is the durable trail and the heart of how this debate stays robust. The next round's author reads it as memory, codex reads it as your rebuttal, and it's what gets posted to the PR. So the FULL per-finding narrative lives in this file, NEVER in your structured return. Using the Write tool, create the file \`${claudeSectionFile(round)}\` (overwrite any existing one) with EXACTLY this Markdown shape: + +**claude** — <one sentence: what you did this round> + +- \`F1\` **fixed** — <why; what you changed; cite file:line> +- \`F2\` **disputed** — <the specific technical reason codex is wrong; cite file:line> +- \`F3\` **partial** — <what you fixed and what you didn't, and why> +${doCommit ? '... one bullet PER finding (disposition ∈ fixed | disputed | partial), then:\n\ncommit: `<the SHA you committed, or omit this whole line if you changed nothing>`' : '... one bullet PER finding (disposition ∈ fixed | disputed | partial). (No commit line — running under --no-commit.)'} + +CRITICAL — your STRUCTURED RETURN IS A MINIMAL ACK, nothing more. Return ONLY: \`filesChanged\` (the source paths you edited — never the scratch file above), \`commitSha\` (the SHA, or "" if you changed nothing / under --no-commit), and \`done\` (true once you've addressed every finding this round). Do NOT put your summary or any per-finding detail in the structured output — all of that goes in the section file and the commit body. (This is deliberate: past runs CRASHED because the author poured a multi-finding narrative into a structured field, which overflowed the output encoding and dropped a required field until the retry cap tripped. Writing the narrative to the file instead removes that failure by construction — keep the structured payload tiny.)` + + return agent(prompt, { + label: `claude:round${round}`, + phase: 'Debate', + model, // deep reasoning: the author fixing/disputing real findings + schema: CLAUDE_RESPONSE_SCHEMA, + }) +} + +// --------------------------------------------------------------------------- +// The shared ledger — section files on disk, assembled into the comment by the +// orchestrator (a faithful `cat`). The workflow renders only the small CODEX +// section (from the structured verdict) and the comment HEADER in-process; the +// author writes its own claude section (see claudeResponds). +// --------------------------------------------------------------------------- +// One codex finding as a Markdown bullet — the single projection of a finding's +// fields for the ledger section. (The per-round commit message is now written by +// the author itself, in its own session, so this no longer feeds it.) +function findingBullet(f) { + return `- \`${f.id}\` · ${f.severity} · ${f.status} — ${f.issue} (${f.location})` +} + +// One round's findings, as a Markdown list. Shared by the codex-section renderer. +function renderFindings(verdict) { + const list = (verdict.findings || []).map((f) => findingBullet(f)).join('\n') + return list || '- _(none)_' +} + +// CODEX's side of one round, as a Markdown section: its verdict, findings, and +// response to the author's rebuttal. Rendered in-process from the STRUCTURED +// verdict (small, faithful) and written to disk by a Haiku writer. The author's +// side is a SEPARATE file the author writes itself (its dispositions), so this +// renderer no longer touches the claude response at all — that's the decoupling +// that keeps the author's structured payload minimal. This carries the round's +// `### Round N` header (it's written every round, including the terminal one). +function codexSection(round, verdict) { + const lines = [ + `### Round ${round}`, + '', + `**codex** — approved: \`${verdict.approved}\``, + '', + verdict.summary, + '', + 'Findings:', + renderFindings(verdict), + ] + if (verdict.responseToRebuttal) lines.push('', `_codex on the rebuttal:_ ${verdict.responseToRebuttal}`) + return lines.join('\n') +} + +// The comment header (small). The full comment is this header followed by the +// per-round section files, `cat`-ed together by the orchestrator (see SKILL step 3) +// — a deterministic shell concat, never re-rendered through an agent. The workflow +// returns this header as `commentHeader`; it can't read the section files itself +// (no I/O), so it hands the orchestrator the header + the section dir. +// +// This header's chrome (the `## ` title, the badge, the `base.slice(0, 12)`) is +// deliberately kept STRUCTURALLY PARALLEL to lens-debate's renderComment header +// chrome. The no-module workflow runtime has no imports, so a truly shared +// renderer isn't available; the two are instead siblings that move together. A +// house-style change (badge emoji, base-slice length, a new metadata row) is a +// mechanical mirror edit — make it here and in lens-debate's renderComment. If +// the runtime ever admits a shared helper file, lift this common chrome there. +function ledgerHeader(meta) { + const badge = meta.status === 'consensus' ? '✅ **Consensus**' : `⚠️ **${meta.status}**` + return `## Codex ⇄ Claude debate\n\n${badge} after ${meta.rounds} round(s) · codex reviewed at \`${meta.reasoningEffort}\` reasoning effort · base \`${(meta.base || '').slice(0, 12)}\`` +} + +// Per-round section file paths. TWO files per round, named so the `section-*.md` +// glob sorts both into round order AND codex-before-claude WITHIN a round +// (`section-001-1-codex.md` < `section-001-2-claude.md` < `section-002-1-codex.md`), +// so `cat`-ing the glob yields the debate in chronological order for both the +// author's memory read and the comment assembly. Zero-padded for the same reason. +const codexSectionFile = (round) => `${workDir}/section-${String(round).padStart(3, '0')}-1-codex.md` +const claudeSectionFile = (round) => `${workDir}/section-${String(round).padStart(3, '0')}-2-claude.md` + +// Drop a string to a scratch file via a mechanical Haiku writer — the single home +// for the "write this content to this path" idiom (the workflow can't do file I/O +// itself, and Claude isn't headless, so a tiny agent does it). Both the per-round +// codex-section writer and the one-shot rationale writer route through here; +// payloads are small (one round / one note) so Haiku is safe, and overwriting is +// idempotent (safe on a resume). +function writeFileAgent(path, content, label) { + const prompt = `You are a MECHANICAL WRITER. Do exactly these steps and nothing else — do not edit any other file, do not run git, do not add commentary. + +1. Ensure the scratch dir exists: \`mkdir -p ${workDir}\`. +2. Using the Write tool, create \`${path}\` with EXACTLY this content, overwriting any existing file: + +${content}` + return agent(prompt, { label, phase: 'Debate', model: mechModel }) +} + +// Write ONE round's CODEX section to its own small file (the claude section is the +// author's own write, in claudeResponds). The author reads these as cross-round +// memory and the orchestrator cats them into the posted comment. No whole-ledger +// retype: the payload is just this round's structured verdict, rendered. +async function writeCodexSection(round, verdict) { + return writeFileAgent(codexSectionFile(round), codexSection(round, verdict), `ledger:codex:round${round}`) +} + +// VERIFY the author actually wrote its disposition section this round. The author's +// claude section is the load-bearing handoff: it's the next round's rebuttal (codex +// reads it), the author's own cross-round memory, AND part of the posted comment. +// If the author skipped the Write, `lastClaudeSectionPath` would still point at a +// path that doesn't exist — codex-review.sh would warn and proceed with an EMPTY +// rebuttal, and the debate could still converge over a hole in the trail. So after +// every author turn we deterministically check the file exists, is non-empty, AND +// carries a backticked disposition marker for EVERY open finding this round. The +// workflow has no file I/O of its own, so a thin mechanical agent runs `test -s` +// plus an exact `grep -F` per finding id. We do NOT parse prose or score the +// disposition text — only that a `\`Fn\`` token is present, which is an exact, +// bounded check the author prompt already mandates ("one bullet PER finding", +// each id backticked). This closes the hole the file-as-source-of-truth opened: +// a non-empty but INCOMPLETE section (omitting `F2`) used to advance the rebuttal +// pointer and could converge over a per-finding hole in the durable trail. A miss +// — empty file OR any missing finding id — is recorded as a section gap and +// downgrades the terminal status (see below), the same fail-loud-not-silent +// treatment as a missed commit. Codex's own next-round re-review still polices +// the SUBSTANCE of each disposition; this guards only the COMPLETENESS of the +// published per-finding record, which nothing else covers. +async function verifyClaudeSection(round, openIds) { + const path = claudeSectionFile(round) + // Each open finding must appear as a backticked id token (e.g. `F1`) in the + // section. `grep -F` is a literal substring match — no regex/prose brittleness. + const idChecks = openIds + .map((id) => `grep -Fq ${shq(`\`${id}\``)} ${shq(path)} || { echo ${shq(`missing-${id}`)}; ok=0; }`) + .join('; ') + const idList = openIds.length ? openIds.map((id) => `\`${id}\``).join(', ') : '(none)' + const res = await agent( + `You are a MECHANICAL RUNNER. Run exactly this and nothing else, then report:\n\`ok=1; test -s ${shq(path)} || { echo empty; ok=0; }${idChecks ? `; ${idChecks}` : ''}; echo "ok=$ok"\`\nThis checks the section file exists, is non-empty, and contains a backticked marker for every open finding (${idList}). Return \`ok\`: true if the final line was "ok=1", false otherwise (any "empty" or "missing-Fn" line means false). Do not edit any file. Do not run git.`, + { + label: `verify:claude:round${round}`, + phase: 'Debate', + model: mechModel, + schema: { type: 'object', additionalProperties: false, required: ['ok'], properties: { ok: { type: 'boolean', description: 'true when the section file exists, is non-empty, and carries a backticked marker for every open finding' } } }, + }, + ) + return res?.ok === true +} + +const transcript = [] +// 'consensus' is the only NORMAL terminus. 'reviewer-error' is the one abnormal +// terminus: codex itself failed to produce a verdict (broken/unavailable). That +// is infrastructure failure, not a debate outcome, so it ends the loop too — +// distinct from the deliberate "no deadlock exit" for substantive disagreement. +let status = 'consensus' +let finalVerdict = null +// The author's PRIOR-round disposition section file — fed to codex next round as +// the rebuttal (codex-review.sh cats it into codex's prompt). null until the first +// author turn writes one. This replaces the old in-memory rebuttal blob: the author +// writes the file itself, so the dispositions never round-trip through structured +// output, and codex reads them straight off disk. +let lastClaudeSectionPath = null +// Rounds where the author edited files (commit mode on) but returned no SHA — +// the in-session commit it was told to make didn't land. The edits aren't lost +// (they stay in the tree and the next reviewer still diffs them against base), +// but the "one commit per round" contract was broken for that round, so the run +// is NOT a clean consensus: we downgrade the terminal status below rather than +// report success over a missed commit. Not a hard abort: a transient SHA omission +// shouldn't nuke a multi-round debate whose edits are all present in the tree. +const commitGaps = [] +// Rounds where the author's disposition section file is missing, empty, OR missing +// a backticked marker for one or more open findings after its turn — the handoff +// that feeds the rebuttal, the author's memory, and the comment broke. We DON'T +// silently let an empty or per-finding-incomplete rebuttal slip to codex (which +// would warn and proceed, possibly converging over a hole in the trail): a miss is +// recorded here and downgrades the terminal status to 'section-incomplete' below. +// Not a hard abort for the same reason as commitGaps — the tree edits are still +// present and reviewed. +const sectionGaps = [] + +// --------------------------------------------------------------------------- +// The loop — runs until consensus. No round cap, no deadlock exit. +// --------------------------------------------------------------------------- +// The debate continues, round after round, until codex resolves every finding +// (any severity). No upper bound, no "deadlock" surrender: the two sides argue +// every point until one concedes. (The harness's per-workflow agent backstop is +// the only hard ceiling; interrupt via /workflows or TaskStop by hand.) +phase('Debate') + +// Resolve the diff base to the merge-base of (base, HEAD) so codex reviews only +// what THIS branch changed, not commits the base branch gained since the branch +// forked (those would otherwise show up in `git diff base` — master's drift +// reviewed as ours). A thin mechanical git agent; the workflow can't run git +// itself. Idempotent when `base` is already a merge-base SHA (caller resolved it). +const rawBase = base +const baseRes = await agent( + `You are a MECHANICAL RUNNER. Run \`git -C ${repoPath} merge-base ${base} HEAD\` and return ONLY the resulting commit SHA (hex) in \`sha\`. If the command FAILS (missing/typoed base, stale ref, unrelated history), return \`sha\`: "" and put the verbatim git error in \`error\` — do NOT fall back to the raw base ref. Do nothing else.`, + { label: 'resolve:merge-base', phase: 'Debate', model: mechModel, schema: { type: 'object', additionalProperties: false, required: ['sha'], properties: { sha: { type: 'string', description: 'the merge-base SHA, or "" on failure' }, error: { type: 'string', description: 'the git error when sha is empty' } } } }, +) +// Fail loud on a bad base. Falling back to the raw `${base}` tip would review the +// base branch's drift since the fork as if this change made it — the exact noise +// the merge-base removes — so a missing/typoed/stale base must abort, not degrade. +if (!baseRes?.sha?.trim()) { + const err = (baseRes?.error || '').trim() + log(`Aborting: \`git merge-base ${rawBase} HEAD\` failed; the diff scope can't be trusted. Not falling back to the raw ${rawBase} tip.`) + return { + status: 'merge-base-error', + base: rawBase, + rounds: 0, + transcript: [], + finalVerdict: null, + note: `merge-base of \`${rawBase}\` and HEAD could not be resolved (missing/typoed base, stale ref, or unrelated history), so the review scope is untrustworthy. Fix the base ref (e.g. \`git fetch\`) and re-run.${err ? `\ngit error:\n${err}` : ''}`, + } +} +base = baseRes.sha.trim() +log(`Diffing against ${base.slice(0, 12)} (merge-base of ${rawBase} and HEAD), so the base branch's drift since the fork isn't reviewed.`) + +// Clear any stale ledger from a PRIOR debate in this worktree. The scratch dir +// is persistent (per-worktree, not per-run) and the section files use a flat, +// stable `section-NNN-*.md` namespace, so a previous longer debate's high-numbered +// sections would otherwise survive into this run — the author cats `section-*.md` +// as its memory, and the orchestrator cats them into the posted comment, so stale +// sections would pollute BOTH the author's context and the published trail. (The +// glob also catches a prior author's claude section files, which double as the +// rebuttal codex reads, so a stale one must not linger either.) A thin mechanical +// agent (the workflow can't run shell itself). The reset is section/ledger-scoped: +// it deletes only the stale `section-*.md` files, not the whole scratch dir, so +// other artifacts in there (verdict-N.json and any other per-run files) keep their +// own lifecycle and a future pre-loop writer won't be silently wiped. This script +// has no true resume (agent() is one-shot, the whole workflow re-runs from +// scratch), so a fresh start owns a fresh ledger. +await agent( + `You are a MECHANICAL RUNNER. Run exactly this and nothing else: \`mkdir -p -- ${shq(workDir)} && rm -f -- ${shq(workDir)}/section-*.md\`. Do not edit any other file. Do not run git.`, + { label: 'ledger:reset', phase: 'Debate', model: mechModel }, +) + +// Persist the author's rationale ONCE (it's constant across rounds) so +// codex-review.sh can inject it into codex's round-1 prompt; codex's warm session +// then carries the note across later rounds without re-injection. Only when a +// rationale was passed — otherwise rationaleFileArg is `-` and no file is needed. +if (rationale) { + await writeFileAgent(rationaleFile, rationale, 'rationale:write') +} + +for (let round = 1; ; round++) { + const verdict = await codexReviews(round, lastClaudeSectionPath) + finalVerdict = verdict + const entry = { round, codex: verdict, claude: null } + transcript.push(entry) // record this round (mutated in place as it progresses) + + // Write codex's section for this round to disk straight away — before any + // terminal break — so EVERY round (including a consensus-approval or error round + // that never reaches the author) lands in the section record the author reads as + // memory and the orchestrator cats into the comment. The claude section is the + // author's own write later in the round, when there is an author turn. + await writeCodexSection(round, verdict) + + // Reviewer error — terminal failure path. The runner could not get a verdict + // out of codex (broken/unavailable CLI), so codex-review.sh synthesized an + // error verdict carrying reviewerError:true. There are no findings to route to + // Claude, and retrying a broken reviewer just spins forever, so abort the + // debate and surface the failure. This is deliberately separate from the + // "no deadlock exit" rule, which only governs substantive disagreement. + if (verdict.reviewerError) { + status = 'reviewer-error' + log(`Round ${round}: reviewer error — aborting debate. ${verdict.summary}`) + break + } + + const open = openFindings(verdict) + log(`Round ${round}: codex approved=${verdict.approved}, findings open=${open.length}`) + + // Consensus requires BOTH no open finding AND codex's explicit approval. An + // inconsistent verdict — `approved:false` with nothing open — is not consensus: + // codex declined to approve while leaving us nothing to route to Claude, so + // treating it as agreement would ship an unapproved change. There's no finding + // to debate, so re-running codex would just replay the same inconsistency; + // surface it as a reviewer error (the terminal abnormal path) instead of + // looping forever or falsely converging. + if (open.length === 0 && verdict.approved !== true) { + status = 'reviewer-error' + log(`Round ${round}: inconsistent verdict — approved=false with no open findings; aborting as reviewer-error.`) + break + } + + // Consensus: codex approved AND every finding resolved (any severity). + if (open.length === 0) { + break + } + + // Claude responds: fixes what it agrees with (editing the tree), disputes the + // rest, and writes its dispositions to its own claude section file (its memory, + // the rebuttal codex reads next round, and part of the posted comment). It reads + // the per-round section files for its cross-round memory. We point the rebuttal at + // the path it just wrote so the NEXT round feeds it to codex — but only AFTER + // verifying the file actually landed (see verifyClaudeSection below). + const response = await claudeResponds(round, verdict, commit) + entry.claude = response + log( + `Round ${round}: claude done=${response.done}, files=${(response.filesChanged || []).length}`, + ) + + // VERIFY the author wrote its disposition section before we lean on it. The file + // is the next round's rebuttal, the author's memory, and part of the comment — if + // it's missing/empty/incomplete the handoff broke. We require a backticked marker + // for every finding the author had to address this round (`open`), so a non-empty + // but partial section can't slip a per-finding hole into the trail. Only point + // `lastClaudeSectionPath` at it (so codex reads it as the rebuttal next round) once + // it exists AND covers every open id; on a miss record the gap, leave the rebuttal + // pointer where it was (codex sees `-`/the prior round's section rather than an + // incomplete one), and downgrade the terminal status. + if (await verifyClaudeSection(round, open.map((f) => f.id))) { + lastClaudeSectionPath = claudeSectionFile(round) + } else { + sectionGaps.push(round) + log(`Round ${round}: author's disposition section ${claudeSectionFile(round)} is missing, empty, or missing a marker for an open finding — handoff broke; not feeding it to codex as the rebuttal.`) + } + + // The author commits its own round in-session (one commit per round, message + // carrying codex's findings and its dispositions), so here we just record the + // SHA it returned. Only when it actually changed files; flag the inconsistency + // if it reported changes but no commit rather than silently dropping it. + if (commit && (response.filesChanged || []).length > 0) { + entry.commit = (response.commitSha || '').trim() + if (entry.commit) { + log(`Round ${round}: committed ${entry.commit}`) + } else { + // The author edited the tree but didn't return a SHA: its in-session commit + // didn't land. Record the gap so the terminal status reflects it instead of + // reporting a clean consensus over a round that broke the one-commit-per-round + // contract. The edits themselves remain in the tree for the next reviewer. + commitGaps.push(round) + log(`Round ${round}: author changed ${response.filesChanged.length} file(s) but returned no commit SHA — round left uncommitted`) + } + } + // No section write here: codex's section was written at the top of the loop, and + // the author wrote its own claude section during its turn — both already on disk. +} + +const filesChanged = Array.from( + new Set(transcript.flatMap((e) => (e.claude && e.claude.filesChanged) || [])), +) + +// Downgrade a would-be consensus when any round's in-session commit didn't land. +// The debate may have converged (codex approved, nothing open), but with the +// "one commit per round" contract broken we must NOT advertise a clean consensus: +// /be-review keys off this status (and the SKILL's status table) to decide whether +// the step settled cleanly. 'commit-incomplete' is a distinct, non-consensus +// terminus — the edits are all in the tree (the next reviewer diffs them), but a +// human/caller must reconcile the uncommitted round(s). We don't touch a status +// that's already abnormal (reviewer-error), which is strictly more severe. +if (status === 'consensus' && commitGaps.length) { + status = 'commit-incomplete' + log(`Round(s) ${commitGaps.join(', ')} left uncommitted despite changing files — downgrading consensus to commit-incomplete.`) +} + +// Downgrade a would-be consensus when any round's author skipped its disposition +// section file OR left it missing a marker for an open finding. The debate may read +// as converged, but a missing or per-finding-incomplete section is a hole in the +// trail the author, codex (as the rebuttal), and the posted comment all draw on — +// so we must NOT advertise a clean consensus. 'section-incomplete' is a distinct, +// non-consensus terminus: a human/caller must fill in the missing round(s) before +// trusting the per-round record. We don't override an already-abnormal status +// (reviewer-error, or commit-incomplete which is reported the same converged-but- +// -not-clean way) — the first downgrade already marks the run unclean. +if (status === 'consensus' && sectionGaps.length) { + status = 'section-incomplete' + log(`Round(s) ${sectionGaps.join(', ')} are missing the author's disposition section or a finding marker — downgrading consensus to section-incomplete.`) +} + +log(`Debate ended: ${status} after ${transcript.length} round(s); ${filesChanged.length} file(s) changed.`) + +// The terminal round needs no extra section write: codex's section for it was +// written at the top of the loop (every round), and a terminal round has no author +// turn (and so no claude section) by definition. + +// Hand the orchestrator everything it needs to post the comment, but NOT a single +// pre-rendered `comment` string — the author's per-round dispositions live in the +// section files on disk (it wrote them itself), and the workflow can't read files. +// So we return the small in-process `commentHeader` plus the section dir + glob; the +// orchestrator assembles the comment with a faithful `cat` (header followed by the +// section files in glob order) and posts that — a deterministic shell concat, no +// agent ever retyping the ledger. See SKILL step 3. +return { + status, + rounds: transcript.length, + base, + finalVerdict, + filesChanged, + // Rounds whose author-side commit didn't land (empty unless status is + // 'commit-incomplete'). Lets the caller pinpoint and reconcile the gap. + commitGaps, + // Rounds whose author-side disposition section file is missing/empty (empty unless + // status is 'section-incomplete'). Lets the caller pinpoint the hole in the trail. + sectionGaps, + transcript, + // The comment's deterministic header (badge + round count + reasoning effort + + // base). The orchestrator posts: this header, a blank line, then + // `cat <workDir>/section-*.md`. + commentHeader: ledgerHeader({ status, rounds: transcript.length, base, reasoningEffort: REASONING_EFFORT }), + // Where the per-round section files live, so the orchestrator can cat them. + workDir, + sectionGlob: `${workDir}/section-*.md`, +} diff --git a/.claude/skills/codex-debate/scripts/codex-answer.schema.json b/.claude/skills/codex-debate/scripts/codex-answer.schema.json new file mode 100644 index 000000000..55e44f84e --- /dev/null +++ b/.claude/skills/codex-debate/scripts/codex-answer.schema.json @@ -0,0 +1,49 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "answer": { + "type": "string", + "description": "Your complete, self-contained answer to the user's prompt as it stands THIS round. On a cross-check round this is your UPDATED unified answer, revised in light of the other assistant's answer." + }, + "keyPoints": { + "type": "array", + "items": { "type": "string" }, + "description": "The core claims your answer rests on, one per item — the things the other side must agree with for there to be consensus." + }, + "agreesWithOther": { + "type": "boolean", + "description": "true ONLY when the other assistant's latest answer is correct and complete and you have NO substantive disagreement left — your answers say the same thing. false on the first round (you haven't seen theirs yet) and whenever any objection below remains." + }, + "objections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "point": { + "type": "string", + "description": "The specific claim in the OTHER assistant's answer you disagree with, or the gap you think it leaves." + }, + "reason": { + "type": "string", + "description": "Why it's wrong or incomplete — cite file:line or concrete evidence when the prompt is about this repo." + } + }, + "required": ["point", "reason"] + }, + "description": "Your remaining disagreements with the other assistant's latest answer. Empty when you fully agree (agreesWithOther:true) and on round 1." + }, + "changedMind": { + "type": "string", + "description": "What you revised this round because the other assistant convinced you, and why. Empty string on round 1 or when nothing changed." + } + }, + "required": [ + "answer", + "keyPoints", + "agreesWithOther", + "objections", + "changedMind" + ] +} diff --git a/.claude/skills/codex-debate/scripts/codex-answer.sh b/.claude/skills/codex-debate/scripts/codex-answer.sh new file mode 100755 index 000000000..e881bd881 --- /dev/null +++ b/.claude/skills/codex-debate/scripts/codex-answer.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# codex-answer.sh — the canonical, deterministic codex invocation for the +# SYMMETRIC answer-debate (the `answer` mode of /codex-debate). codex answers a +# freeform user prompt as one of two equal debaters; on follow-up rounds it +# CROSS-CHECKS the other assistant's ("CLAUDE") latest answer against its own and +# either concedes (revising its answer) or holds firm (recording objections), +# looping until both sides agree. codex runs as a READ-ONLY peer — it may read +# this repo to ground its answer (git diff/log, read files, grep) but cannot +# modify anything. The output is constrained to codex-answer.schema.json and +# written to <out-json>. +# +# This script owns only what is SPECIFIC to answering a prompt: arg parsing, the +# warm/cold prompt text, the answer schema + session file, and the answer-shaped +# error verdict. The shared codex-driving core (read-only exec/resume, retry/ +# backoff, thread-id capture, session persistence) lives in codex-exec-lib.sh. +# +# Usage: +# codex-answer.sh <prompt-file> <crosscheck-file|-> <out-json> [reasoning-effort] [confirm] +# +# <prompt-file> path to a file holding the user's prompt/question +# <crosscheck-file> path to a file holding CLAUDE's latest answer (JSON) for +# codex to cross-check, or "-" on the first round (codex +# hasn't seen CLAUDE's answer yet — it answers independently). +# On a CONFIRM turn this file instead holds the synthesized +# unified CANDIDATE answer codex is asked to approve verbatim. +# <out-json> path the JSON answer is written to (also echoed to stdout) +# <reasoning-effort> codex model_reasoning_effort for this run; the answer +# workflow passes its REASONING_EFFORT constant here so the +# value has one home. Defaults to "xhigh" for standalone runs. +# [confirm] the literal token "confirm" selects the CONFIRM prompt shape: +# codex judges ONE shared synthesized candidate (held in the +# crosscheck-file) and approves it VERBATIM or objects — it does +# NOT rewrite its own answer. Mirrors the workflow's +# claudeConfirms turn so both peers plug into one confirm contract. +# +# Notes: +# * codex runs under `--sandbox read-only` (see codex-exec-lib.sh), which enforces +# read-only at the kernel boundary, NOT merely by prompt text. codex reads +# arbitrary repo files to ground its answer and could be prompt-injected by file +# contents, so the read-only promise must be enforced, not advertised. +# * Always emits a schema-valid answer on stdout, even if codex errors — a +# synthesized error answer (reviewerError:true) so the loop never wedges. +# * WARM SESSION: round 1 cold-starts codex; every later round resumes that same +# session so codex retains its OWN prior answer + reasoning across rounds. +set -uo pipefail + +prompt_file="${1:?usage: codex-answer.sh <prompt-file> <crosscheck-file|-> <out-json> [reasoning-effort] [confirm]}" +crosscheck_file="${2:?missing crosscheck-file (use - for none)}" +out="${3:?missing out-json path}" +# The answer workflow owns this value (its REASONING_EFFORT constant) and passes +# it down; "xhigh" is only the default for a standalone invocation of this script. +effort="${4:-xhigh}" +# CONFIRM mode: the 5th arg is the literal "confirm" when codex is judging ONE +# synthesized candidate (held in crosscheck_file) rather than cross-checking +# CLAUDE's separate answer. This swaps in the confirm prompt shape below — a +# verbatim/approve-or-object contract symmetric to the workflow's claudeConfirms. +is_confirm= +[ "${5:-}" = "confirm" ] && is_confirm=1 + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The codex-FACING output schema. Do NOT add a `reviewerError` property to it: +# codex's `--output-schema` (OpenAI structured outputs) requires every declared +# property to be in `required` with additionalProperties:false, so an optional +# `reviewerError` 400s the request. codex never emits reviewerError; the error +# answer synthesize_error_verdict writes carries it but is validated by the +# workflow's in-JS ANSWER_SCHEMA, not by this file. (This has been re-added in +# error twice — review mode's codex-verdict.schema.json omits it for the same reason.) +schema="$here/codex-answer.schema.json" +# shellcheck source=codex-exec-lib.sh +source "$here/codex-exec-lib.sh" + +# The user's prompt. Required and must be non-empty — an empty prompt would make +# codex answer nothing and silently degrade the debate, so fail loud instead. +if [ ! -s "$prompt_file" ]; then + echo "ERROR: prompt file '$prompt_file' is missing or empty." >&2 + exit 2 +fi +prompt_text="$(cat "$prompt_file")" + +# Pull CLAUDE's latest answer, if any (the cross-check input). Built as a plain +# string and injected below via a simple variable reference so any special +# characters in the JSON (backticks, $, ...) stay literal. +crosscheck="" +if [ "$crosscheck_file" != "-" ]; then + if [ -s "$crosscheck_file" ]; then + crosscheck="$(cat "$crosscheck_file")" + else + # A cross-check was expected (path given, not "-") but the file is missing or + # empty — the handoff broke. Proceed without it, but make the failure loud so + # codex's cross-check isn't silently skipped. + echo "WARNING: expected cross-check file '$crosscheck_file' is missing or empty; proceeding with no cross-check this round." >&2 + fi +fi + +# WARM SESSION. Round 1 (crosscheck_file == "-") cold-starts and resets any stale +# id; later rounds resume codex's own answer session. Resolve the id first so the +# prompt below can lean on codex's retained context when warm. +session_id_file="$(dirname "$out")/codex-answer-session.id" +[ "$crosscheck_file" = "-" ] && is_round1=1 || is_round1= +resume_id="$(codex_resolve_session "$session_id_file" "$is_round1")" + +# Synthesize codex-answer's error verdict shape when codex produces nothing after +# every attempt (called by codex_exec_round). reviewerError:true is the signal the +# workflow aborts the debate on. +synthesize_error_verdict() { + local out="$1" tail_log="$2" attempts="$3" + jq -n --arg log "$tail_log" --arg attempts "$attempts" '{ + answer: ("codex produced no answer this round after " + $attempts + " attempt(s). Tail of log: " + $log), + keyPoints: [], + agreesWithOther: false, + objections: [], + changedMind: "", + reviewerError: true + }' >"$out" +} + +# Whether this is a cross-check round: a cross-check file was provided (not "-") AND +# it actually held CLAUDE's answer. A follow-up round MUST cross-check even when the +# warm session id is missing — dropping the cross-check would tell codex to answer +# independently again (agreesWithOther:false, no objections) and the debate could +# never converge. So the cross-check, not the resume id, gates which prompt we use. +is_crosscheck= +[ -n "$crosscheck" ] && is_crosscheck=1 + +# A reusable block carrying CLAUDE's latest answer + the cross-check instructions, +# spliced into BOTH the warm and cold follow-up prompts (mirrors codex-review.sh's +# $rebuttal_block) so a missing resume id degrades to a COLD CROSS-CHECK, never to a +# fresh independent answer. Empty on round 1. +crosscheck_block="" +if [ -n "$is_crosscheck" ]; then + crosscheck_block="$(cat <<EOF + +CLAUDE's LATEST answer (JSON) is: +$crosscheck + +Cross-check CLAUDE's answer against your own. Then: + - Where CLAUDE is right and you were wrong or incomplete, UPDATE your answer to + match and note what you changed in changedMind. + - Where CLAUDE is wrong or has a gap, keep your position and record it under + objections with a specific, evidence-backed reason (cite file:line for repo + questions). +EOF +)" +fi + +# Prompt shapes, chosen by (confirm × resume id × cross-check): +# * CONFIRM (confirm token): codex judges ONE synthesized candidate +# (in $crosscheck) and approves it VERBATIM or objects — it does NOT rewrite its +# own answer. One contract symmetric to the workflow's claudeConfirms turn, so +# both peers plug into the same approve-a-fixed-candidate interface. Takes +# precedence over warm/cold below (the session is warm here, but the activity is +# approval, not cross-check). +# * WARM follow-up (resume id + cross-check): lean prompt leaning on codex's +# retained answer. +# * COLD follow-up (no resume id + cross-check): the full answer prompt PLUS the +# cross-check block — codex re-derives its own answer from scratch but still +# reconciles against CLAUDE's, so the debate keeps converging. +# * COLD first round (no cross-check): the independent answer prompt. +# Unquoted heredocs: only $prompt_text, $crosscheck, and $crosscheck_block expand; +# their expansions are inserted literally (heredoc results aren't re-scanned), so +# special chars stay inert. +if [ -n "$is_confirm" ]; then + prompt="$(cat <<EOF +You are CODEX. You and another assistant ("CLAUDE") were each asked the SAME +question, debated, and AGREED. A unified candidate answer has been synthesized from +your two agreed answers. Your ONLY job now is to APPROVE it or object — do NOT +rewrite it, do NOT produce a new answer of your own. + +You may inspect this repository to verify (READ-ONLY — read files, run git +diff/log/grep; do NOT modify, create, or delete anything, and run no git write +command: add/commit/push/stash/checkout). Cite file:line for repo claims. + +The original question was: +$prompt_text + +The candidate unified answer to approve is: +$crosscheck + +Return the JSON schema: + - answer: echo the candidate VERBATIM (you are approving it, not rewriting it). + - keyPoints: the core claims the candidate rests on. + - objections: anything the candidate gets wrong, drops, or overstates relative to + what you agreed — empty if you approve it as-is. Be specific (file:line for repo + claims). + - changedMind: empty (you are confirming, not revising). + - agreesWithOther: true ONLY if you approve the candidate as a correct, complete + unified answer with NO objection left. +EOF +)" +elif [ -n "$resume_id" ] && [ -n "$is_crosscheck" ]; then + prompt="$(cat <<EOF +You are CODEX, continuing the SAME answer session you started earlier — you still +have your own previous answer and reasoning in context. You and another assistant +("CLAUDE") were each asked the SAME question and are now cross-checking each other +to reach ONE agreed answer. + +The original question was: +$prompt_text + +Cross-check CLAUDE's answer against your own (READ-ONLY — you may read repo files, +git diff/log, grep to verify, but do NOT modify, create, or delete anything, and +run no git write command: add/commit/push/stash/checkout). +$crosscheck_block +Return the JSON schema: + - answer: your UPDATED, self-contained unified answer as it stands now. + - keyPoints: the core claims your answer rests on. + - objections: your remaining disagreements with CLAUDE's latest answer (empty + when you fully agree). + - changedMind: what CLAUDE convinced you to change this round (empty if nothing). + - agreesWithOther: true ONLY when CLAUDE's latest answer is correct and complete + and you have NO objection left — your two answers say the same thing. +EOF +)" +else + prompt="$(cat <<EOF +You are CODEX, a rigorous, truthful expert. Answer the user's question below +thoroughly and honestly — exactly as you would for a careful colleague. You are in +a debate with another assistant ("CLAUDE") who is answering the SAME question +independently; you cross-check each other until you agree, so give your best, most +defensible answer. + +You may inspect this repository to ground your answer (READ-ONLY — read files, run +git diff/log/grep; do NOT modify, create, or delete anything, and run no git write +command: add/commit/push/stash/checkout). Cite file:line for claims about this +codebase. If the question isn't about this repo, answer from your own knowledge. + +The question: +$prompt_text +$crosscheck_block +Return the JSON schema: + - answer: your complete, self-contained answer (on a cross-check round, your + UPDATED unified answer revised in light of CLAUDE's). + - keyPoints: the core claims your answer rests on, one per item. + - objections: your remaining disagreements with CLAUDE's latest answer — empty + when you fully agree, and empty on the first round (no cross-check yet). + - changedMind: what CLAUDE convinced you to change this round (empty if nothing, + and empty on the first round). + - agreesWithOther: true ONLY when CLAUDE's latest answer is correct and complete + and you have NO objection left. false on the first round (no cross-check yet). +EOF +)" +fi + +# Drive codex for this round (retry/backoff, thread capture, error fallback) — the +# shared core does the work; this script supplied the prompt, schema, and shapes. +codex_exec_round "$schema" "$out" "$session_id_file" "$effort" "$resume_id" "$prompt" diff --git a/.claude/skills/codex-debate/scripts/codex-exec-lib.sh b/.claude/skills/codex-debate/scripts/codex-exec-lib.sh new file mode 100644 index 000000000..943b466b2 --- /dev/null +++ b/.claude/skills/codex-debate/scripts/codex-exec-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# +# codex-exec-lib.sh — the shared, SOURCED core of the codex⇄claude debate scripts. +# +# Both modes of /codex-debate drive codex the same way; only WHAT they ask and the +# verdict SHAPE differ. This file is the single home for the part that is identical +# (and the part most volatile to codex CLI changes): driving codex headless and +# READ-ONLY, resuming its warm session, retrying transient failures with backoff, +# capturing the thread id, and — when codex produces nothing after every attempt — +# synthesizing a schema-valid error verdict so the debate loop never wedges. +# +# It is `source`d, not executed. The two callers (codex-review.sh, codex-answer.sh) +# own the parts that DIFFER: parsing their own args, building the prompt, choosing +# the schema + per-mode session-id file, and defining the verdict shape. +# +# Contract — a caller must: +# 1. source this file; +# 2. define a function synthesize_error_verdict <out> <tail_log> <attempts> +# that writes its own schema-valid error verdict (reviewerError:true) to <out>; +# 3. resolve the warm-session resume id with codex_resolve_session <id-file> <round1?> +# (so it can pick the warm vs cold prompt), then build $prompt; +# 4. run one round with codex_exec_round <schema> <out> <id-file> <effort> <resume-id> <prompt>. +# +# Tunables (shared by both modes; names kept for back-compat): +# CODEX_REVIEW_RETRIES total attempts per round (default 3) +# CODEX_REVIEW_BACKOFF base seconds; attempt n waits n*base (default 5) + +# Resolve the warm-session resume id for this round, and reset it on round 1. +# +# * Round 1 (is_round1 == "1"): start a NEW session — drop any id left behind by +# a previous debate in this worktree so we never resume a stale one. Echoes "". +# * Later rounds: echo the persisted id (empty if none was captured — the caller +# then cleanly cold-starts with the full prompt, never a wedge). +# +# Echoing (rather than setting a global) keeps the caller explicit: it captures the +# id, uses it to choose the warm vs cold prompt, and passes it back to +# codex_exec_round. Usage: resume_id="$(codex_resolve_session "$id_file" "$round1")" +codex_resolve_session() { + local session_id_file="$1" is_round1="$2" + if [ "$is_round1" = "1" ]; then + rm -f "$session_id_file" + return 0 + fi + if [ -s "$session_id_file" ]; then + cat "$session_id_file" + fi +} + +# One codex invocation: warm-resume when we have a session id (carries codex's own +# prior turn), else a cold start. `--json` emits a `thread.started` event carrying +# codex's thread_id (captured by codex_exec_round to resume next round); it does NOT +# change the verdict, which `--output-schema`/`-o` still write to "$out". `resume` +# has no `--sandbox` flag, so read-only is enforced there via `-c sandbox_mode` — +# the same kernel-enforced policy, set through config instead of the flag. +# +# Reads $resume_id, $schema, $out, $effort, $prompt from the enclosing +# codex_exec_round via bash's dynamic scope (they're `local` there). +_codex_run_once() { + if [ -n "$resume_id" ]; then + codex exec resume \ + -c sandbox_mode="read-only" \ + -c model_reasoning_effort="$effort" \ + --json \ + --output-schema "$schema" \ + -o "$out" \ + "$resume_id" "$prompt" + else + codex exec \ + --sandbox read-only \ + -c model_reasoning_effort="$effort" \ + --json \ + --output-schema "$schema" \ + -o "$out" \ + "$prompt" + fi +} + +# Run ONE debate round against codex and leave a schema-valid verdict in <out> +# (also echoed to stdout). Owns the out/log lifecycle, the retry/backoff loop, the +# thread-id capture, and the error-verdict fallback. +# +# codex_exec_round <schema> <out> <session_id_file> <effort> <resume_id> <prompt> +# +# model_reasoning_effort is scoped to the debate here (via -c, from <effort>) rather +# than the user's global ~/.codex/config.toml — review/answer is the one place we +# always want codex thinking at full depth, regardless of their default. +# +# RETRY/BACKOFF. codex's CLI fails transiently often enough to matter (API hiccups, +# a spurious internal error) and writes no verdict — which would otherwise degrade +# the round to reviewer-error on a single bad roll. Retry with linear backoff, +# accepting the first attempt that writes a non-empty verdict to <out>. Only after +# every attempt fails empty do we synthesize the reviewerError verdict (via the +# caller's synthesize_error_verdict hook). +codex_exec_round() { + local schema="$1" out="$2" session_id_file="$3" effort="$4" resume_id="$5" prompt="$6" + local log="$out.log" + + # The out path lives under a per-worktree scratch dir (.codex-debate/); make sure + # it exists before codex tries to write the verdict there. + mkdir -p "$(dirname "$out")" + + local attempts="${CODEX_REVIEW_RETRIES:-3}" + local backoff="${CODEX_REVIEW_BACKOFF:-5}" + # Validate both as positive integers. Left unchecked, a non-numeric value makes the + # arithmetic test error every iteration, so the loop would spin forever instead of + # giving up. Fall back to the documented defaults (and clamp attempts to >=1) + # loudly rather than wedge the headless debate on a typo'd override. + if ! [[ "$attempts" =~ ^[0-9]+$ ]] || [ "$attempts" -lt 1 ]; then + echo "WARNING: CODEX_REVIEW_RETRIES='$attempts' is not a positive integer; using 3." >&2 + attempts=3 + fi + if ! [[ "$backoff" =~ ^[0-9]+$ ]]; then + echo "WARNING: CODEX_REVIEW_BACKOFF='$backoff' is not a non-negative integer; using 5." >&2 + backoff=5 + fi + + local n=1 wait_s + : >"$log" # start each round fresh; attempts below APPEND so no failure's diagnostics are lost + while :; do + rm -f "$out" + # Append (not truncate): when every attempt fails, the synthesized error + # verdict's tail_log must reflect ALL attempts' diagnostics, not just the last. + echo "=== attempt $n/$attempts ===" >>"$log" + if ! _codex_run_once </dev/null >>"$log" 2>&1; then + echo "codex exec exited non-zero (attempt $n/$attempts; see $log)" >&2 + fi + # Success the moment codex writes a verdict: the kernel sandbox + --output-schema + # make a non-empty "$out" a real, schema-valid verdict, not a partial. + [ -s "$out" ] && break + # Out of attempts — fall through to the synthesized error verdict. + [ "$n" -ge "$attempts" ] && break + wait_s=$(( backoff * n )) + echo "codex produced no verdict (attempt $n/$attempts); retrying in ${wait_s}s..." >&2 + n=$(( n + 1 )) + sleep "$wait_s" + done + + if [ -s "$out" ]; then + # Persist codex's session id so the NEXT round can resume this same warm session + # (carrying codex's own prior turn). The successful attempt's `thread.started` + # is the last one appended to the log; on a resume round it echoes the same id, + # so overwriting is a harmless refresh. Failure to capture an id just means next + # round cold-starts via the caller's fallback — not fatal. + local sid + # Extract the LAST thread_id in the log (one awk, no grep|tail|cut pipeline). + # Splitting on '"', the value sits two fields AFTER the "thread_id" key field + # (key, then ":", then value) — NOT a fixed column, since other quoted keys + # (e.g. "type":"thread.started") precede it on the same JSON event line. + sid="$(awk -F'"' '{for (i = 1; i < NF; i++) if ($i == "thread_id") sid = $(i + 2)} END {print sid}' "$log")" + if [ -n "$sid" ]; then + printf '%s\n' "$sid" >"$session_id_file" + fi + fi + + if [ ! -s "$out" ]; then + # codex produced no verdict — hand off to the caller's shape-specific synthesizer + # so the debate loop can surface the failure instead of hanging. reviewerError is + # the machine-detectable signal the workflow aborts on: a broken/unavailable codex + # is INFRASTRUCTURE failure, not substantive disagreement, so it must NOT spin the + # loop forever. + local tail_log + tail_log="$(tail -c 2000 "$log" 2>/dev/null || true)" + synthesize_error_verdict "$out" "$tail_log" "$attempts" + fi + + cat "$out" +} diff --git a/.claude/skills/codex-debate/scripts/codex-review.sh b/.claude/skills/codex-debate/scripts/codex-review.sh new file mode 100755 index 000000000..597d09579 --- /dev/null +++ b/.claude/skills/codex-debate/scripts/codex-review.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# +# codex-review.sh — the canonical, deterministic codex invocation for the +# codex<->claude review debate (the `review` mode of /codex-debate). Runs the +# codex CLI as a READ-ONLY reviewer of the current working-tree state against a +# base branch, constrained to codex-verdict.schema.json, and writes the JSON +# verdict to <out-json>. +# +# This script owns only what is SPECIFIC to reviewing a diff: arg parsing, the +# warm/cold review prompt text, the verdict schema + session file, and the +# verdict-shaped error fallback. The shared codex-driving core (read-only exec/ +# resume, retry/backoff, thread-id capture, session persistence) lives in +# codex-exec-lib.sh. +# +# Usage: +# codex-review.sh <base-branch> <rebuttal-file|-> <out-json> [reasoning-effort] [rationale-file|-] +# +# <base-branch> branch to diff against (e.g. master) +# <rebuttal-file> path to a file holding CLAUDE's previous-round response — the +# author-written Markdown disposition section (its per-finding +# fixed/disputed/partial trail), NOT JSON. "-" on the first round +# (no rebuttal yet). Cat'd verbatim into codex's prompt below. +# <out-json> path the JSON verdict is written to (also echoed to stdout) +# <reasoning-effort> codex model_reasoning_effort for this run; the debate +# workflow passes its REASONING_EFFORT constant here so the +# value has one home. Defaults to "xhigh" for standalone runs. +# <rationale-file> path to a file holding the author's note on DELIBERATE +# decisions, or "-" for none. Injected into the round-1 (cold) +# review prompt so codex doesn't flag intentional choices as +# defects; codex's warm session carries it across later rounds. +# +# Notes: +# * codex runs under `--sandbox read-only` (see codex-exec-lib.sh), which enforces +# read-only at the kernel boundary (file writes and other state-mutating +# syscalls denied), NOT merely by prompt text. codex reviews arbitrary diffs and +# could be prompt-injected by file contents, so the read-only promise must be +# enforced, not advertised. +# * Always emits a schema-valid verdict on stdout, even if codex errors — a +# synthesized error verdict (approved:false) so the loop never wedges. +# * WARM SESSION: round 1 cold-starts codex and records its session id; every later +# round resumes that same session (`codex exec resume <id>`) so codex retains its +# OWN prior review + reasoning across rounds. +set -uo pipefail + +base="${1:?usage: codex-review.sh <base-branch> <rebuttal-file|-> <out-json> [reasoning-effort] [rationale-file|-]}" +rebuttal_file="${2:?missing rebuttal-file (use - for none)}" +out="${3:?missing out-json path}" +# The debate workflow owns this value (its REASONING_EFFORT constant) and passes +# it down; "xhigh" is only the default for a standalone invocation of this script. +effort="${4:-xhigh}" +# Author's note on deliberate decisions (constant across rounds); "-" = none. Only +# the cold/round-1 prompt injects it — codex's warm session retains it after that. +rationale_file="${5:--}" + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +schema="$here/codex-verdict.schema.json" +# shellcheck source=codex-exec-lib.sh +source "$here/codex-exec-lib.sh" + +# Pull CLAUDE's previous-round response (the author's Markdown disposition section), +# if any. Built as a plain string and injected below via a simple variable reference +# so any special characters in the text (backticks, $, ...) stay literal (heredoc +# expansion results are not re-scanned). +rebuttal="" +if [ "$rebuttal_file" != "-" ]; then + if [ -s "$rebuttal_file" ]; then + rebuttal="$(cat "$rebuttal_file")" + else + # A rebuttal was expected (path given, not "-") but the file is missing or + # empty — the handoff broke. Proceed without it, but make the failure loud + # so codex's responseToRebuttal isn't silently empty. + echo "WARNING: expected rebuttal file '$rebuttal_file' is missing or empty; proceeding with no rebuttal this round." >&2 + fi +fi + +rebuttal_block="" +if [ -n "$rebuttal" ]; then + rebuttal_block=" +This is a FOLLOW-UP round — you already gave your full review. Your job now is to +CLOSE OUT the findings already on the table, not re-scan the whole diff for more. +For each existing finding: verify CLAUDE's fix and mark it resolved, or address +CLAUDE's dispute (concede and mark resolved, or hold firm with specific reasoning +in responseToRebuttal). Raise a NEW finding ONLY if CLAUDE's changes this round +introduced it (a regression). Do NOT keep surfacing pre-existing issues you didn't +raise in round 1 — that prevents the debate from ever converging. + +CLAUDE responded to your PREVIOUS review as follows: +$rebuttal +" +fi + +# The author's note on DELIBERATE decisions, if supplied — injected into the COLD +# review prompt below so codex doesn't raise intentional choices as findings. Read +# from a file (the workflow writes it once) so multi-line notes with special chars +# survive intact, the same way the rebuttal is handled. +rationale="" +if [ "$rationale_file" != "-" ]; then + if [ -s "$rationale_file" ]; then + rationale="$(cat "$rationale_file")" + else + # A rationale was expected (path given, not "-") but the file is missing or + # empty — the rationale:write handoff broke. Proceed without it (a missing + # rationale degrades to a bare-diff review the IMPLEMENTOR still disputes from + # its own inherited rationale block — it is a false-finding SUPPRESSOR, not a + # correctness input, so we don't abort the round), but make the failure loud + # so the round isn't silently mistaken for a rationale-aware review. Mirrors + # the rebuttal warning above. + echo "WARNING: expected rationale file '$rationale_file' is missing or empty; proceeding with no deliberate-decisions note this round (codex reviews the bare diff)." >&2 + fi +fi +rationale_block="" +if [ -n "$rationale" ]; then + rationale_block=" +The author flagged the following as DELIBERATE decisions. Do NOT raise them as +findings unless the reasoning itself is wrong — if it is, say specifically why: +$rationale +" +fi + +# WARM SESSION. Round 1 (rebuttal_file == "-") cold-starts and resets any stale id; +# later rounds resume codex's own review session. Resolve the id first so the prompt +# below can lean on codex's retained context when warm. +session_id_file="$(dirname "$out")/codex-session.id" +[ "$rebuttal_file" = "-" ] && is_round1=1 || is_round1= +resume_id="$(codex_resolve_session "$session_id_file" "$is_round1")" + +# Synthesize codex-review's error verdict shape when codex produces nothing after +# every attempt (called by codex_exec_round). reviewerError:true is the signal the +# workflow aborts the debate on. +synthesize_error_verdict() { + local out="$1" tail_log="$2" attempts="$3" + jq -n --arg log "$tail_log" --arg attempts "$attempts" '{ + approved: false, + summary: ("codex produced no verdict this round after " + $attempts + " attempt(s). Tail of log: " + $log), + findings: [], + responseToRebuttal: "", + reviewerError: true + }' >"$out" +} + +# Two prompts: a lean follow-up for the WARM (resume) path that leans on codex's +# retained context, and the full review prompt for the COLD path (round 1, or the +# fallback when no session id was captured). Unquoted heredocs: only $base, +# $rebuttal, $rebuttal_block, and (in the cold prompt) $rationale_block expand; +# their expansions are inserted literally (heredoc results aren't re-scanned), so +# special chars in $rebuttal / $rationale stay inert. The rationale rides the cold +# prompt ONLY — codex's warm session already retains it from round 1. +if [ -n "$resume_id" ]; then + prompt="$(cat <<EOF +You are CODEX, continuing the SAME review session you started earlier — you still +have your own previous review and reasoning in context. The author ("CLAUDE") has +now responded to that review and changed the working tree. + +The tree changed since your last turn, so re-inspect the CURRENT state (READ-ONLY — +do not modify, create, or delete anything, and run no git write command: +add/commit/push/stash/checkout): + + git diff $base (committed + unstaged changes on this branch) + git status --short (untracked/new files — read those too; they aren't in the diff) + +Ignore the debate's own scratch dir '.codex-debate/' if it appears. + +CLAUDE responded to your previous review as follows: +$rebuttal + +CLOSE OUT the findings already on the table — do NOT re-scan the whole diff for new +pre-existing issues you didn't raise before (that prevents the debate from ever +converging). For each existing finding (reuse its stable id): verify CLAUDE's fix +and mark it resolved, or address CLAUDE's dispute — concede (mark it resolved) or +hold firm with specific technical reasoning in responseToRebuttal. Raise a NEW +finding ONLY if CLAUDE's changes THIS round introduced it (a regression). + +EXCEPTION to "hold firm": if CLAUDE shows a finding is NOT a code edit for THIS +worktree but a downstream / ship-phase / process gate (a companion repo pinning this +repo's final post-review HEAD, a CI/release step, a cross-repo PR), mark it RESOLVED — +acknowledged and DEFERRED to the ship phase. You cannot satisfy a ship-phase gate +mid-review, and holding it open deadlocks the debate forever; the review converges on +the CODE. This is ONLY for a genuine non-code/process gate, NEVER a code change CLAUDE +would simply rather not make — those you still hold firm on. + +Return your updated review in the JSON schema: + - findings: one entry per issue, each with severity and the stable id you used + before. status=resolved once addressed (CLAUDE fixed it, OR you accept CLAUDE's + reasoning); else open. + - approved: true ONLY when EVERY finding is resolved, at every severity. + - responseToRebuttal: address each of CLAUDE's disputes individually — concede or + hold firm with specific, technical reasoning. Leave no dispute unanswered. +EOF +)" +else + prompt="$(cat <<EOF +You are CODEX, a rigorous senior code reviewer. Review the changes in this branch +and give your honest, thorough feedback — exactly as you would on a serious PR. +You're in a debate with the author ("CLAUDE"), who will fix what they agree with +and push back, with reasons, on what they don't. + +Inspect the change yourself (READ-ONLY — do not modify, create, or delete anything, +and run no git write command: add/commit/push/stash/checkout): + + git diff $base (committed + unstaged changes on this branch) + git status --short (untracked/new files — read those too; they aren't in the diff) + +Read every changed file plus enough surrounding code to judge it in context. +Ignore the debate's own scratch dir '.codex-debate/' if it appears. +$rationale_block +Give ALL your feedback in this pass — every issue worth raising, at EVERY severity +(blocking, major, minor, nit): correctness bugs, logic errors, silently swallowed +errors, unjustified fallbacks, security problems, and clear simplicity/efficiency +issues. Don't hold issues back for a later round, and don't limit yourself to +blockers — surface everything you see now. Cite file:line. (If the change is +genuinely clean, approving with no findings is fine — just never stay quiet about +a real issue to seem agreeable.) +$rebuttal_block +Return your review in the JSON schema: + - findings: one entry per issue, each with a severity and a stable id (F1, F2, …) + reused across rounds for the same issue. Set status=resolved once it is + adequately addressed (CLAUDE fixed it, OR you accept CLAUDE's reasoning); else open. + - approved: true ONLY when EVERY finding is resolved — all your feedback addressed + at every severity, not just blockers. The review is not done while any issue you + raised still stands open. + - responseToRebuttal: when CLAUDE disputes a finding, address each dispute + individually — concede (mark that finding resolved) or hold firm with specific, + technical reasoning. Leave no dispute unanswered. Empty on round 1. + +One EXCEPTION to "hold firm": if CLAUDE shows a finding is NOT a code edit for THIS +worktree but a downstream / ship-phase / process gate (a companion repo pinning this +repo's final post-review HEAD, a CI/release step, a cross-repo PR), mark it RESOLVED — +acknowledged and DEFERRED to the ship phase. You cannot satisfy a ship-phase gate +mid-review, and holding it open deadlocks the debate forever; the review converges on +the CODE. This is ONLY for a genuine non-code/process gate, NEVER a code change CLAUDE +would simply rather not make — those you still hold firm on. +EOF +)" +fi + +# Drive codex for this round (retry/backoff, thread capture, error fallback) — the +# shared core does the work; this script supplied the prompt, schema, and shapes. +codex_exec_round "$schema" "$out" "$session_id_file" "$effort" "$resume_id" "$prompt" diff --git a/.claude/skills/codex-debate/scripts/codex-verdict.schema.json b/.claude/skills/codex-debate/scripts/codex-verdict.schema.json new file mode 100644 index 000000000..7703af22e --- /dev/null +++ b/.claude/skills/codex-debate/scripts/codex-verdict.schema.json @@ -0,0 +1,61 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "approved": { + "type": "boolean", + "description": "true ONLY when every finding is resolved — all your feedback addressed at any severity (minor and nit included), not just blockers." + }, + "summary": { + "type": "string", + "description": "One-paragraph assessment of the change as it currently stands." + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable identifier reused across rounds for the same issue, e.g. F1, F2." + }, + "severity": { + "type": "string", + "enum": ["blocking", "major", "minor", "nit"] + }, + "location": { + "type": "string", + "description": "file:line (or file) the finding applies to." + }, + "issue": { + "type": "string", + "description": "What is wrong and why it matters." + }, + "suggestion": { + "type": "string", + "description": "Concrete fix or direction." + }, + "status": { + "type": "string", + "enum": ["open", "resolved"], + "description": "resolved once the authoring engineer has adequately addressed it (by a fix or a dispute you accept); otherwise open." + } + }, + "required": [ + "id", + "severity", + "location", + "issue", + "suggestion", + "status" + ] + } + }, + "responseToRebuttal": { + "type": "string", + "description": "Directly address the authoring engineer's disputes from the previous round: concede (and mark the finding resolved) or hold firm with specific reasoning. Empty string on the first round." + } + }, + "required": ["approved", "summary", "findings", "responseToRebuttal"] +} diff --git a/.claude/skills/do/SKILL.md b/.claude/skills/do/SKILL.md index e0dd2d25d..5c8a8ac3c 100644 --- a/.claude/skills/do/SKILL.md +++ b/.claude/skills/do/SKILL.md @@ -340,10 +340,11 @@ Check whether a PR already exists for this branch (`gh pr view`). ```md ## [Hickey/Lowy](https://kolu.dev/blog/hickey-lowy/) Analysis - | # | Lens | Finding | Disposition | - |---|--------|------------------------------------------|-------------------| - | 1 | Hickey | viewportDimensions complects two roles | Fixed in this PR | - | 2 | Lowy | useViewport encapsulates ghost concern | Fixed in this PR | + | # | Lens | Finding | Disposition | + |---|--------|------------------------------------------|---------------------| + | 1 | Hickey | viewportDimensions complects two roles | Fixed in this PR | + | 2 | Lowy | useViewport encapsulates ghost concern | Fixed in this PR | + | 3 | Lowy | clipboard.ts named after a consumer | ⚠️ **No-op** | ### Hickey rationale <prose from the hickey sub-agent> @@ -352,7 +353,7 @@ Check whether a PR already exists for this branch (`gh pr view`). <prose from the lowy sub-agent> ``` - The Disposition cell mirrors the sub-agent's Actions disposition verbatim — **Fixed in this PR** or **No-op** (deletion-only / subsumed by another finding). There is no Deferred disposition; if a sub-agent emitted one, the audit step above flipped it to Fixed in this PR. The Finding cell is the short bolded label the sub-agent emits at the start of each Actions entry. If both lenses produced zero findings, write a one-line "No findings — analysis below" instead of an empty table. + The Disposition cell mirrors the sub-agent's Actions disposition verbatim — **Fixed in this PR** or **No-op** (deletion-only / subsumed by another finding). **Render every No-op as `⚠️ **No-op**`** (warning emoji + bold) so the reviewer's eye lands on it; No-op rows are the ones a human most needs to scrutinize (a finding the reviewer acknowledged but didn't fix), and plain text lets them blend into the Fixed-in-this-PR rows above. There is no Deferred disposition; if a sub-agent emitted one, the audit step above flipped it to Fixed in this PR. The Finding cell is the short bolded label the sub-agent emits at the start of each Actions entry. If both lenses produced zero findings, write a one-line "No findings — analysis below" instead of an empty table. **If PR already exists** (followup runs, `--from` entry points): @@ -388,7 +389,13 @@ CI commands are typically local (e.g. `nix flake check`, `just ci`, `make ci`) a ### evidence -**Opt-in step.** Most projects skip this. The step exists so projects with empirical "did the feature actually work" needs — UI screenshots, performance benchmarks, demo recordings, output transcripts — can attach that evidence to the PR without baking the mechanism into agency. +**Opt-in step.** Most projects skip this. The step exists so projects with empirical "did this actually work" needs can attach proof to the PR without baking the mechanism into agency. That proof is **visual** _or_ **behavioral** — and the second kind is easy to under-fire on, because it often has zero visual diff: + +- **Visual** — UI screenshots, before/after stills, demo recordings; **video** when the change is about motion (an animation, a transition). +- **Behavioral** — proof that _state survives an interaction or a restart_. When the diff touches a persistence, restore, session, autosave, debounce/coalesce, or reconnect path, the evidence that matters is "does the round-trip still hold?", not a pixel change. These changes routinely have **no visual diff** yet are exactly where a survives-restart capture proves the fix didn't break recoverability (e.g. resize → stop the app → restart → restore session → panel returns at the resized width). +- **Other empirical** — performance benchmarks, output transcripts. + +**Bug fixes default to "demonstrate the fixed behavior."** The bug was usually invisible — a lost write, a storm, a hang, a broken round-trip — so a before→after or survives-restart clip is the evidence even when nothing _looks_ different. Don't gate evidence on a pixel changing; gate it on "is there a behavior worth proving." **If `--minimal`**: Skip with status `skipped` and reason `"--minimal"`. Move to **done**. @@ -402,6 +409,8 @@ CI commands are typically local (e.g. `nix flake check`, `just ci`, `make ci`) a The section is project-specific and free-form: it can be inline prose describing the capture procedure, a pointer to another file (`See ./scripts/capture-evidence.md`), a script reference (`Run ./scripts/capture-pr-evidence.sh and use its stdout`), or any combination. Don't second-guess the form — read it, then **spawn a sub-agent** (`Agent(subagent_type: "general-purpose", ...)`) so the capture work (MCP calls, screenshot uploads, gh API requests) doesn't pollute `/do`'s main context. +**Read the trigger broadly.** A project's section supplies the _capture mechanism_; the criterion for _when to fire_ is the visual-or-behavioral framing above. If the section's wording leans visual ("when the change has visible UI impact") but the diff is a behavioral fix on a persistence/restore/round-trip/debounce/reconnect path, capture the **behavior** anyway — the absence of a visual diff is not a reason to skip. Only skip when there is genuinely no behavior worth proving (a pure refactor, a docs change, an internal cleanup with no observable before→after). + The sub-agent prompt should include: - The literal section content from `.agency/do.md`. diff --git a/.claude/skills/hickey/SKILL.md b/.claude/skills/hickey/SKILL.md index ca8b6f881..45fc7fd1e 100644 --- a/.claude/skills/hickey/SKILL.md +++ b/.claude/skills/hickey/SKILL.md @@ -65,6 +65,7 @@ For each new abstraction (component, module, signal, type) the code introduces: 1. **Name what it represents at the domain level**, not the implementation level. 2. **Survey the codebase for the canonical in-repo pattern for the same *kind* of operation** — not just the same domain concept. If the diff adds a picker, find every other "pick a thing" surface in the project. If it adds a dialog or popover, find every other modal/overlay surface. If it adds a non-UI primitive (scheduler, error type, config loader, fetcher, state container), find the project's existing instance of that primitive kind. The implementer's "this is a new domain concept" framing is an easiness judgment; the *kind of operation* is the simplicity question. **When the canonical pattern exists and the diff reinvents it rather than extends it, surface that as the headline finding — before any micro-level critique inside the new abstraction.** 3. **"Mirror existing pattern" is an easiness judgment, not a simplicity judgment.** Creating ComponentB because ComponentA exists and looks similar adds a concept. Extending ComponentA keeps concept count flat. +4. **Package surface as a fragmentation site.** When evaluating a new published-shape package (`@org/foo`), read its exports list the way Layer 2 reads a per-entity structure — *does the consumer have to reconstitute one concept by wiring multiple exports together?* If yes, the package has fragmented one primitive into N exports and shipped the integration cost to the consumer. The fix is the same as any Layer-2 fix: collapse to one primitive at the natural layer (one entry point, internal submodules hidden). See `/lowy` §6.5 for the volatility-side argument; the worked example (`@kolu/solid-xterm@0.1` → `@0.2.0`, [kolu#998 commit `4af1c647`](https://github.com/juspay/kolu/commit/4af1c647)) is the same case study used there. **Budget heuristic.** The codebase survey in step 2 is worth its cost when the diff introduces a new top-level abstraction — typically signalled by new files added (`git diff --diff-filter=A --name-only origin/HEAD...HEAD` is non-empty) or a new exported component / module / type. Pure refactors, bug fixes, and line-level edits inside an existing abstraction don't trigger the survey. diff --git a/.claude/skills/kolu/SKILL.md b/.claude/skills/kolu/SKILL.md new file mode 100644 index 000000000..ddb707181 --- /dev/null +++ b/.claude/skills/kolu/SKILL.md @@ -0,0 +1,188 @@ +--- +name: kolu +description: >- + Drive one AI agent from another through kolu's terminals: spawn a Claude + Code / Codex / opencode session in a PTY, prompt it, watch the screen for its + reply, read it, and prompt again — a create→send→snapshot loop run with the + `kaval-tui` CLI directly (no MCP). `kaval-tui` writes input and reads + scrollback; `pulam-tui` adds a precise agent-state done-signal when you drive + hooked terminals. Triggers on "drive another agent", "send a prompt to a + terminal agent", "have one agent prompt another", "agent drives agent", + "orchestrate agents in terminals", "make Claude drive Codex", "prompt the + agent running in that terminal", or wiring a loop where one coding agent + supervises another. +--- + +# kolu — drive one agent from another through its terminal + +You can run a coding agent (Claude Code, Codex, opencode) inside a kolu-owned +PTY and steer it from the outside: type a prompt, submit it, watch the screen +until it's done, read what it said, type the next prompt. The whole toolkit is +**`kaval-tui`** — write input, read the screen, spawn, kill. The driver runs it +directly; there's no server to stand up and no MCP layer. + +`pulam-tui` adds a *precise* done-signal (`wait --until <state>`) — but only for +**hooked** terminals (see the last section). For a raw `kaval-tui`-spawned +terminal, the done-signal is **watching the screen settle**, below. + +## The loop + +```sh +id=$(kaval-tui create --json -- claude | jq -r .id) # spawn the inner agent +kaval-tui send "$id" "refactor the parser to use a lexer" # 1. TYPE the prompt +kaval-tui send "$id" --key Enter # 2. SUBMIT it (its own step) +wait_until_settled "$id" # 3. let its turn finish (below) +kaval-tui snapshot "$id" --viewport # 4. read the screen +kaval-tui send "$id" "now add tests for it"; kaval-tui send "$id" --key Enter # loop +``` + +Leaf commands, all `kaval-tui`: **create** (spawn) · **send** (type) · +**send --key Enter** (submit) · **snapshot** (read) · **kill**. **Typing and +submitting are two separate `send`s** — that is load-bearing, see below. + +> **Read with `snapshot --viewport`, not `| tail`.** A bare `snapshot` prints the +> **whole scrollback** — thousands of lines on a long-running or compacted agent — +> so `snapshot | tail -8` hands you the bottom of the buffer (often just trailing +> blanks), not the live screen. `--viewport` asks the daemon for just its +> terminal's last screenful — the right "what's on screen now" read, and correct +> regardless of how tall your own shell is (over `--host` the remote terminal is a +> different size). `--tail N` (alias `--lines N`) bounds it to the last N lines +> when you want a fixed slice. + +## `kaval-tui send` — type, then submit (two steps) + +`kaval-tui send <id> [text...]` writes input to the terminal — **exactly the text +(and any `--key`s) you pass, with NO implicit Enter**. It types; it does not +submit. **Submitting a prompt is its own second `send`:** + +```sh +kaval-tui send "$id" "fix the failing test in parser.ts" # 1. type the prompt +kaval-tui send "$id" --key Enter # 2. submit it +``` + +**Do this as two separate `send` commands — not `send "text" --key Enter` in one +call.** The separation is load-bearing: an Enter sent in the same breath as the +text races Claude Code's bracketed-paste / debounced input handling — it arrives +before the pasted text has registered and is **silently dropped**, leaving the +prompt staged on the `❯` line while `send` reports success. A standalone +follow-up `send --key Enter` lands after the text has settled, so it actually +submits. (If a turn never seems to start, this is the #1 cause — `snapshot` and +look for the prompt sitting unsent on the `❯` line.) + +Specifics: + +- **Multiline prompts and piped stdin go as one bracketed paste**, so they land + in the input box as a block instead of submitting line-by-line. Automatic + (`--paste` / `--no-paste` force it). For a big prompt, pipe it — + `cat task.md | kaval-tui send "$id"` — then `send "$id" --key Enter`. +- **`--key <name>`** (repeatable, sent after the text) is both the submit channel + (`Enter`) and the control channel: `Escape`, `C-c`, `Enter`, + `Up`/`Down`/`Left`/`Right`, `Tab`, `Home`, `End`, `Backspace`, `M-<char>`. +- **`--json`** → `{ id, bytes, paste, keys }` to confirm what was written. + +**`send` is blind** — it writes whether or not the agent is ready for input. +Always pair it with `snapshot` so you don't fire a prompt into a not-yet-ready +session (e.g. before the TUI has drawn its input box, or over a trust prompt). + +**Interrupt a runaway** before redirecting it: + +```sh +kaval-tui send "$id" --key Escape # stop Claude Code mid-stream +kaval-tui send "$id" --key C-c # SIGINT whatever's running +``` + +## The done-signal — watch the screen settle + +After you submit, you need to know when the turn ends. For a raw +`kaval-tui`-spawned terminal there's no agent-state feed, so use the screen +itself: **poll `snapshot` until it stops changing.** A working agent streams +output, so a screen that's held still for a couple of polls means the turn ended +— it finished, *or* it's blocked asking you something (both mean "your move"). +Then read the snapshot to see which, and respond. + +```sh +# Block until <id>'s screen is unchanged across 2 polls, capped at a deadline so +# a wedged agent can't hang the loop forever (the manual equivalent of a timeout). +wait_until_settled() { + local id=$1 deadline=$(( $(date +%s) + 600 )) prev="" cur stable=0 + while [ "$stable" -lt 2 ] && [ "$(date +%s)" -lt "$deadline" ]; do + sleep 3 + cur=$(kaval-tui snapshot "$id" --viewport) # diff the live screen, not the whole scrollback + if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi + prev=$cur + done +} +``` + +Poll **`--viewport`**, not a bare `snapshot`: a settle test diffs two reads, and +diffing two full-scrollback dumps is slow and noisy (any scrollback churn reads +as "still moving"), while two screenfuls compare cleanly. + +Tune `sleep`/deadline to the work. It's coarser than `pulam-tui wait` (a busy +agent that pauses mid-thought can read as settled), so after it returns, +**confirm the reply is actually present** in the snapshot before moving on. + +## `pulam-tui wait` — the precise done-signal (hooked terminals only) + +When you *do* have agent-state detection, `pulam-tui wait <id> --until <buckets>` +is the exact done-signal — it blocks until the agent reaches a coarse state, then +exits 0: + +- **`working`** — busy (`thinking` / `tool_use` / background task). +- **`awaiting`** — `awaiting_user`: it's **asking you** a question. +- **`waiting`** — the **just-finished** post-turn lull. + +`awaiting` and `waiting` both mean "your move", so `--until awaiting,waiting` +catches a turn ending; `--timeout <ms>` fails loud (exit 2) so a wedged agent +can't hang the loop; if the terminal **exits** before reaching the state, `wait` +fails loud too (exit 3 — the agent you were driving died); `--json` → +`{ id, agent }`. + +> **Mind the stale-state race — wait in two phases.** `wait` matches the agent's +> state **the instant it connects**, replaying whatever it is right now. So right +> after a `send`, the agent may still report the *previous* turn's +> `waiting`/`awaiting` for a beat before it picks up the new prompt — and a lone +> `wait --until awaiting,waiting` would return immediately on that stale state, +> before the turn you asked for has even begun. For a robust loop, wait for the +> pickup first, then the turn-end: +> +> ```sh +> kaval-tui send "$id" "fix the parser"; kaval-tui send "$id" --key Enter +> pulam-tui wait "$id" --until working # 1. it picked up the prompt +> pulam-tui wait "$id" --until awaiting,waiting # 2. its turn ended +> ``` + +> **Caveat — agent state needs HOOKED terminals.** Detection keys on kolu's shell +> rc-hooks (the OSC marks a terminal emits as commands run). `kaval-tui create` +> is the **raw** multiplexer — a plain `$SHELL`, no hooks by design — so an agent +> you spawn that way often isn't detected, and `wait` will just time out. `wait` +> is reliable when you drive **already-hooked** terminals: the ones a running +> **kolu-server** spawned (point `kaval-tui --socket $XDG_RUNTIME_DIR/kolu/pty-host.sock` +> at them), or a future `kolu-tui`. For a raw `kaval-tui create` loop, use the +> screen-settle done-signal above. + +## Reach — which daemon you're driving + +Bare `kaval-tui` **autodiscovers** a running daemon on this machine. Two ways to +point it elsewhere: + +- **`--socket <path>`** targets a specific local daemon — e.g. a running + **kolu-server's** kaval (`$XDG_RUNTIME_DIR/kolu/pty-host.sock`), to drive the + terminals you have open in kolu (these ARE hooked, so `pulam-tui wait` works + against them once a `pulam` reads that kaval). +- **`--host <ssh>`** reaches a daemon on another machine (provisioned with Nix); + a remote PTY survives the link. + +## Acceptance + +Before calling a driven turn done: + +- You **submitted with a separate `send --key Enter`** (not an implicit Enter, + not `send "text" --key Enter` in one call) — a prompt left staged on the `❯` + line is the #1 failure here. +- The inner agent's **reply is actually in the `snapshot`** — not an empty box or + a half-rendered stream. The screen-settle wait is coarse; verify the content. +- Your wait had a **deadline / `--timeout`** so a wedged agent fails instead of + hanging the loop. +- If the screen settled on a **question** (the agent is awaiting you), you **read + it and answered** — you didn't send the next task on top of a blocked prompt. diff --git a/.claude/skills/lens-debate/SKILL.md b/.claude/skills/lens-debate/SKILL.md new file mode 100644 index 000000000..6bd3b4864 --- /dev/null +++ b/.claude/skills/lens-debate/SKILL.md @@ -0,0 +1,263 @@ +--- +name: lens-debate +description: Run a structural-review debate between two lenses — lowy (volatility-based decomposition) and hickey (structural simplicity) — on the current diff. Each reviews independently, then they cross-examine every finding until they agree per-finding, and the agreed fixes are applied. Use when the user types `/lens-debate`, or asks to "have lowy and hickey review this", "run the lens debate", "debate this diff structurally", or "argue the structure of this PR until the lenses agree". +argument-hint: "[<pr-number>] [--base <branch>] [--max-rounds <n>] [--no-commit] [--no-apply] [--no-comment] [--with-police]" +--- + +# Lowy ⇄ Hickey lens debate + +Two structural reviewers argue your change to a settled conclusion. **lowy** +(volatility-based decomposition — do boundaries encapsulate axes of change?) and +**hickey** (structural simplicity — are independent concerns complected, or one +thing fragmented?) each review the diff *independently*, then cross-examine +**every** finding from **both** reviews until they agree on each one. The agreed +`fix` findings are applied — each as its own commit — and the outcome is **posted +to the PR** as a comment. You stay out of the middle: the script couriers +schema-constrained dispositions between the lenses and decides when they agree. + +This is the sibling of `/codex-debate`. Same engine (the `Workflow` tool), same +"both sides emit structured JSON so agreement is detected in code, not by vibes," +same "commits but never pushes or merges." The difference is *who debates whom*. + +## Why this shape + +The structure was found by trial in #1109, and two parts of it are load-bearing: + +- **Independent parallel review, then debate.** lowy and hickey review the diff + *simultaneously and independently* — neither sees the other's findings before + forming its own. A first cut fed hickey a *pre-curated* "lowy finding" to rebut + and it concluded *drop* — framing bias. Running the reviews independently in + parallel made hickey raise the same issue on its own, flipping the verdict to + *fix*. **Curation biases the outcome; independent-then-debate does not.** So the + lenses never trust a handed-down finding list — each reads the source itself. + +- **Both lenses run on Opus** (overriding their `model: sonnet` frontmatter), as + `/be` already requires for structural review. + +- **The lowy lens runs Löwy's electricity probe.** Beyond the generic "where's + the boundary?", the lowy reviewer must name the *receptacle* (the stable + interface consumers plug into), the *volatile implementations* behind it, + whether the thing is "electricity" (a domain-agnostic utility) or an app + concern, and where a consumer is forced to "expose the wires." This is **not a + second lens** — a separate voice would double-count lowy and reintroduce the + framing bias above. It's the same volatility vote with a sharper probe that + reliably pulls structural review out of abstraction and into "what plugs into + what" (the abstraction-without-grounding failure mode a lens debate is prone + to). It earned its keep on a live run (#1111). + +## Why deadlock is not possible + +Neither this skill nor `/codex-debate` has a deadlock exit — both run until +consensus, as many rounds as it takes. But the *reason* convergence is safe to +rely on is even stronger here. In `/codex-debate` the asymmetry is reviewer vs +**author**: Claude wrote the code and carries an authorship stake, so in +principle it could dig in and dispute a finding round after round (the loop +trusts good-faith concession to break the tie, and aborts only on reviewer +*infrastructure* failure). + +Here both sides are **disinterested third-party lenses** applied to someone +else's diff. Neither authored the code; neither has anything to defend. Their +disagreements are not ego conflicts but framework-weighting differences ("is this +worth fixing in *this* PR?") about a shared question with a knowable answer. Two +good-faith analysts, each told to argue from the code and concede when the other +is right, **converge** — there is no fixed position to defend. So there is **no +deadlock exit**: the debate runs until consensus, as many rounds as it takes. + +Three mechanics make that real rather than hopeful: + +1. **Independent review** (above) removes the up-front framing bias. +2. **Settled findings lock.** The moment both lenses agree on a finding's + disposition, it leaves the active set. The contested set is monotonically + non-increasing — the debate can only shrink, never grow, so it can't oscillate + a settled point back open. +3. **Sequential reveal.** Within a round lowy posts first and hickey answers + lowy's *current* positions, so the two land together instead of chasing each + other's stale positions. + +`--max-rounds` (default **12**) is a pure safety backstop so a pathological +oscillation can't run unbounded — not a deadlock cap. Reaching it is reported as +`unresolved` (needs a human), never `deadlock`, and should essentially never +happen between two good-faith lenses. + +**This skill requires Claude Code's `Workflow` tool** (it is the engine). Under +codex/opencode runtimes the skill is inert. + +## Arguments + +Parse `[<pr-number>] [--base <branch>] [--max-rounds <n>] [--no-commit] [--no-apply] [--no-comment] [--with-police]`: + +- **`<pr-number>`** (optional): a PR to debate. If given, `gh pr checkout <n>` + first and default the base to that PR's base branch. If omitted, debate the + **current branch's** diff. +- **`--base <branch>`**: ref to diff against. Always a **remote-tracking ref**, + never a stale local branch. Default: `origin/<PR base>` when a PR number is + given, else the repo default branch via + `git symbolic-ref --short refs/remotes/origin/HEAD` (e.g. `origin/master`), + used **as-is**. Fallback `origin/master`. Step 1 runs `git fetch origin` first. + The workflow resolves this to the **merge-base** of `base` and HEAD and diffs + against that, so the base branch's drift since the fork isn't reviewed as ours. +- **`--max-rounds <n>`**: safety backstop on debate rounds. Default **12**. Not a + deadlock cap (see above) — raise it freely. +- **`--no-commit`**: still apply the agreed fixes to the working tree, but leave + them uncommitted for you to commit yourself. Default is to **commit each fix + individually** (see below). +- **`--no-apply`**: skip the Apply phase entirely — the debate still settles every + finding, but the agreed `fix` plans are **returned** (the `fixes` field) instead + of implemented. For callers that want to review or re-validate the change + requests against a different tree before applying them themselves. Implies + nothing about commenting; the comment then records the fixes as "handed off". + (`--no-commit` is moot under `--no-apply` — nothing is implemented, so nothing + is committed.) +- **`--no-comment`**: don't post the debate summary to the PR. By **default**, + when a PR exists, the summary IS posted as a PR comment (see step 3). +- **`--with-police`**: fold in `/code-police` as a third, **lower-weight voice**. + It runs in the parallel review and *seeds* findings into the debate, but does + **not** get a vote in consensus — only lowy ⇄ hickey decide agreement. Off by + default (in #1109 its findings largely duplicated the lens findings). + +## Steps + +### 1. Resolve context + +- Determine `repoPath` (the worktree root, normally the cwd). +- **`git fetch origin`** so the base remote-tracking ref is current. +- Resolve `base` per the rules above (a remote-tracking ref like `origin/master`). +- If a PR number was given, `gh pr checkout <n>` and confirm the branch. +- Confirm a non-empty diff: `git diff --stat <base>`. If empty, say there's + nothing to review and stop. + +### 2. Run the debate Workflow + +Invoke the **`Workflow` tool** pointing at this skill's committed script, passing +context through `args`: + +``` +Workflow({ + scriptPath: ".claude/skills/lens-debate/debate.workflow.js", + args: { + repoPath: "<worktree root>", // also the per-worktree scratch dir root + base: "<base branch>", // a remote-tracking ref, e.g. origin/master + maxRounds: <n, default 12>, + commit: <false only if --no-commit>, + apply: <false only if --no-apply>, + withPolice: <true only if --with-police>, + rationale: "<optional author note on deliberate design decisions>", + model: "<optional model override; defaults to opus>" + } +}) +``` + +The workflow runs in the background and notifies you when it completes. It runs +three phases the user can watch via `/workflows`: + +- **Review** — `review:lowy`, `review:hickey` (and `review:code-police` with + `--with-police`) in parallel, each independent. +- **Debate** — alternating `lowy:roundN` / `hickey:roundN` until every finding is + agreed. Agreed findings drop out of each subsequent round. Agreement on a `fix` + means both lenses agree on the disposition *and* the plan — if they both say + `fix` but propose different changes, the finding stays open until the plans + converge too (so Apply never picks one lens's plan arbitrarily). +- **Apply** — a single `apply:all` agent implements **every** agreed `fix` in one + session and (unless `--no-commit`) commits each one individually, staging + **exactly** that fix's changed files with a message carrying the debate context. + One orientation for all fixes instead of a fresh implement+commit agent per + finding. Skipped wholesale under `--no-apply` — the plans come back in `fixes` + for the caller to apply. + +When `rationale` is set, pull it from the PR/issue description (the deliberate +design decisions the author wants the lenses to respect, e.g. a deliberate +fail-open) so the lenses don't flag intentional choices. + +Ephemeral scratch (commit-message files) lives under the gitignored, per-worktree +`<repoPath>/.lens-debate/`, so parallel debates in different worktrees never +collide and the scratch never shows up in the diff the lenses review. It returns: + +``` +{ status: "consensus" | "apply-incomplete" | "unresolved" | "clean", + rounds, base, withPolice, + settled, // per-finding: id, origin, title, location, agreed disposition, plan, both reasonings + unresolved, // findings still contested at the backstop (empty on consensus) + applied, // [{ id, title, files, commit }] (empty under --no-apply) + applyGaps, // [{ id, reason }] agreed fixes that didn't cleanly land — empty unless status is "apply-incomplete" + fixes, // the agreed `fix` findings with converged plans — the caller's change requests under --no-apply + reviews, // each lens's independent findings + history, // per-round dispositions + comment } // the deterministically rendered PR comment body — post it VERBATIM (step 3) +``` + +- **consensus** — every finding settled (the normal outcome). +- **clean** — every lens found nothing worth raising. +- **apply-incomplete** — the lenses *converged*, but the Apply phase didn't land + every agreed fix cleanly: a fix was **missing from the apply agent's output** + (so we can't confirm it was applied) or, in commit mode, was **changed but + returned no commit SHA** (its per-fix commit didn't land). The offending fixes + are in `applyGaps`. Any edits present stay in the working tree, but this is + **not** a clean consensus — surface the gap and reconcile it (re-apply or commit + the outstanding fix) before relying on the per-fix history. Do **not** report it + as a plain consensus. +- **unresolved** — the backstop was hit with findings still contested. Rare; + needs a human. This is NOT a deadlock — the lenses simply didn't converge in + the round budget; raise `--max-rounds` or adjudicate the listed findings. + +### 3. Present the result + +Report in chat (do **not** push or merge — the per-fix commits sit on the local +branch for the human to review): + +- The outcome (`status`) and round count. +- `git log --oneline <base>..HEAD` (the per-fix commits) and `git diff --stat + <base>` so the user sees what the debate changed. +- A per-finding table from `settled`: origin (lowy/hickey/police), title, + location, agreed disposition (fix/drop), and the applied commit SHA for fixes. +- On any **unresolved** finding, surface both lenses' final positions plainly so + the human can adjudicate — do not pick a winner yourself. +- **Post the debate summary to the PR (default).** When a PR exists and + `--no-comment` was NOT passed, post the workflow's **deterministically rendered + `comment`** verbatim — write it to a file and `gh pr comment <pr> -F <file>`: + + ```bash + mkdir -p "$repoPath/.lens-debate" # clean/all-drop/--no-commit runs never run the Apply commit step, so the dir may not exist yet + printf '%s' "$comment" > "$repoPath/.lens-debate/comment.md" + gh pr comment <pr> -F "$repoPath/.lens-debate/comment.md" + ``` + + The workflow returns `comment` already rendered — the + `## [⚖️ Lowy ⇄ Hickey lens debate](https://kolu.dev/blog/hickey-lowy/)` header + with the outcome badge and round count, the independent per-lens finding counts, + the applied fixes (with commit SHAs), the agreed no-change observations, and any + unresolved findings with both lenses' positions. Posting the returned string + (rather than re-improvising a table) keeps the comment a **deterministic** render + of the debate outcome. This mirrors `/codex-debate`; `--no-comment` suppresses it. + +## Safety & notes + +- **The lenses are read-only reviewers; only the Apply phase writes.** lowy and + hickey never edit code — they only emit dispositions. The sole writes to the + tree come from the single `apply:all` agent implementing the *agreed* fixes + (one session, one commit per finding) — not one agent per fix. +- **Commits, but never pushes or merges.** Each agreed fix is committed locally + (unless `--no-commit`) so the PR history reads as the debate's conclusions, but + the skill never pushes or merges. Consensus means "both lenses agree on the + disposition," not "ship it" — the human reviews the commits and pushes/merges. +- **No deadlock; bounded by a safety backstop.** The loop runs to consensus. + `--max-rounds` only prevents a pathological unbounded run; reaching it is + reported as `unresolved`, not deadlock. +- **Parallel-safe.** Ephemeral scratch lives under the gitignored, per-worktree + `<repoPath>/.lens-debate/`, so debates on many worktrees run at once without + clobbering each other. +- **Posts to the PR by default** (unless `--no-comment`) — the point is to leave + the structural-review trail on the PR. + +## Files + +- `debate.workflow.js` — the Workflow script (parallel review + the + lock-and-converge debate loop + the apply phase). + +The lenses read `.claude/skills/{lowy,hickey}/SKILL.md` (and +`.claude/skills/code-police/SKILL.md` with `--with-police`) at runtime for their +frameworks. + +This is generated from `agents/.apm/skills/lens-debate/`; edit the source there and run +`just ai::apm` to regenerate. + +ARGUMENTS: $ARGUMENTS diff --git a/.claude/skills/lens-debate/debate.workflow.js b/.claude/skills/lens-debate/debate.workflow.js new file mode 100644 index 000000000..48ed7f3b7 --- /dev/null +++ b/.claude/skills/lens-debate/debate.workflow.js @@ -0,0 +1,584 @@ +// The Workflow runtime requires `export const meta` to be the FIRST statement +// and a PURE LITERAL (no variable interpolation), so the primary model is +// inlined as 'opus' in the phase entries below. The only Apply-phase agent is a +// single `apply:all` on `model` (Opus) that implements and commits each agreed +// fix in-session. Those inlined 'opus' phase entries plus the `const MODEL` +// socket just after meta are the model bindings — every other model reference in +// this script reads MODEL lazily at input-resolution time, well after meta is +// evaluated. +export const meta = { + name: 'lens-debate', + description: + 'lowy + hickey review a diff independently in parallel, then debate every finding to consensus; apply the agreed fixes', + phases: [ + { title: 'Review', detail: 'lowy and hickey (and optionally code-police) review the diff independently, in parallel', model: 'opus' }, + { title: 'Debate', detail: 'lowy and hickey cross-examine every finding until they agree per-finding', model: 'opus' }, + { title: 'Apply', detail: 'implement each agreed fix as its own commit (skipped under apply:false)', model: 'opus' }, + ], +} + +// The model every lens/agent runs on. SKILL.md flags this as load-bearing +// (lenses run on Opus, overriding their `model: sonnet` frontmatter) and model +// migrations are a recurring change — keep it to one socket. Inlined into the +// phase entries above (meta must be a pure literal); the `model` input below +// defaults to it. +const MODEL = 'opus' + +// --------------------------------------------------------------------------- +// Inputs (passed via the Workflow tool's `args`) +// --------------------------------------------------------------------------- +// The harness JSON-ENCODES `args` before the workflow sees it, so it arrives as a +// STRING even when the caller passed a real object; a bare `args.repoPath` would then +// be `undefined` and every input (repoPath/base/rationale/…) silently default. That's +// the cross-repo bug: `repoPath` degrades to `.` (the cwd), the lenses review the +// WRONG repo and the apply phase commits onto it. Parse a stringified `args` +// defensively (empty string → {}; object used as-is; malformed JSON throws loudly, +// fail-fast). See codex-debate/debate.workflow.js for the same fix and its evidence. +const a = typeof args === 'string' ? (args.trim() ? JSON.parse(args) : {}) : args || {} +const repoPath = a.repoPath || '.' +// The diff base. Resolved to the MERGE-BASE of (rawBase, HEAD) just below, before +// DIFF is built, so the lenses review only what THIS branch changed — not commits +// the base branch gained since the branch forked (those would otherwise appear in +// `git diff base` as the base branch's drift, reviewed as ours). `let` because the +// resolution reassigns it. Idempotent when the caller already passed a merge-base +// SHA (e.g. /be-review). +let base = a.base || 'origin/master' +// Safety backstop only — NOT a deadlock cap. The debate runs until consensus; +// this just keeps a pathologically oscillating debate from running unbounded. +// Hitting it is reported as `unresolved` (needs human), never `deadlock`, and +// should essentially never happen between two good-faith lenses. Raise freely. +const maxRounds = a.maxRounds || 12 +// Apply agreed `fix` findings as individual commits (default on). `--no-commit` +// still applies the edits to the working tree, it just leaves them uncommitted. +// No-op when `apply` is false — the apply:false path returns plans in `fixes` +// and never commits; `commit` only gates the in-workflow Apply phase. +const commit = a.commit !== false +// Run the Apply phase at all (default on). `apply: false` skips Phase 3 entirely: +// the debate still settles every finding, but the agreed `fix` plans are RETURNED +// (the `fixes` field) instead of implemented — for callers that want the agreed +// fix plans returned so they can apply them against a tree of their choosing. +const apply = a.apply !== false +// Fold in /code-police as a third, lower-weight voice: it SEEDS findings into +// the debate set but does not get a vote in consensus (only lowy ⇄ hickey do). +const withPolice = a.withPolice === true +// Optional author note on deliberate design decisions, so the lenses don't flag +// intentional choices (e.g. a deliberate fail-open). Threaded into every prompt. +const rationale = (a.rationale || '').trim() +// Model every lens/agent runs on; defaults to MODEL (see top of file). Overridable +// via args to mirror the file's input pattern and to make a model bump a one-liner. +const model = a.model || MODEL +// Mechanical tier (Haiku). The lenses' reviews + the per-finding debate + applying +// an agreed fix all do real reasoning → `model` (Opus, load-bearing for the +// lenses). The merge-base resolver is pure git → run it on `mechModel`. +// Defaults match a direct invocation; /be-review passes it. +const mechModel = a.mechModel || 'haiku' +// Per-worktree scratch for commit-message files; gitignored so it never shows up +// in the diff the lenses review, and parallel debates in different worktrees +// never collide. Only the commit-message files land here. +const workDir = `${repoPath}/.lens-debate` + +// Löwy's "electricity" probe — a sharper version of the SAME volatility lens, NOT +// a second voting voice (a separate lens would double-count lowy and reintroduce +// the up-front framing bias this skill avoids). It forces the abstract "where's +// the boundary?" down to the concrete "what plugs into what?", which is exactly +// the abstraction-without-grounding failure mode a lens debate is otherwise prone +// to. Earned its keep on a live run (#1111). Baked into the lowy reviewer's output. +const ELECTRICITY_PROBE = `As a REQUIRED part of your output, apply Löwy's electricity test (Righting Software / The Method) to ground the boundary question in "what plugs into what": name the **receptacle** (the stable interface every consumer plugs into), name the **volatile implementations** that receptacle encapsulates (the interchangeable generators behind it), say whether this is "electricity" (a domain-agnostic utility) or an application concern, and call out where a consumer is forced to "expose the wires" — reach past the receptacle and depend on a specific implementation. If the diff has no such boundary, say so explicitly; do not invent one.` + +// The two structural lenses that debate to consensus. code-police, when enabled, +// is appended as a finding SOURCE only — it is not a debater. +const DEBATERS = ['lowy', 'hickey'] +const REVIEWERS = [ + { lens: 'lowy', framework: 'volatility-based decomposition — do boundaries encapsulate axes of change? (Lowy / Parnas)', probe: ELECTRICITY_PROBE }, + { lens: 'hickey', framework: 'structural simplicity — independent concerns complected, or one thing fragmented? (Simple Made Easy)' }, +] +if (withPolice) REVIEWERS.push({ lens: 'code-police', framework: 'code quality, correctness, and common-mistake review' }) + +// The result shape's empty collections, shared by the two EARLY returns +// (merge-base-error, clean) so adding a result field is one edit, not a mirror +// edit per return site. The final return carries real values and stays literal. +const EMPTY_RESULT = { settled: [], unresolved: [], applied: [], applyGaps: [], fixes: [], reviews: {}, history: [] } + +// Resolve the diff base to the merge-base of (base, HEAD) BEFORE building DIFF +// (which interpolates `base` eagerly), so the lenses review only what this branch +// changed, not the base branch's drift since the fork. A thin mechanical git +// agent (the workflow can't run git itself); grouped under the Review phase. +// Idempotent when `base` is already a merge-base SHA (caller resolved it). +const rawBase = base +const baseRes = await agent( + `You are a MECHANICAL RUNNER. Run \`git -C ${repoPath} merge-base ${base} HEAD\` and return ONLY the resulting commit SHA (hex) in \`sha\`. If the command FAILS (missing/typoed base, stale ref, unrelated history), return \`sha\`: "" and put the verbatim git error in \`error\` — do NOT fall back to the raw base ref. Do nothing else.`, + { label: 'resolve:merge-base', phase: 'Review', model: mechModel, schema: { type: 'object', additionalProperties: false, required: ['sha'], properties: { sha: { type: 'string', description: 'the merge-base SHA, or "" on failure' }, error: { type: 'string', description: 'the git error when sha is empty' } } } }, +) +// Fail loud on a bad base. Falling back to the raw `${base}` tip would make the +// lenses review the base branch's drift since the fork as if this change made it — +// the exact noise the merge-base removes — so a missing/typoed/stale base aborts. +if (!baseRes?.sha?.trim()) { + const err = (baseRes?.error || '').trim() + log(`Aborting: \`git merge-base ${rawBase} HEAD\` failed; the diff scope can't be trusted. Not falling back to the raw ${rawBase} tip.`) + return { + ...EMPTY_RESULT, + status: 'merge-base-error', + base: rawBase, + rounds: 0, + withPolice, + note: `merge-base of \`${rawBase}\` and HEAD could not be resolved (missing/typoed base, stale ref, or unrelated history), so the review scope is untrustworthy. Fix the base ref (e.g. \`git fetch\`) and re-run.${err ? `\ngit error:\n${err}` : ''}`, + } +} +base = baseRes.sha.trim() + +// How every agent is told to inspect the change. The lenses do NOT trust a +// curated finding list — they read the source themselves (the load-bearing +// lesson from #1109: curation biases the verdict). +const DIFF = `Inspect the FULL change in the repo at \`${repoPath}\` — your shell cwd may be a DIFFERENT worktree, so use \`git -C ${repoPath}\` and ABSOLUTE paths under \`${repoPath}\`: run \`git -C ${repoPath} diff ${base}\` (committed + unstaged) and \`git -C ${repoPath} status --short\` (untracked/new files do NOT appear in the diff), then Read every new/changed file plus enough surrounding code to judge it in context. Ignore the debate's own scratch dir \`.lens-debate/\` if it appears.` + +const rationaleBlock = rationale ? `\nAuthor's note on deliberate decisions (do not flag these as defects unless the reasoning is itself wrong):\n${rationale}\n` : '' + +// --------------------------------------------------------------------------- +// Schemas — the review and the per-finding debate position +// --------------------------------------------------------------------------- +const FINDINGS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['findings'], + properties: { + findings: { + type: 'array', + description: 'ALL your independent structural findings — every issue worth raising through your lens, no cap. An empty list is fine only for a genuinely clean diff.', + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'location', 'problem', 'suggestion', 'disposition'], + properties: { + title: { type: 'string' }, + location: { type: 'string', description: 'file:line' }, + problem: { type: 'string', description: "the problem in your lens's terms" }, + suggestion: { type: 'string', description: 'a concrete, implementable change' }, + disposition: { type: 'string', enum: ['fix', 'drop'], description: 'fix = worth changing in THIS PR; drop = observation only' }, + }, + }, + }, + }, +} + +const POSITION_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['positions'], + properties: { + positions: { + type: 'array', + description: 'one entry for EVERY contested finding id you were given', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'disposition', 'reasoning'], + properties: { + id: { type: 'string' }, + disposition: { type: 'string', enum: ['fix', 'drop'] }, + plan: { type: 'string', description: 'if fix: the exact change, implementable' }, + agreesWithPlan: { + type: 'boolean', + description: + "when disposition===fix, true only if you endorse the other lens's plan as-is; if false, your `plan` field is the amendment that must still converge", + }, + reasoning: { type: 'string', description: 'argue from the code (cite file:line); concede explicitly when the other lens is right' }, + }, + }, + }, + }, +} + +// One Apply agent implements every agreed fix and commits each in a single +// session, so it returns the full per-fix outcome (not one impl per agent). One +// entry per fix it was handed; `commit` is "" under `--no-commit` or when a fix +// turned out to need no change. +const APPLY_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['applied'], + properties: { + applied: { + type: 'array', + description: 'one entry for EVERY agreed fix you were given, in the same order', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'summary', 'filesChanged'], + properties: { + id: { type: 'string' }, + summary: { type: 'string', description: 'one line: what you changed for this fix' }, + filesChanged: { type: 'array', items: { type: 'string' } }, + commit: { type: 'string', description: 'this fix\'s commit SHA, or "" if nothing was committed' }, + }, + }, + }, + }, +} + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- +function reviewBrief(lens, framework, probe) { + const probeBlock = probe ? `\n${probe}\n` : '' + return `You are the **${lens}** reviewer. First Read \`.claude/skills/${lens}/SKILL.md\` for your framework, then ${DIFF} + +Review the change through the **${framework}** lens, INDEPENDENTLY — you are NOT seeing any other reviewer's findings. That independence is the whole point: being handed someone else's curated finding biases the verdict. +${rationaleBlock}${probeBlock} +Give ALL your findings — every structural issue you see through your lens, no cap, at every level (boundary, complecting, naming, duplication, …). Each: a title, a file:line location, the problem in your lens's terms, a concrete suggestion, and a disposition — \`fix\` (worth changing in THIS PR) or \`drop\` (observation only). Don't fabricate issues, but don't hold any back either; an empty list is fine only for a genuinely clean diff.` +} + +function findingLine(f) { + return `### ${f.id} (raised by ${f.origin}) — ${f.title}\n at ${f.location}; raiser's disposition: ${f.disposition}\n problem: ${f.problem}\n suggestion: ${f.suggestion}` +} + +function debateBrief(lens, opp, activeFindings, oppPos, settledList, roundNum) { + const settledNote = settledList.length + ? `\nALREADY SETTLED (you both agreed — do NOT relitigate, shown for context only):\n${settledList.map((s) => `- ${s.id}: ${s.disposition}`).join('\n')}\n` + : '' + const oppBlock = oppPos + ? `**${opp}'s positions to rebut or concede, point by point:**\n${JSON.stringify(oppPos, null, 2)}\n\nFor each finding you also call \`fix\`, set \`agreesWithPlan\`: true only if you endorse ${opp}'s \`plan\` as-is. If false, your \`plan\` field is the amended plan that must still converge — the finding stays open another round until the plans agree, just like the disposition.` + : `Round 1 — give your initial disposition on every contested finding below, including ${opp}'s and any from other reviewers.` + return `You are **${lens}**, cross-examining **${opp}** to reach agreement. First Read \`.claude/skills/${lens}/SKILL.md\` for your framework, then ${DIFF} Ground every call in the source. +${rationaleBlock} +CONTESTED findings — disposition EVERY one (yours, ${opp}'s, and any from other reviewers): +${activeFindings.map(findingLine).join('\n\n')} +${settledNote} +${oppBlock} + +Round ${roundNum}. For EVERY contested finding id above, output a disposition (\`fix\` = worth changing in THIS PR / \`drop\` = leave as-is, observation only), a concrete implementable plan if \`fix\`, and reasoning grounded in the code. **The goal is the correct answer for THIS PR, not winning** — concede explicitly ("conceding: …") when ${opp}'s code-grounded argument is right. A \`fix\` is worth it only if it genuinely improves the PR.` +} + +// ONE brief for ALL agreed fixes — implemented and committed in a single Apply +// session, so the agent orients on the repo once instead of paying that cost per +// fix (the old form spawned an implement agent AND a commit agent per finding, +// serially). The fixes are independent and their plans already converged in the +// debate, so there's no cross-fix reasoning to isolate; what we keep is one +// commit PER finding so the history still reads finding-by-finding. +function applyAllBrief(fixes, doCommit) { + const list = fixes + .map( + (f) => `### ${f.id} (raised by ${f.origin}) — ${f.title} + at ${f.location} + problem: ${f.problem} + original suggestion (context, not the agreed plan): ${f.suggestion} + agreed plan: ${f.plan}`, + ) + .join('\n\n') + const commitStep = doCommit + ? `After a fix's edits are done, COMMIT that fix on its own before moving to the next, so each finding maps to one commit and the history reads finding-by-finding. Stage ONLY the files you changed for that fix — never \`git add -A\` or \`git add .\`. Write the message to a file under \`${workDir}\` (run \`mkdir -p ${workDir}\` first) and commit with \`git -C ${repoPath} add -- <files> && git -C ${repoPath} commit -F <msgfile>\`, using EXACTLY this message shape: + + fix(lens): <the fix's title> + + <your one-line summary of the change> + + Agreed by the lowy ⇄ hickey lens debate (finding <id>, raised by <origin>). Not pushed or merged. + +Do NOT push. Record each fix's resulting commit SHA (\`git -C ${repoPath} rev-parse HEAD\`) in its \`commit\` field. If a fix turns out to need no change, leave its \`filesChanged\` empty and its \`commit\` "".` + : `Do NOT git add / commit / push — leave every change in the working tree and set each fix's \`commit\` to "".` + return `You are implementing the changes that two structural-review lenses (lowy and hickey) independently agreed should be fixed in THIS PR. Work in the repo at \`${repoPath}\` — your shell cwd may be a DIFFERENT worktree, so every file you Read/Edit MUST be an ABSOLUTE path under \`${repoPath}\` and every git command MUST use \`git -C ${repoPath}\`. + +Apply each agreed fix below, IN ORDER. The fixes are independent — keep each one tightly scoped to its finding and don't let one bleed into another. Read the surrounding code first so each edit fits the existing style. You may run the project's formatter on files you touched. + +${list} + +${commitStep} + +Return one \`applied\` entry per fix (same order): its id, a one-line summary, the exact files you changed, and the commit SHA (or "").` +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const posMap = (res) => Object.fromEntries((res?.positions ?? []).map((p) => [p.id, p])) + +// Render the PR comment deterministically from the debate outcome, returned as a +// string so the ORCHESTRATOR posts it verbatim (`gh pr comment -F`) — no agent +// re-improvises a table. Unlike codex-debate there are NO per-round files to +// assemble: the lenses don't read a ledger (feeding them prior reasoning would +// invite entrenchment against conceding), so the comment is the only artifact. +// +// The header chrome (the `## ` title, the badge, the `base.slice(0, 12)`) is +// deliberately kept STRUCTURALLY PARALLEL to codex-debate's ledgerHeader chrome. +// The no-module workflow runtime has no imports, so a truly shared renderer isn't +// available; the two are instead siblings that move together. A house-style change +// (badge emoji, base-slice length, a new metadata row) is a mechanical mirror edit +// — make it here and in codex-debate's ledgerHeader. If the runtime ever admits a +// shared helper file, lift this common chrome there. +// `outcome` is the single mode bit for what happened to the agreed fixes: +// { kind: 'applied', items } when this run implemented them, or +// { kind: 'handed-off', items } when apply:false returned the plans to the +// caller — one param, so "at most one of applied/handed-off" holds by +// construction instead of by convention. +// `applyGaps` (agreed fixes the Apply phase did not cleanly land) is rendered +// HERE, not just in the machine `status`: the SKILL posts this comment verbatim, +// so an apply-incomplete run must surface a warning badge and a dedicated gap +// section instead of advertising `✅ Consensus` and listing the gapped fix as +// `Applied`. Keep this consistent with the status downgrade in Phase 3. +function renderComment({ rounds, settledOut, unresolved, outcome, reviewByLens, withPolice, base, clean, applyGaps = [] }) { + const gapIds = new Set(applyGaps.map((g) => g.id)) + const badge = applyGaps.length + ? `⚠️ **Apply incomplete** — ${applyGaps.length} agreed fix(es) not cleanly applied` + : clean + ? '✅ **Clean** — every lens found nothing worth raising' + : unresolved.length === 0 + ? '✅ **Consensus**' + : `⚠️ **${unresolved.length} unresolved**` + const counts = Object.entries(reviewByLens) + .map(([lens, fs]) => `${lens}=${fs.length}`) + .join(', ') + // A clean diff never debated, so the "after N round(s)" clause is omitted; the + // base, the lens roster, and the (all-zero) per-lens counts still ride along so + // the comment carries the same audit metadata as a debated run. + const meta = `lowy + hickey${withPolice ? ' + code-police' : ''} · base \`${(base || '').slice(0, 12)}\`` + const lines = [ + '## [⚖️ Lowy ⇄ Hickey lens debate](https://kolu.dev/blog/hickey-lowy/)', + '', + clean ? `${badge} · ${meta}` : `${badge} after ${rounds} round(s) · ${meta}`, + '', + `Independent findings: ${counts}`, + ] + const drops = settledOut.filter((s) => s.agreed && s.disposition === 'drop') + if (outcome.kind === 'applied') { + // Only CLEANLY-landed fixes go under `Applied`; a fix in `applyGaps` (missing + // from the apply output, or changed-but-uncommitted) is NOT applied work and + // must not be advertised as such under what would otherwise be a consensus + // badge — it gets its own gap section below. + const cleanlyApplied = outcome.items.filter((a) => !gapIds.has(a.id)) + if (cleanlyApplied.length) { + lines.push('', `### Applied (${cleanlyApplied.length})`) + cleanlyApplied.forEach((a) => lines.push(`- \`${a.id}\` ${a.title}${a.commit ? ` — commit \`${a.commit.slice(0, 9)}\`` : ' — (uncommitted)'}`)) + } + if (applyGaps.length) { + lines.push('', `### Apply incomplete — needs reconcile (${applyGaps.length})`) + const reasonText = { 'missing-from-output': 'not confirmed applied (absent from apply output)', uncommitted: 'changed but not committed (per-fix commit missing)' } + applyGaps.forEach((g) => { + const item = outcome.items.find((a) => a.id === g.id) + const title = item?.title ? ` ${item.title}` : '' + lines.push(`- \`${g.id}\`${title} — ${reasonText[g.reason] ?? g.reason}`) + }) + } + } + // apply:false runs hand the agreed plans to the caller instead of implementing + // them; the comment records the handoff so the trail still shows what was agreed + // (the caller appends its own apply outcomes when it posts this). + if (outcome.kind === 'handed-off' && outcome.items.length) { + lines.push('', `### Agreed fixes — handed off to the caller (${outcome.items.length})`) + outcome.items.forEach((f) => lines.push(`- \`${f.id}\` ${f.title} (${f.location})`)) + } + if (drops.length) { + lines.push('', `### Agreed — no change (${drops.length})`) + drops.forEach((d) => lines.push(`- \`${d.id}\` ${d.title} (${d.location})`)) + } + if (unresolved.length) { + lines.push('', `### Unresolved — needs human (${unresolved.length})`) + // Surface BOTH lenses' full final positions (disposition + reasoning + any + // plan), not just the bare verdict — a human adjudicating needs the actual + // disagreement, which lives in each side's reasoning/plan text. + unresolved.forEach((u) => { + lines.push('', `- \`${u.id}\` ${u.title} (${u.location})`) + for (const lens of ['lowy', 'hickey']) { + const p = u[lens] + const verdict = p?.disposition ?? '?' + const reasoning = p?.reasoning ? ` — ${p.reasoning}` : '' + lines.push(` - **${lens}**: ${verdict}${reasoning}`) + if (p?.plan?.trim()) lines.push(` - plan: ${p.plan}`) + } + }) + } + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// Phase 1 — independent parallel review +// --------------------------------------------------------------------------- +phase('Review') + +const reviews = await parallel( + REVIEWERS.map((r) => () => + agent(reviewBrief(r.lens, r.framework, r.probe), { label: `review:${r.lens}`, phase: 'Review', model, schema: FINDINGS_SCHEMA }), + ), +) + +const reviewByLens = {} +const combined = [] +REVIEWERS.forEach((r, idx) => { + const findings = reviews[idx]?.findings ?? [] + reviewByLens[r.lens] = findings + findings.forEach((f, i) => combined.push({ id: `${r.lens}-${i + 1}`, origin: r.lens, ...f })) +}) +log(`Independent findings: ${REVIEWERS.map((r) => `${r.lens}=${reviewByLens[r.lens].length}`).join(', ')}`) + +if (combined.length === 0) { + // Route the clean outcome through the SAME renderer as a debated run so the + // comment carries the same audit metadata (base, lens roster, per-lens counts, + // whether code-police ran) instead of a bare one-liner. + const comment = renderComment({ rounds: 0, settledOut: [], unresolved: [], outcome: { kind: apply ? 'applied' : 'handed-off', items: [] }, reviewByLens, withPolice, base, clean: true }) + return { ...EMPTY_RESULT, status: 'clean', rounds: 0, base, withPolice, note: 'every lens found nothing worth raising', reviews: reviewByLens, comment } +} + +// --------------------------------------------------------------------------- +// Phase 2 — debate to consensus. NO deadlock exit: the loop runs until every +// finding is agreed. Agreed findings LOCK (leave the active set), so the +// contested set is monotonically non-increasing — the debate can only shrink. +// Sequential reveal (lowy posts, hickey answers lowy's CURRENT positions) lets +// the two land together rather than chase each other's stale positions. +// --------------------------------------------------------------------------- +phase('Debate') + +const settled = {} // id -> { disposition, plan, lowy, hickey } +let activeIds = combined.map((f) => f.id) +let lowyPrev = null +let hickeyPrev = null +const history = [] +let status = 'unresolved' +let rounds = 0 + +for (let r = 1; r <= maxRounds && activeIds.length > 0; r++) { + rounds = r + const activeFindings = combined.filter((f) => activeIds.includes(f.id)) + const settledList = Object.entries(settled).map(([id, s]) => ({ id, disposition: s.disposition })) + + const lowyRes = await agent(debateBrief('lowy', 'hickey', activeFindings, hickeyPrev, settledList, r), { + label: `lowy:round${r}`, + phase: 'Debate', + model, + schema: POSITION_SCHEMA, + }) + const lowyPos = posMap(lowyRes) + + const hickeyRes = await agent(debateBrief('hickey', 'lowy', activeFindings, lowyPos, settledList, r), { + label: `hickey:round${r}`, + phase: 'Debate', + model, + schema: POSITION_SCHEMA, + }) + const hickeyPos = posMap(hickeyRes) + lowyPrev = lowyPos + hickeyPrev = hickeyPos + + const per = [] + for (const id of [...activeIds]) { + const l = lowyPos[id] + const h = hickeyPos[id] + // For a `fix`, agreement requires the second poster (hickey, who has seen + // lowy's positions) to endorse lowy's plan as-is — otherwise the finding + // stays active so the plan converges the same way the disposition does. + // `plan` is optional in the schema, so a `fix` can only settle once lowy has + // actually supplied a non-empty plan: endorsing an absent plan is not + // consensus, and Apply must never run on a `plan: undefined` (it would fall + // back to a vague placeholder and commit an arbitrary edit as "agreed"). + const lowyHasPlan = !!(l && typeof l.plan === 'string' && l.plan.trim()) + const agreed = !!( + l && + h && + l.disposition === h.disposition && + (l.disposition !== 'fix' || (h.agreesWithPlan === true && lowyHasPlan)) + ) + per.push({ id, lowy: l?.disposition ?? '?', hickey: h?.disposition ?? '?', agreed }) + if (agreed) { + // Endorsement guarantees l.plan is the converged text; no arbitrary fallback. + settled[id] = { disposition: l.disposition, plan: l.disposition === 'fix' ? l.plan : undefined, lowy: l, hickey: h } + activeIds = activeIds.filter((x) => x !== id) + } + } + history.push({ round: r, per }) + log(`Round ${r}: ${per.map((p) => `${p.id} ${p.lowy}/${p.hickey}${p.agreed ? '✓' : '✗'}`).join(' ')} | settled ${Object.keys(settled).length}/${combined.length}`) + + if (activeIds.length === 0) { + status = 'consensus' + break + } +} + +// Final per-finding verdict: agreed ones carry the consensus disposition; +// any still-contested ones are surfaced (unresolved → human), never silently dropped. +const settledOut = combined.map((f) => { + const s = settled[f.id] + if (s) { + return { id: f.id, origin: f.origin, title: f.title, location: f.location, problem: f.problem, suggestion: f.suggestion, agreed: true, disposition: s.disposition, plan: s.plan, lowy: s.lowy, hickey: s.hickey } + } + return { id: f.id, origin: f.origin, title: f.title, location: f.location, problem: f.problem, suggestion: f.suggestion, agreed: false, disposition: 'unresolved', plan: undefined, lowy: lowyPrev?.[f.id], hickey: hickeyPrev?.[f.id] } +}) +const unresolved = settledOut.filter((s) => !s.agreed) +log(`Debate ended: ${status} after ${rounds} round(s); ${settledOut.length - unresolved.length}/${settledOut.length} settled, ${unresolved.length} unresolved.`) + +// --------------------------------------------------------------------------- +// Phase 3 — apply every agreed `fix` finding in a SINGLE session, one commit +// per finding. One agent orients on the repo once and applies all the fixes, +// rather than paying a fresh implement+commit agent (and its re-orientation +// cost) per finding; the fixes are independent and their plans already +// converged, so there's no cross-fix reasoning to isolate. Skipped wholesale +// under `apply: false`: the agreed plans are returned in `fixes` for the caller +// to implement against whatever tree it chooses. +// --------------------------------------------------------------------------- +const fixes = settledOut.filter((s) => s.agreed && s.disposition === 'fix') +let applied = [] +// Agreed fixes the Apply phase did not cleanly land. Two failure shapes, both of +// which would otherwise be rendered as "applied" and reported under a consensus: +// - missing: the agent dropped the fix from its output entirely (no entry, no +// files) — we can't tell if it was applied, so it must not be reported as done. +// - uncommitted: in commit mode the agent changed files for the fix but returned +// no SHA — its per-fix commit didn't land, breaking "one commit per fix". +// The edits (when present) stay in the tree, so this is a status downgrade, not a +// hard abort: the caller reconciles the gap rather than losing a converged debate. +const applyGaps = [] +if (apply && fixes.length) { + phase('Apply') + const res = await agent(applyAllBrief(fixes, commit), { label: 'apply:all', phase: 'Apply', model, schema: APPLY_SCHEMA }) + const byId = Object.fromEntries((res?.applied ?? []).map((a) => [a.id, a])) + // Re-key off the agreed `fixes` (not the agent's array) so a fix the agent + // dropped from its output still surfaces — as 0 files / uncommitted — instead + // of vanishing from `applied` and the PR comment. + applied = fixes.map((f) => { + const entry = byId[f.id] + const a = entry || {} + const sha = (a.commit || '').trim() + const files = a.filesChanged ?? [] + if (!entry) { + // The agent never reported this agreed fix. We can't confirm it was applied, + // so flag it rather than render a phantom 0-file "applied" row as success. + applyGaps.push({ id: f.id, reason: 'missing-from-output' }) + log(`Apply ${f.id}: agreed fix absent from apply-agent output — not confirmed applied`) + } else if (commit && !sha && files.length > 0) { + // Reported changed-but-uncommitted in commit mode: the per-fix commit the + // agent was told to make didn't land. Surface it as a gap, not a clean apply. + applyGaps.push({ id: f.id, reason: 'uncommitted' }) + log(`Apply ${f.id}: agent changed ${files.length} file(s) but returned no commit SHA`) + } + return { id: f.id, title: f.title, files, commit: sha || null } + }) + applied.forEach((a) => log(`Applied ${a.id}: ${a.files.length} file(s)${a.commit ? `, committed ${a.commit.slice(0, 9)}` : ' (uncommitted)'}`)) + // A converged debate whose fixes didn't cleanly land is NOT a clean consensus: + // downgrade so /be-review (which keys off this status) and the comment don't + // advertise success over an unconfirmed/uncommitted fix. Only touch a status + // that was otherwise clean ('consensus'/'clean'); 'unresolved' already signals + // the human must act. + if (applyGaps.length && (status === 'consensus' || status === 'clean')) { + const prior = status + status = 'apply-incomplete' + log(`Apply incomplete: ${applyGaps.map((g) => `${g.id} (${g.reason})`).join(', ')} — downgrading ${prior} to apply-incomplete.`) + } +} else if (fixes.length) { + log(`Apply skipped (apply: false) — returning ${fixes.length} agreed fix plan(s) to the caller.`) +} + +return { + status, + rounds, + base, + withPolice, + settled: settledOut, + unresolved, + applied, + // Agreed fixes that didn't cleanly land (missing from the apply output, or + // changed-but-uncommitted). Empty unless status is 'apply-incomplete'; lets the + // caller pinpoint which fix to reconcile. + applyGaps, + // The agreed `fix` findings with their converged plans — the caller's + // change-request payload under `apply: false` (redundant with `settled` when + // the Apply phase ran, but always present so consumers need not re-filter). + fixes, + reviews: reviewByLens, + history, + comment: renderComment({ rounds, settledOut, unresolved, outcome: apply ? { kind: 'applied', items: applied } : { kind: 'handed-off', items: fixes }, reviewByLens, withPolice, base, applyGaps }), +} diff --git a/.claude/skills/lowy/SKILL.md b/.claude/skills/lowy/SKILL.md index 954db8228..3253c32d9 100644 --- a/.claude/skills/lowy/SKILL.md +++ b/.claude/skills/lowy/SKILL.md @@ -103,6 +103,26 @@ Volatility-based building blocks are reusable because they encapsulate one axis Lowy observes that reuse increases downward through layers: infrastructure and data-access components should be highly reusable across contexts, business-logic orchestrators are reusable across multiple clients, and clients/UI are rarely reusable. If a lower-layer component is locked to a single consumer, the boundary likely tracks functionality rather than a genuine axis of change. +**Single in-tree consumer is not disqualifying when the interface is stable under the encapsulated axis.** §5's bar is whether the interface would survive the volatility it claims to encapsulate — not whether it currently has more than one importer. A receptacle with one wire plugged in is still a receptacle. The published precedent: [`@kolu/surface`](https://kolu.dev/blog/surface-framework/) (and its peers `@kolu/solid-pierre`, the seven `@kolu/*` packages graduated from the [kolu#998 ralph loop](https://github.com/juspay/kolu/pull/998)) extracted from single-in-tree-consumer code. Each encapsulates a stable volatility axis its README names explicitly. The reuse-count check would have killed all of them. The interface-stability check admits them — correctly. The shape that disqualifies is *"the interface mirrors the implementation"*, not *"only one place imports it today"*. + +### 6.5 Package Coherence + +When the extraction crosses a *package* boundary (not just a module within the same package), the reviewer's job is not done after naming one volatility axis. The package as a whole must read as a **coherent library** — one concept, one socket. If the package ships three exports for three internal aspects of what should be one primitive, you have shipped *partial wiring*, not a receptacle. + +Run this check whenever the extraction adds a new published-shape package (`@org/foo`): + +1. **Read the package's exports list as if you were a new consumer.** Does it suggest one coherent thing or a topic-bundle? + - `@kolu/surface` exports `defineSurface` → one entry, one concept (typed reactive layer). Coherent. + - `@kolu/solid-xterm@0.1` (kolu#998 cycle 3–5) exported `createXtermWebgl`, `attachXtermStyleSync`, `createScrollLock` → three entries, three internal aspects of "xterm lifecycle" leaked through three exports. Not a coherent SolidJS adapter for xterm; a topic-bundle of three xterm-adjacent helpers. The fix shipped in `@kolu/solid-xterm@0.2.0` (commit [`4af1c647`](https://github.com/juspay/kolu/commit/4af1c647)) is one `createSolidXterm({ container, theme, fontSize, addons, webgl, scrollLock, ... })` primitive that hides WebGL / style / scroll as internal submodules. + +2. **Apply §5's atomic-verb rule at the package level.** §5 already warns that an interface exposing `OpenPort` / `ClosePort` / `AdjustBeam` alongside `ReadCode` mixes axes. A package exporting `createX_webgl` / `attachX_style` / `createX_scroll` does the same thing one altitude up: the package's surface is three operations on three axes, not one atomic abstraction. + +3. **The Surface test.** If the package's exports list does not resemble Surface's shape — *one entry point per coherent concept, with internal submodules hidden* — the package is shaped around the implementation, not around a stable contract. Even if each individual export passes §5 in isolation, the *package* fails the test. + +4. **The "consumer wires it together" smell.** If the only in-tree consumer imports several of the package's exports and then composes them by hand — the way `Terminal.tsx` had to wire `createXtermWebgl` + `attachXtermStyleSync` + `createScrollLock` + a bare `XTerm` constructor + 8 addon imports in v0.1 — the missing primitive is the composition. The package is shipping submodules and asking the consumer to be the integrator. Wrap them. + +**Action when this fires.** Re-extract behind a single primitive that owns the integrated lifecycle; demote the current exports to internal submodules of that primitive. The Lowy verdict is not "don't extract" — it's "extract one socket, not three wires." + ### 7. The Almost-Expendable Test Lowy's litmus test for correct decomposition: when a change request arrives, the response should be *contemplative* — you think through how to adapt. If a module is *expensive* to change, it's too big (functional decomposition has coupled unrelated concerns). If a module is *expendable* (trivially thrown away), it's an unnecessary boundary. If a module is *almost expendable* — it encapsulates just enough to contain one axis of change, and replacing it is straightforward but not trivial — the decomposition is correct. @@ -128,6 +148,8 @@ After completing all steps, **invoke `/fact-check` on your own output**. The fac - _"The module encapsulates [domain entity]"_ — domain entities are not volatility axes. What *about* the entity changes? Name the specific volatility or it's domain decomposition. - _"This is variable, so we should encapsulate it"_ — variable is not volatile. Can you state the risk in terms of likelihood and effect? - _"this is a new kind of [picker / dialog / scheduler / error type] for a new domain"_ — "new domain, same kind" duplicates the receptacle, not the volatility axis. Run the prior-encapsulation check from §1: the canonical pattern (command palette, generic dialog, single tagged error, etc.) is already the receptacle for this volatility. A parallel encapsulation is duplicated encapsulation, which maximizes change blast radius the same way functional decomposition does. +- _"Fails Lowy's reuse test"_ (when based on import count alone) — reuse-count is a symptom, not a diagnosis. The diagnosis is §5's interface-stability check. An interface can have one importer today and a perfectly stable contract; ten importers and still be shaped around its implementation. Cite the axis, not the count. +- _"Each export passes §5 in isolation"_ (without checking the package surface) — §5 fires per interface; §6.5 fires per package. Three coherent helpers in one package can collectively fail the package-coherence check if their union suggests one thing the package doesn't actually deliver. Read the exports list as a consumer would and ask "what library is this?" — if the answer is a topic-bundle ("xterm-adjacent helpers") rather than a primitive ("SolidJS adapter for xterm"), §6.5 applies. If fact-check finds issues, revise before presenting to the user. diff --git a/.claude/skills/odu-mcp/SKILL.md b/.claude/skills/odu-mcp/SKILL.md new file mode 100644 index 000000000..1e27d736c --- /dev/null +++ b/.claude/skills/odu-mcp/SKILL.md @@ -0,0 +1,32 @@ +--- +name: odu-mcp +description: odu MCP server launcher — drive CI from a coding agent. `bin/serve` resolves odu via Nix and runs `odu mcp` in the cwd. See the repo README for the tools/resources and override knobs. +user-invocable: false +--- + +# odu-mcp + +The agent face of [odu](https://github.com/juspay/odu) — an MCP stdio server +that re-exposes a live CI run as agent tools (`run`, `node_rerun`, +`wait_for_settle`, `cancel`) and subscribable resources (`surface://streams/nodes`, +`surface://collections/logs/{id}`), so Claude Code / Codex / opencode / Gemini +CLI drive CI with structured calls instead of scraping terminal output. + +`cancel` stops the live run and waits until it's torn down; `run`'s `supersede` +cancels a run already live here before starting (the "stop this, run the fixed +commit" move), and `linger` keeps the coordinator serving past settle so a node +can be rerun afterwards. Together they let the agent loop call off or replace a +run instead of stranding it or hitting "a run is already in progress". + +`bin/serve` is self-contained — it resolves odu via `nix run` and serves over +stdio in the consumer's repo (dialing `.ci/odu.sock`). Set `ODU_FLAKE` to +override the odu flake-ref (default `github:juspay/odu`); a repo that +re-exports odu can point it at its own pinned output with `ODU_FLAKE=.#odu`. + +Full docs in the [repo README](https://github.com/juspay/odu/blob/master/README.md). + +This skill primitive exists for APM's deployment convention — it lands +`bin/serve` at `.agents/skills/odu-mcp/bin/serve` in the consumer's working +tree (APM's skills-convergence path), which keeps the launcher available even +before `apm install` runs on a fresh clone. The package is mechanically a +"skill" in APM's primitive vocabulary; semantically it's a tool launcher. diff --git a/.claude/skills/odu-mcp/bin/serve b/.claude/skills/odu-mcp/bin/serve new file mode 100755 index 000000000..519fbd6cb --- /dev/null +++ b/.claude/skills/odu-mcp/bin/serve @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Launch the odu MCP server — odu's agent face. Resolves odu via Nix and runs +# `odu mcp` in the current directory: the server dials `.ci/odu.sock` and +# starts / attaches to runs in the cwd, so an MCP host launching this from the +# repo root drives that repo's CI. Self-contained — no surrounding devshell +# required. Requires flakes (`experimental-features = nix-command flakes`). +# +# Override knobs (env vars): +# ODU_FLAKE — flake-ref for odu (default: github:juspay/odu). A consuming +# repo that re-exports odu can point this at its pinned output, +# e.g. ODU_FLAKE=.#odu +set -euo pipefail +exec nix run --accept-flake-config "${ODU_FLAKE:-github:juspay/odu}" -- mcp "$@" diff --git a/.claude/skills/perfection-review/SKILL.md b/.claude/skills/perfection-review/SKILL.md new file mode 100644 index 000000000..fbcb6ba17 --- /dev/null +++ b/.claude/skills/perfection-review/SKILL.md @@ -0,0 +1,53 @@ +--- +name: perfection-review +description: >- + Adversarial "perfection" review — hold a change to an *ideal* bar, not just a correct one, + assuming eternal time, unlimited energy, and no ship pressure. Use when the user asks to + review "for perfection", to make a defect "impossible to express", or to hunt where a defect + "relocates" across review rounds. Grounds every claim in the diff, fans out adversarial + verifiers via Workflow, and reports residual surfaces with a structural fix for each. ONLY + invoke when the user explicitly asks for a perfection / ideal-bar review. +argument-hint: "[<pr-number>] [--base <branch>] [--post]" +--- + +# Perfection review + +The bar is not **"closed"** but **"the defect can no longer be expressed."** Assume eternal +time, unlimited energy, no deadline. "Overridable", "acceptable for scope", and "documented +intent" are *still holes*. + +Most defects are **one shape in costumes**. Name the shape, then hunt **where it relocates** — +a fix that satisfies the literal ask but lets the defect resurface one seam over is not done. +Track it across rounds until it has nowhere left to go. + +## Method + +1. **Ground in the diff, not the story** — the PR body, commit messages, docs, and the + author's claims are assertions to falsify, not facts. Check them against the code. +2. **Letter vs effect** — a safety added but never exercised is inert. Require a real path + that uses it and a test that fails when it is removed. +3. **Unspellable > absent** — make the wrong thing impossible to write, not merely + discouraged. If a future author can still spell the defect, it isn't closed. Beware a + guarantee that proves *presence*, not *behaviour*. +4. **Reconcile claim and code** — an overclaiming comment or doc is itself a defect: make the + code earn the sentence, or soften the sentence. +5. **Finish the blast radius** — not done until every dependent and downstream is carried to + the same bar and verified against the final state. + +## Verify adversarially — use Workflow + +**Review with fresh context, separate from the author** — a reviewer carrying the author's +context rationalizes the author's intent; a fresh mind grounded only in the diff does not. +Fan out grounded verifiers (one per claim) **plus an adversary whose only job is to express +the defect anyway**, each citing the diff; then synthesize. Default to *refuted-if-uncertain*, +loop until nothing new surfaces, and re-verify the headline finding yourself. Keep agent +schemas **flat** and isolate the adversary so one failure can't abort the run. + +## Report + +Lead with **credit** for what's closed — don't move goalposts on done work — then the +**residual surfaces**, ranked, each with its structural fix. Frame each finding as the +**invariant it violates**, not a lone exploit: a single proof-of-concept trains a one-line +patch and the defect just relocates — give the property, and if you must show an example, +give a few from different angles and call it one costume of many. Separate "the product is +fine" from "the claim isn't yet true". Post to the PR only when asked (`--post`). diff --git a/.claude/skills/pu/SKILL.md b/.claude/skills/pu/SKILL.md new file mode 100644 index 000000000..40b392dbb --- /dev/null +++ b/.claude/skills/pu/SKILL.md @@ -0,0 +1,77 @@ +--- +name: pu +description: >- + Provision and drive a `pu` box — an Incus container used as + a clean Linux host for CI, builds, and evidence capture. Use when you need to + run something on a fresh remote box instead of the user's machine: `nix run` a + build, run CI against a real host, capture screenshots/video off-machine, or + reproduce on a pristine environment. Covers create/connect/scp/destroy, running + remote commands, copying artifacts back, and the no-egress failure mode. + Triggers on "pu box", "spin up a box", "run this on a box", "ephemeral host", + "pu create/connect/destroy". +--- + +# pu — on-demand Incus boxes + +`pu` hands out Linux containers. Each box is a clean NixOS host with Nix ++ flakes, reachable over SSH through `pu`'s own proxy. Use one whenever work should +run **off the user's machine** — a CI run, a `nix run` build, evidence capture — +so nothing local is at risk and the environment is reproducible. A box can be short-lived +(spin up, use, `destroy`) or kept around long-term — the lifetime is yours to choose. + +## Lifecycle + +```sh +pu create "$host" # create; writes ~/.pu-state/$host/ssh_config +pu list # NAME + LOCATION (the physical host it landed on) +pu connect "$host" # interactive ssh +pu connect "$host" -- CMD # run CMD on the box and return +pu destroy "$host" # tear down — always do this when finished +``` + +Name is positional. Pick a descriptive, collision-free name (e.g. `app-pr-42-evidence`). + +`pu connect` is the reliable way in — it reads `~/.pu-state/$host/ssh_config` itself, so it +needs no setup. Bare `ssh "$host"` works **only** if you've added `Include +~/.pu-state/*/ssh_config` to `~/.ssh/config` (optional, often not set up); otherwise use +`pu connect`, or pass the config explicitly: `ssh -F ~/.pu-state/$host/ssh_config "$host"`. + +## Run commands on the box + +```sh +# One-shot +pu connect "$host" -- 'uname -a' + +# Background a long-running server (nohup so it survives the SSH session) +pu connect "$host" -- "nohup nix run github:owner/app -- --port 8080 >/tmp/app.log 2>&1 &" + +# Poll until it's healthy +pu connect "$host" -- 'until curl -sf http://127.0.0.1:8080/health; do sleep 2; done' +``` + +The box has its **own loopback** — bind servers to `127.0.0.1` on whatever port you +like; there is no clash with anything on the user's machine. + +## Copy artifacts back + +`pu connect` is SSH, so `scp` works against the box's generated config: + +```sh +scp -F ~/.pu-state/"$host"/ssh_config "$host":/tmp/out.png /tmp/out.png +``` + +## Failure mode: no outbound network + +A box occasionally lands on a host with broken egress — DNS and even raw-IP TCP +time out, so `nix run github:...` hangs on "Resolving timed out". This is +host-specific, not your fault. **Probe egress first; if it fails, destroy and +recreate** (a fresh box usually lands on a healthy host): + +```sh +pu connect "$host" -- 'timeout 15 curl -sS -o /dev/null -w "%{http_code}\n" https://api.github.com' \ + || { echo "no egress — recreating"; pu destroy "$host"; pu create "$host"; } +``` + +If retries keep landing on dead hosts, capture diagnostics for the admin — the box's +`LOCATION` from `pu list`, `/etc/resolv.conf`, `ip route`, and a `/dev/tcp` connect +test to the gateway and to a raw IP — and hand them over (e.g. a gist). diff --git a/.claude/skills/surface/SKILL.md b/.claude/skills/surface/SKILL.md new file mode 100644 index 000000000..e74adf07b --- /dev/null +++ b/.claude/skills/surface/SKILL.md @@ -0,0 +1,58 @@ +--- +name: surface +description: >- + How a downstream app consumes the shared @kolu/surface stack (@kolu/surface · + surface-app · surface-nix-host · surface-mcp) — declaring a typed reactive surface, + serving it, consuming it (SolidJS hooks or a CLI), and mirroring a remote surface over + ssh. Grounded in the real consumers: kolu, pulam-web, drishti, odu, and the TUIs. Load + when wiring a surface server/client/mirror, or reaching for getHostSession / a link / + the `.use()` hooks. CHANGING the framework is gated separately — + `.claude/rules/surface.md` (a paired, CI-green drishti PR pinned to final kolu HEAD). +--- + +# Using @kolu/surface (downstream consumer guide) + +Declare a typed reactive surface once; the framework derives the oRPC contract, wires the +server, and binds the Solid client hooks. **This is the consumer guide** — *changing* the +framework needs a paired drishti PR (`.claude/rules/surface.md`). + +## Who uses it — match the closest consumer, don't hand-roll + +| Consumer | Shape | Notable | +| --- | --- | --- | +| **kolu** (`client`+`server`) | one browser ⇄ one Node server, ONE ws | single-tier; two sibling surfaces; uses `surface`+`surface-app` only (**not** `-nix-host`) | +| **pulam-web** | browser ⇄ Node ⇄ ssh fleet mirror | one ws per host (`/rpc/ws?host=`); `connectSurface`; re-serves `terminalWorkspaceSurface` | +| **drishti** (`srid/drishti`) | browser ⇄ Node ⇄ ssh agent mirror | the canonical twin; 3 workspaces (common/agent/app) | +| **odu** (`juspay/odu`) | CI runner: stdio lanes → unix-socket fan-in → CLI/MCP | serve+consume+mirror over every transport at once; `surface-mcp` projection | +| **pulam-tui / kaval-tui** | one-shot CLI/TUI, no browser | transport-blind `{client,dispose}`; unix-socket local, ssh remote; **no `.use()` hooks** | + +## The spine (real import paths) + +- **Define** — `defineSurface({cells,collections,streams,events,procedures})` (`@kolu/surface/define`). Many surfaces over one transport: `composeSurfaceContracts(map)` + sibling clients, never merged. +- **Serve** — `implementSurface(surface, deps)` / `implementSurfaces(map, fwDeps, perKeyDeps)` (`@kolu/surface/server`; `inMemoryStore` / `inMemoryChannelByName` back the cells/channels). **Always flatten before serving:** `implement(surface.contract).router({ ...fragment.router })` — else oRPC double-prefixes `/surface/surface/…` and every call 404s. +- **Consume (SolidJS)** — `surfaceClient(surface, link)` / `surfaceClients(link, map)` (`@kolu/surface/solid`) → `client.cells.X.use({authority,initial,onError})`, `.collections.X.use({keys,onError})` then `.byKey(id)?.()` / `.keys()`, `.streams.X.use(inputFn,{onError})` with `.pending()`/`.error()`, `.events.X.use(inputFn,handler)`. +- **Consume (CLI/TUI)** — no reactive hooks; raw awaited `conn.client.surface.<verb>(…)` + async-iterator iteration; a live board uses `mirrorRemoteSurface(spec, client, {collections,streams}, {log})` (`@kolu/surface/mirror`) into plain callbacks. + +## Links (transport, swappable) + +`websocketLink(ws)` (`/links/websocket`) · `stdioLink` (`/links/stdio`) · `unixSocketLink({socketPath})` (`/links/unix-socket`) · `directLink(router)` (`/links/direct`, in-process identity, for tests). Serve side: `serveOverStdio` (`/peer-server`), `serveOverUnixSocket` (`/unix-socket`), oRPC `RPCHandler` (`@orpc/server/ws`, `.upgrade(ws)`) for browsers. CLIs keep ONE transport-blind `Connection = {client, dispose}` so every command is written once across local vs ssh. + +## Mirror a remote surface (drishti / pulam-web / odu) + +1. **Dial the host** — `getHostSession<contract>({host, binary, resolveDrvPath})` (`@kolu/surface-nix-host`): long-lived, `nix copy`s the agent closure, runs `<bin> --stdio` over ssh, reconnects. `buildHostRegistry` fans out N hosts; one-shot CLIs use `dialAgentOnce` instead. +2. **Mirror inward** — `pumpRemoteSurface({source, session, makeSink, …})` (`-nix-host`) folds the remote agent's frames into a local `implementSurface` re-serve via a `SurfaceSink` (`makeSink`, `@kolu/surface/mirror`). The parent implements the *same* surface; a remotely-unobservable cell (e.g. connection state) is parent-authoritative. +3. **Re-serve** — the local fragment served on `/rpc/ws`, accepted via `acceptSurfaceSocket` (`@kolu/surface-app/server`). Browsers connect with `connectSurface` (`@kolu/surface-app/solid`), which bundles socket + `websocketLink` + `surfaceClient` + a default-on liveness heartbeat. + +## Gotchas (hard-won, all real) + +- **Procedures call off the FULL link, not the scoped client** — `surfaceClients` per-key `.rpc` is typed `unknown`; reach the root link for raw procedures. +- **Raw streaming** — `unenrolledStreamCall(client.X, input, {signal, onRetry})` (`@kolu/surface/client`) carries the reconnect (`STREAM_RETRY`) context; a bare `client.X(…)` silently loses it. There is **no `stream` namespace** (`.claude/rules/streaming.md` is stale on that point). +- **Consume streams fine-grained** — value-bearing → `.streams.use()` (replace-each-frame); delta-accumulate → `mirrorRemoteSurface` / `createSubscription`+`reduce`. Never coarse-read-and-copy: same-shape frames coalesce and the view freezes. +- **Snapshot-then-deltas + fail-fast** — a cell always opens with a snapshot; `firstFrameOrThrow` (`@kolu/surface/first-frame`) treats an empty stream as a link failure, never a silent empty. +- **Liveness is on by construction** — framework-reserved `surface.system.live`; `connectSurface` / `HostSession` / `createServerLifecycle` default their watchdog to it (`probeSurfaceLive`). Don't nominate your own probe unless you mean to (pulam-tui's version-cell probe is the rare, deliberate exception). +- **Version skew** — gate on `isContractVersionCompatible` (major.minor), never a string `==`. +- **Nix-baked deps** — odu declares no `@kolu/*` in `package.json`; they're symlinked at Nix build (the bake-in-via-Nix convention). A bare `pnpm install` won't resolve them. + +## Reference + +Runnable end-to-end: `packages/surface/example/` (its `mini-ci` stdio example is odu's seed). Full API + rationale: each package's `README.md`. Match the closest consumer above; don't reinvent a primitive the table already shows how to use. diff --git a/.claude/skills/talk/SKILL.md b/.claude/skills/talk/SKILL.md index 8c9d14979..eede44adc 100644 --- a/.claude/skills/talk/SKILL.md +++ b/.claude/skills/talk/SKILL.md @@ -10,7 +10,7 @@ You are now in **talk mode**. Have a conversation with the user — discuss idea ## Rules -- **Do NOT edit or mutate the current repo.** No `Edit`, `Write`, `NotebookEdit` tool calls against workspace files, and no Bash commands that create, modify, or delete files in the checked-out repo. (Sole exception: `--html` mode below, which permits writing a single `.html` artifact to `$PWD`.) +- **Do NOT edit or mutate the current repo.** No `Edit`, `Write`, `NotebookEdit` tool calls against workspace files, and no Bash commands that create, modify, or delete files in the checked-out repo. (Sole exception: `--html` mode below, which permits writing a single `.html` artifact to the repo root or an existing `docs/plans/`.) - **Do NOT run destructive repo commands.** No `git commit`, `git push`, `git add`, `git rm`, or anything else that mutates the current repo. - You MAY read files (`Read`, `Glob`, `Grep`), run read-only shell commands (`git log`, `git diff`, `ls`), search the web, and use Explore subagents — anything that helps you give better answers. - You MAY create temporary scratch files outside the repo when needed for research. Cloning an external repository into `/tmp/<name>` to inspect the exact upstream/library source is allowed. Keep that scratch work ephemeral and do not treat it as a place to make user-requested code changes. @@ -114,14 +114,16 @@ Laconic mode trims the *output*, not the *investigation*. Do the same reading yo ## HTML artifact mode (`--html`) -If `ARGUMENTS` contains `--html` (strip the flag before treating the rest as the topic), respond by writing a self-contained `.html` file to `$PWD` instead of replying in chat. Print only the file path — the HTML *is* the response. +If `ARGUMENTS` contains `--html` (strip the flag before treating the rest as the topic), respond by writing a self-contained `.html` file instead of replying in chat. Print only the file path — the HTML *is* the response. + +- **Output directory**: write to `docs/plans/` if that directory already exists; otherwise write to the repo root (`$PWD`). Do not create `docs/plans/` — only use it when it's already there. The point is to pair with a runner that can render the artifact and let the user select text on it to queue comments back (e.g. [juspay/kolu#922](https://github.com/juspay/kolu/pull/922)). The user reads the rendered HTML, replies with their selected comments as text, you re-emit the updated HTML. The artifact stays the conversation's single source of truth. -- **Filename**: stable for the session — `talk-<short-slug>.html` derived from the topic (lowercase, dashes, no spaces), or `talk.html` if there's no obvious slug. Follow-up turns update the **same** file; do not spawn a new artifact per turn. +- **Filename**: stable for the session — `talk-<short-slug>.html` derived from the topic (lowercase, dashes, no spaces), or `talk.html` if there's no obvious slug, in the output directory above. Follow-up turns update the **same** file; do not spawn a new artifact per turn. - **File contents**: self-contained — embedded `<style>` block, no external assets, no JavaScript, no remote fonts. Plain semantic markup that renders legibly inside an iframe preview. Carry the same `file:line` citations you would put in a text response; the research/citation rules above are unchanged. - **UI prototypes for UI work**: if the topic involves UI changes, embed *rendered* HTML/CSS prototypes of the proposed components inside the artifact — not ASCII mockups, not prose descriptions of what the UI would look like. The runner renders the file, so the user sees the proposed UI alongside the rationale and can comment on the visual itself. Approximate the target visual style (colors, spacing, typography); the prototype is static (no JS), but layout and hierarchy should be representative enough to react to. -- **Repo-write exception**: writing that one `.html` file in `$PWD` is the only mutation `--html` permits. No `Edit` on pre-existing repo files, no `git` writes, no destructive ops — the rest of talk mode's read-only posture holds. +- **Repo-write exception**: writing that one `.html` file in the output directory above is the only mutation `--html` permits. No `Edit` on pre-existing repo files, no `git` writes, no destructive ops — the rest of talk mode's read-only posture holds. - **Follow-up loop**: when the user replies with comments (typically pasted from a select-and-queue surface as a Markdown list), re-emit the **full** revised HTML and print the file path again. Do not narrate the diff in chat; the updated artifact is the reply. - **Interaction with `hickey` + `lowy`**: when the artifact is a design sketch, run the reviewers as usual and fold their findings into the HTML body **before** printing the file path — same "post-review proposal, not original sketch + critique appended" rule as text-mode responses. - **Interaction with laconic mode**: laconic trims the HTML body the same way it would trim a text response — brief prose, no preamble, no needless bullets, no heading scaffolding unless the answer is genuinely structured. `--html` picks the medium; `--no-laconic` picks the verbosity. UI-prototype markup is the substance of the answer, not prose filler, so it's not what laconic trims. diff --git a/.github/agents/hickey.agent.md b/.github/agents/hickey.agent.md new file mode 100644 index 000000000..c142f0916 --- /dev/null +++ b/.github/agents/hickey.agent.md @@ -0,0 +1,8 @@ +--- +name: hickey +description: Evaluate code (especially LLM-generated) for structural simplicity using Rich Hickey's "Simple Made Easy" framework. Use this agent whenever reviewing a PR, diff, or code snippet for accidental complexity — particularly when the code was generated by an AI coding assistant and line-by-line review isn't feasible. Also use when the user asks about complecting, simplicity vs. easiness, structural coupling, or concept deduplication. Trigger on phrases like "is this simple", "does this complect", "review for complexity", "structural analysis", or any reference to Hickey, Simple Made Easy, or grey-box review. +--- + +# Hickey sub-agent + +You are the hickey reviewer. Invoke the `hickey` skill (via the `Skill` tool, `skill: "hickey"`) on whatever task, diff, or code the caller hands you, then return the findings exactly in the Output Format that skill specifies. The skill holds the methodology and is the single source of truth — do not paraphrase, summarize, or reimplement any of its steps here; just delegate. diff --git a/.github/agents/lowy.agent.md b/.github/agents/lowy.agent.md new file mode 100644 index 000000000..189de11f4 --- /dev/null +++ b/.github/agents/lowy.agent.md @@ -0,0 +1,8 @@ +--- +name: lowy +description: Evaluate architecture and module boundaries for volatility-based decomposition using Juval Lowy's framework (from "Righting Software", building on Parnas 1972). Use when reviewing module splits, service boundaries, new abstractions, or any decomposition decision. Trigger on phrases like "where should this boundary be", "how to split this", "module boundaries", "encapsulate change", "volatility", or references to Lowy, Parnas, or "Righting Software". Complements hickey (interleaved concerns) with a different lens (change encapsulation). +--- + +# Lowy sub-agent + +You are the lowy reviewer. Invoke the `lowy` skill (via the `Skill` tool, `skill: "lowy"`) on whatever task, diff, or decomposition decision the caller hands you, then return the findings exactly in the Output Format that skill specifies. The skill holds the methodology and is the single source of truth — do not paraphrase, summarize, or reimplement any of its steps here; just delegate. diff --git a/.github/hooks/agency-do-stop-guard.json b/.github/hooks/agency-do-stop-guard.json new file mode 100644 index 000000000..db20dd514 --- /dev/null +++ b/.github/hooks/agency-do-stop-guard.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": ".github/hooks/scripts/agency/scripts/do-stop-guard.sh" + } + ] + } + ] + } +} diff --git a/.github/hooks/scripts/agency/scripts/do-stop-guard.sh b/.github/hooks/scripts/agency/scripts/do-stop-guard.sh new file mode 100755 index 000000000..b2a246797 --- /dev/null +++ b/.github/hooks/scripts/agency/scripts/do-stop-guard.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Prevents the agent from stopping while /do workflow is still running. +# Reads .do-results.json and blocks the stop if active == "working". +# Output shape is cross-compatible with Claude Code and Codex: only +# `{"decision":"block","reason":"…"}` is emitted for the block case, and +# empty stdout is used for the approve case (Codex rejects `decision:"approve"`). +# Safe default: if the file exists but can't be parsed, block (not approve). +results="$CLAUDE_PROJECT_DIR/.do-results.json" +if [ ! -f "$results" ]; then + exit 0 +fi +active=$(jq -r '.active // empty' "$results" 2>/dev/null) || active="parse_error" +case "$active" in + working) + echo '{"decision":"block","reason":"/do workflow still running — continue from where you left off. Check .do-results.json for current progress."}' + ;; + parse_error) + echo '{"decision":"block","reason":"Could not parse .do-results.json — file may be corrupted. Check and fix it before stopping."}' + ;; + *) + ;; +esac diff --git a/.github/instructions/apm-sources.instructions.md b/.github/instructions/apm-sources.instructions.md new file mode 100644 index 000000000..22844c2e4 --- /dev/null +++ b/.github/instructions/apm-sources.instructions.md @@ -0,0 +1,14 @@ +--- +description: Redirects agents to edit .apm/ sources (at any directory level) instead of generated agent config files +applyTo: ".claude/**,.opencode/**" +--- + +## Generated Files — Do Not Edit Directly + +> **This rule only applies if an `.apm/` directory exists somewhere in the project.** If there is no `.apm/` directory at any level, `.claude/` and `.opencode/` files are vendored directly and can be edited in place. To locate it, search for a directory matching `**/.apm/` from the project root. + +Everything under `.claude/` and `.opencode/` is **generated** from `.apm/` sources by APM. Direct edits will be overwritten on the next `apm install` run. + +To modify agent configuration, find the `.apm/` directory (it may be at the project root or nested under a subdirectory such as `agents/.apm/`), edit the source files there, then run `apm install` to regenerate. + +**For Codex / opencode targets, also run `apm compile -t codex,opencode`** (or whichever subset applies) — `install` regenerates the runtime folders but does not produce the project-root `AGENTS.md` that those hosts read. Claude reads `.claude/` natively, so no compile step is needed when `claude` is the only target. diff --git a/.github/instructions/emanote-docs.instructions.md b/.github/instructions/emanote-docs.instructions.md new file mode 100644 index 000000000..f9c6c3352 --- /dev/null +++ b/.github/instructions/emanote-docs.instructions.md @@ -0,0 +1,38 @@ +--- +description: Emanote documentation pages +applyTo: "docs/**/*.md" +--- + +# Emanote Documentation Pages + +When adding a new documentation page under `docs/`, give it an explicit simple `slug` in frontmatter. Prefer a short, stable slug that matches the intended public route segment, such as `i18n`, `i18n.fr`, `flake-module`, `wikilinks`, or `yaml-config`; do not rely on filenames or other implementation-shaped paths to define the public route. + +## Wikilink internal references + +Connect docs pages to surrounding documentation with **wikilinks**, both when creating a new page and when editing existing ones. Link to the nearest relevant docs page — `[[yaml-config]]`, `[[html-template]]`, `[[wikilinks]]`, `[[layer]]`, etc. — instead of repeating concepts inline or leaving the page as an isolated note. Prefer wikilinks over raw relative Markdown links for any internal docs reference. + +When you add prose that mentions another concept covered by an existing page, replace the bare phrase with a wikilink — e.g. write `… see [[html-template]] for the override mechanism …` rather than restating it. If no page covers the concept yet, this is a signal to consider promoting it to a dedicated page (see below) rather than burying the explanation inside an unrelated guide. + +## Promote sections to dedicated pages when they grow + +A section under one guide page should be **promoted to its own page** once any of these hold: + +- It has more than two H3 sub-sections (the section is now a small guide of its own). +- It carries reference material — splice tables, override protocols, configuration surfaces — that other pages need to wikilink to. +- It documents a feature with its own slug-worthy concept name (`wikilinks`, `callout`, `folgezettel`, etc.). + +Move the content to a new file with a stable slug, replace the original section with a one-paragraph pointer plus a `[[<slug>]]` wikilink, and add the new page to the relevant section folder-note (`docs/start.md`, `docs/authoring.md`, `docs/config.md`, `docs/theme.md`, `docs/extend.md`, `docs/external-tools.md`, or `docs/reference.md`). Inbound links from other docs pages should point at the dedicated page. + +## Section layout + +`docs/` is organised into seven top-level sections, each driven by a reader concern: + +- `start/` — install + emanote-template. +- `authoring/` — input formats, link syntax, graph structures (folder notes, folgezettel), render features the author types (math, mermaid, syntax-highlighting, images), frontmatter-declared outputs (feed, export). +- `config/` — configuration surface: `yaml-config`, `layer`, `emanoteignore`, `i18n`. +- `theme/` — HTML/Heist template customisation (`html-template` and its sub-pages only). +- `extend/` — code-level extension: Pandoc Lua filters, MCP server. +- `external-tools/` — third-party tools that integrate with Emanote: editors (vim, vscode, obsidian, nota), Syncthing sync, zk. +- `reference/` — migration (`neuron`), examples, edge cases (`known-issues`). + +Place new pages in the section whose reader concern they match; cross-link via wikilinks rather than reorganising. The folder structure drives the sidebar tree; the page slug determines the public URL (see the slug rule above). diff --git a/.github/instructions/emanote.instructions.md b/.github/instructions/emanote.instructions.md new file mode 100644 index 000000000..c1c8c4cf8 --- /dev/null +++ b/.github/instructions/emanote.instructions.md @@ -0,0 +1,31 @@ +--- +description: Developing Emanote +applyTo: "**" +--- + +# Developing Emanote + +Expect humans to use /do to start work. + +## Internationalisation + +Emanote sites may use a single language, but that language is not necessarily English. When adding or changing user-facing strings in default templates, default JavaScript, or site-authored JavaScript, treat the change as i18n work: + +1. Keep literal fallback strings in code only as fallbacks. +2. Add or update the canonical string keys under `template.i18n` in `emanote/default/index.yaml` for every built-in language. +3. In JavaScript, read strings through `_emanote-static/js/i18n.js` helpers such as `text` or `message` instead of hard-coding visible UI text. +4. Add or update e2e coverage when the string is part of visible default UI chrome. + +## Dependencies + +Emanote depends on various dependencies (flake.nix inputs). When work requires changes to a dependency: + +1. Clone the dependency under `~/code` (if not already there) and create a branch there. +2. Do the work in the dependency repo and open a PR against it. +3. **The dependency PR is not a shortcut.** It MUST go through the full `/do` workflow exactly as Emanote PRs do — this is non-negotiable. In particular: + - Run `/code-police` on the dependency PR before requesting review. + - Run `/hickey` and `/lowy` on the dependency PR for structural and boundary review. + - Iterate on CI and review feedback in the dependency PR until it is green and clean. +4. Only after the dependency PR meets the same quality bar as an Emanote PR, wire it into Emanote by updating `flake.nix` inputs to point at the dependency branch/PR. + +Treat every dependency PR as a first-class PR. Skipping `/code-police`, `/hickey`, or `/lowy` on a dependency PR is a workflow violation, even if the change looks small. diff --git a/.mcp.json b/.mcp.json index 92e86fc69..b82fb15a9 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,9 @@ "mcpServers": { "chrome-devtools": { "command": "just", - "args": ["mcp-chrome-devtools"] + "args": [ + "mcp-chrome-devtools" + ] }, "emanote": { "type": "http", diff --git a/apm.lock.yaml b/apm.lock.yaml index 9ad84d261..2bdd9503f 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,5 +1,5 @@ lockfile_version: '1' -generated_at: '2026-05-25T16:12:06.762207+00:00' +generated_at: '2026-06-28T21:16:47.520879+00:00' apm_version: 0.14.2 dependencies: - repo_url: anthropics/skills @@ -12,6 +12,28 @@ dependencies: - .agents/skills/frontend-design - .claude/skills/frontend-design content_hash: sha256:063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67 +- repo_url: juspay/kolu + host: github.com + resolved_commit: d313096ebac029a507a6013c60b42cfb9b5b0058 + virtual_path: agents + is_virtual: true + package_type: apm_package + deployed_files: + - .agents/skills/be + - .agents/skills/be-review + - .agents/skills/codex-debate + - .agents/skills/kolu + - .agents/skills/lens-debate + - .agents/skills/perfection-review + - .agents/skills/surface + - .claude/skills/be + - .claude/skills/be-review + - .claude/skills/codex-debate + - .claude/skills/kolu + - .claude/skills/lens-debate + - .claude/skills/perfection-review + - .claude/skills/surface + content_hash: sha256:7ce5ffd10ce3d30f1bb6465c4cfb0ed269e16993ac8c3ff55c16ab9bb9594f79 - repo_url: juspay/nix-chrome-devtools-mcp host: github.com resolved_commit: 407e0abef0d0e5c7bc401819d0a815b8c49c6674 @@ -62,7 +84,7 @@ dependencies: content_hash: sha256:0a2fe4264d3e462ca50c94c2ce2eac49f48ca2bd25c27c64a789d9cd7c20c854 - repo_url: srid/agency host: github.com - resolved_commit: 16889eb67592c8448c8592e527953ce7272f625c + resolved_commit: 81455051ccca654c57ad6088838c85e7248ac159 resolved_ref: master package_type: apm_package deployed_files: @@ -91,6 +113,11 @@ dependencies: - .codex/agents/hickey.toml - .codex/agents/lowy.toml - .codex/hooks/agency/scripts/do-stop-guard.sh + - .github/agents/hickey.agent.md + - .github/agents/lowy.agent.md + - .github/hooks/agency-do-stop-guard.json + - .github/hooks/scripts/agency/scripts/do-stop-guard.sh + - .github/instructions/apm-sources.instructions.md deployed_file_hashes: .claude/agents/hickey.md: sha256:10d9a4350a85752dc6658af8e0efb3d8be41803a2b6c73464e3d5b7a840435ad .claude/agents/lowy.md: sha256:05aff7f6a91149a42c1fb9a3ca382a6f77bc39ac8a8d45436d443dd3349fb11e @@ -99,20 +126,57 @@ dependencies: .codex/agents/hickey.toml: sha256:f508bacedcac2916cf1e29a11f2ac161483ffc2acf182c8f1c00c26d14073c00 .codex/agents/lowy.toml: sha256:c57b72c43687ef255a4490f65984fe556325cb9847a5626e43c2a7addea049e4 .codex/hooks/agency/scripts/do-stop-guard.sh: sha256:cb033f86c15ffd3d09d8e4b74a61f55a70def4cc4131cc013b7368a2d23f3f33 - content_hash: sha256:5418127b67140ce58953d891bb9755a21fb6508f72f7453485b52358a77be324 + .github/agents/hickey.agent.md: sha256:10d9a4350a85752dc6658af8e0efb3d8be41803a2b6c73464e3d5b7a840435ad + .github/agents/lowy.agent.md: sha256:05aff7f6a91149a42c1fb9a3ca382a6f77bc39ac8a8d45436d443dd3349fb11e + .github/hooks/agency-do-stop-guard.json: sha256:c8e18f0e32fa72f98fdc21a14fa9cee7ac6cc55e018cfc27398ade39a4d7d2c0 + .github/hooks/scripts/agency/scripts/do-stop-guard.sh: sha256:cb033f86c15ffd3d09d8e4b74a61f55a70def4cc4131cc013b7368a2d23f3f33 + .github/instructions/apm-sources.instructions.md: sha256:222dca77aa6f495d0684db2531a18b580b6ad869d05cf160e242fc78ea0ccac5 + content_hash: sha256:e2144eca9c2932044bde96b74db5a546a2cec052f747be926abfc638ab684db9 +- repo_url: juspay/odu + host: github.com + resolved_commit: 721c66393fad9d82f7fe1766fdd2ffcd6f8cf579 + depth: 2 + resolved_by: juspay/kolu + package_type: apm_package + deployed_files: + - .agents/skills/ci + - .agents/skills/odu-mcp + - .claude/skills/ci + - .claude/skills/odu-mcp + content_hash: sha256:1b9686c051e74f620d8657acf432c87c1f4cb8fb0ba3c0bae3fa6d27f006a82a +- repo_url: juspay/project-unknown + host: github.com + resolved_commit: 4b02d628a9ac3b42ad4ff31cf12d11c439fa7a4e + depth: 2 + resolved_by: juspay/kolu + package_type: apm_package + deployed_files: + - .agents/skills/pu + - .claude/skills/pu + content_hash: sha256:ee915e29126ef6360924010ce35f88b71d271a08418a7e86bd7920703f295a5f mcp_servers: - chrome-devtools +- emanote mcp_configs: chrome-devtools: name: chrome-devtools transport: stdio registry: false command: .agents/skills/nix-chrome-devtools-mcp/bin/serve + emanote: + name: emanote + transport: http + registry: false + url: http://localhost:8079/mcp local_deployed_files: - .agents/skills/dpella-mcp - .claude/rules/emanote-docs.md - .claude/rules/emanote.md - .claude/skills/dpella-mcp +- .github/instructions/emanote-docs.instructions.md +- .github/instructions/emanote.instructions.md local_deployed_file_hashes: - .claude/rules/emanote-docs.md: sha256:fd71b77f8bb981e4daefc262bf70cc0d506e997c1004e26753d110eef31b3d42 + .claude/rules/emanote-docs.md: sha256:df2b1776b7c6f40c61322f90e45347d4a3b2de45d494470aa95aeca903c52c48 .claude/rules/emanote.md: sha256:d555a8211505067127b5198ae717b673c7bdd8d16c21b1c8c911163ab2a9d58f + .github/instructions/emanote-docs.instructions.md: sha256:a99df8c50aaea1c2788714d601ad24c6003144bcf4a897c04baac55d3aea8fa5 + .github/instructions/emanote.instructions.md: sha256:e9c47a94ef1f334962569b74085e0c6d446965063187a38888871d5eb2cd8aa1 diff --git a/apm.yml b/apm.yml index 39fb9166e..32230e8c1 100644 --- a/apm.yml +++ b/apm.yml @@ -10,6 +10,7 @@ targets: dependencies: apm: - srid/agency#master + - juspay/kolu/agents - juspay/skills/skills/nix-justfile - juspay/skills/skills/nix-haskell - juspay/skills/skills/nix-for-dev