diff --git a/.kern/self-coverage-baseline.json b/.kern/self-coverage-baseline.json index e3f3ced85..8c870855f 100644 --- a/.kern/self-coverage-baseline.json +++ b/.kern/self-coverage-baseline.json @@ -13,7 +13,7 @@ "blockedHandlers": 620 }, "blockerMaximums": { - "comments-present": 185, + "comments-present": 186, "foreign-missing-reason": 70, "var-bad-expr": 62, "for-stmt": 61, diff --git a/README.md b/README.md index e9d090869..30215e8cc 100644 --- a/README.md +++ b/README.md @@ -295,10 +295,24 @@ agon review branch:feat-x --base main # feat-x's commits vs main, regardl agon review range:main...feat-x # fully explicit two-ref scope ``` -Diff scope is deliberate, never implicit: `--base ` pins the base for `uncommitted`/`branch:` targets, and `range:BASE...TARGET` names both ends. A failed reviewer seat (timeout / hard error) is auto-retried once at half the wall clock before it's reported — and every failure is named in the run summary, never silently folded into a smaller panel. The same retry + loud `panel degraded:` banner applies to brainstorm, tribunal, and council seats. +Diff scope is deliberate, never implicit: `--base ` pins the base for `uncommitted`/`branch:` targets, and `range:BASE...TARGET` names both ends. One convenience: reviewing the branch you are currently **on** (where "branch vs itself" would be an empty diff) auto-bases against the repository's default branch — labelled `(auto-base)` in the run header so the scope is never silent. A failed reviewer seat (timeout / hard error) is auto-retried once at half the wall clock before it's reported — and every failure is named in the run summary, never silently folded into a smaller panel. The same retry + loud `panel degraded:` banner applies to brainstorm, tribunal, and council seats. Standard Review deliberately uses the full active panel even when the legacy `reviewDefaultEngine` preference is configured. Narrowing requires `--engine` or `--engines`. Explicit subsets are strict: an unknown, unavailable, or removed engine aborts the request instead of silently changing the committee. +#### Role-lens review + +`/review role` (REPL) and `agon review --roles` (CLI) run the **same** parallel panel, but each engine reviews through a focused lens — `security`, `correctness`, `dryness`, `performance` — plus an `overall` generalist backstop so coverage is never partitioned away. Roles narrow each reviewer's *attention* only: the diff, repo grounding, machine findings block, consensus merge, and results pager are byte-identical to a standard review, and a reviewer who spots a blocking issue outside its role must still flag it. + +```bash +/review role # REPL: deal the default role roster +/review role security,correctness uncommitted # REPL: explicit roles, zipped per engine +agon review --roles auto --risk auto # CLI: roles composed with risk routing +agon review --roles security,overall -e claude,codex +agon call review uncommitted --roles auto # external-CLI bridge (Claude Code, Codex, CI) +``` + +With `--roles auto` the fixed roster is dealt onto the selected panel in order and every extra engine lands on `overall`; an explicit comma list is zipped engine-by-engine (unknown role ids fall back to `overall`). Roles compose with `--risk`/`--primary-engine` routing — they change what each seat looks *at*, never how many seats there are. + ### Agent An autonomous agent loop that can operate solo or in shadow mode, automatically routed to the best engine by Cesar based on task requirements. @@ -410,6 +424,14 @@ When Cesar finishes a turn that edited files, Agon doesn't take "done" on faith. Note the posture semantics: a gate command classified read-only (like plain `npm test`) auto-runs in **every** mode, because the resolver lets read-only commands run freely — the same command Cesar could already execute unprompted. Mutating gate commands (compound `&&` gates, scripts the classifier can't clear) auto-run only in `auto` mode or under a covering allow rule; gates with output redirection are additionally refused outside `auto`. Everything else falls back to the previous behavior: a one-time nudge asking Cesar to verify. Tune with `cesarGateAutoRun` (on by default), `cesarGateAutoRunLimit`, `cesarGateTimeoutSec`, and `cesarGateOutputTailChars`. +#### Cesar self-inspection tools + +Beyond the gate, Cesar has three built-in tools for verifying its *own* surface and dispatch reliability — so "does the UI actually render what I claimed?" and "which engine keeps failing me?" are answerable in-turn instead of taken on faith: + +- **RenderProbe** — renders a registered Ink component (`StatusBar`, `TodoList`, `ChromeBar`, …) off-screen at a given width/height and returns the final frame as text. Pure in-process render; no side effects. +- **TuiProbe** — boots a real `agon` instance inside a pseudo-terminal (via `pyte` screen-state emulation, so the result is the **final composed grid**, not a stream of ANSI artifacts), optionally sends one safelisted input (`/help`, `/status`, `/todos`, `/plans`, `/checkpoints`), and returns the rendered screen. Fully isolated: throwaway `AGON_HOME`, temp cwd, single-line control-character-rejecting input filter. Requires `python3` with `pyte` installed. +- **EngineReliability** — a per-engine reliability digest with two honestly separated sections: Cesar's **own-turn** tool outcomes, and the **delegated dispatch ledger** (`~/.agon/runs/delegate-tool-ledger.jsonl`), which records every delegate dispatch per engine × backend (`api-loop`, `cli-print`, `companion`) with native-vs-heuristic provenance — a dispatch that *failed* is recorded as `dispatchFailed`, never blurred into "unknown". + **Example Session:** ```text $ agon diff --git a/packages/cli/package.json b/packages/cli/package.json index ad960e1cf..4c65d38f2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ }, "files": [ "dist", + "py", "README.md", "LICENSE" ], diff --git a/packages/cli/py/agon-tui-probe.py b/packages/cli/py/agon-tui-probe.py new file mode 100644 index 000000000..8774c9798 --- /dev/null +++ b/packages/cli/py/agon-tui-probe.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""TuiProbe (tier 2) — PTY probe of AGON's OWN Ink TUI. + +Adapted from ``scripts/claude-tui-probe.py`` (same robustness spine: a +non-blocking ``select()`` read loop, a boot→ready→sent→done state machine, +bounded SIGTERM→SIGKILL→reap teardown, a ``faulthandler`` SIGUSR1 stack dump, +and a hard overall timeout). Two deliberate departures, both mandated by +``.claude/specs/cesar-self-render/spec.md``: + + 1. SCREEN-STATE CAPTURE, not ANSI stripping. The Claude probe concatenates + raw bytes and regex-strips ANSI at the end — that yields a *transcript* + artifact where stale intermediate text ("Loading") survives next to the + final text ("Ready") because the cursor-motion / erase sequences that + would have overwritten it were thrown away. Here every PTY byte is fed + through a ``pyte`` ``Screen`` + ``ByteStream`` sized to --cols/--rows, and + we emit the FINAL grid (``screen.display`` joined, per-line trailing-space + rstrip). That is what the terminal actually shows. + + 2. REAL ISOLATION. The child runs under a throwaway ``AGON_HOME`` (mkdtemp) + AND a separate empty ``cwd`` (mkdtemp), with a minimal throwaway + ``config.json`` (empty engine roster, onboarding pre-completed). The input + is restricted to a safelist of NON-dispatching slash commands. v1 is a + chrome/layout probe and must never trigger engine dispatch. + +READY MARKER (spec Open Question, resolved here): + We anchor "ready" on the string ``AGON`` appearing in the pyte screen state. + Source: the ChromeBar renders ``{'AGON'}`` + at ``packages/cli/src/kern/surfaces/app-views.kern:144`` (chat-mode branch). + The composer's own chat prompt caret is ``'> '`` at + ``packages/cli/src/kern/blocks/composer.kern:141`` — but a bare ``>`` is not + distinctive in a full-screen grid, whereas ``AGON`` uniquely identifies + agon's fully-rendered chat chrome. Both the ChromeBar and the composer input + line render together in the same bottom-chrome frame, so ``AGON`` present == + the composer is ready for input. We poll the SCREEN STATE (not raw bytes) + for the marker, per spec requirement 5. + +Output: JSON to stdout. + success -> {"frame": "", "durationMs": N, "state": "done"} + failure -> {"error": "..."} + ALWAYS exits 0 (model_probe.py convention). + +Run: + python3 scripts/agon-tui-probe.py --debug + python3 scripts/agon-tui-probe.py --input '/status' --cols 100 --rows 30 + +DO NOT rely on this touching the real ~/.agon — it deliberately does not. +""" + +from __future__ import annotations + +import argparse +import errno +import faulthandler +import fcntl +import json +import os +import pty +import select +import shutil +import signal +import struct +import sys +import tempfile +import termios +import time +from dataclasses import dataclass, field +from typing import Optional + +# SIGUSR1 → dump every thread's stack to stderr. Useful when hunting hangs. +faulthandler.enable() +try: + faulthandler.register(signal.SIGUSR1, all_threads=True, chain=False) +except (AttributeError, ValueError): # pragma: no cover + pass + + +# ── pyte import ───────────────────────────────────────────────────────────── +# Prefer the same import path kern_engines uses if that package is importable; +# fall back to a plain ``import pyte``. If pyte is missing entirely, the module +# still loads — main() reports {"error": "pyte not installed"} and exits 0. +_PYTE_AVAILABLE = False +try: + import pyte # noqa: F401 + + _PYTE_AVAILABLE = True +except Exception: + _PYTE_AVAILABLE = False + + +# ── safelist ──────────────────────────────────────────────────────────────── +# v1 is a layout probe: only NON-dispatching slash commands are allowed. Any +# input that would route to an engine (chat text, /review, /council, …) is +# refused so the probe can never spend tokens or mutate anything. +_ALLOWED_INPUT_PREFIXES = ("/help", "/status", "/todos", "/plans", "/checkpoints") + + +def _input_is_safe(text: str) -> bool: + stripped = text.strip() + # Reject ANY control character (incl. \n, \r, ESC): the PTY treats a + # newline as "submit", so '/help x\n/forge y' would smuggle a second, + # engine-dispatching command past a prefix-only check (agon-review + # blocking finding). One line, printable characters only. + if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in stripped): + return False + # Prefix must be the whole command or be followed by a space — + # '/helpanything' is not '/help'. + return any( + stripped == p or stripped.startswith(p + " ") + for p in _ALLOWED_INPUT_PREFIXES + ) + + +# ── paths ─────────────────────────────────────────────────────────────────── + + +def _package_root() -> str: + # /py/agon-tui-probe.py → /py → (works both in the repo + # worktree at packages/cli/ and in the installed @kernlang/agon package, + # which ships py/ + dist/ side by side). + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _default_agon_bin() -> str: + return os.path.join(_package_root(), "dist", "index.js") + + +# ── pty helpers ───────────────────────────────────────────────────────────── + + +def _set_winsize(fd: int, rows: int, cols: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def _is_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + +def _terminate(pid: int, grace_s: float) -> None: + """SIGTERM → bounded grace → SIGKILL → bounded reap. No syscall in this + path may block longer than the configured deadlines, even if some helper + the child spawned keeps the pty open.""" + if _is_alive(pid): + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + deadline = time.monotonic() + grace_s + while time.monotonic() < deadline: + try: + wpid, _ = os.waitpid(pid, os.WNOHANG) + if wpid != 0: + return + except ChildProcessError: + return + time.sleep(0.05) + if _is_alive(pid): + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + reap_deadline = time.monotonic() + 1.0 + while time.monotonic() < reap_deadline: + try: + wpid, _ = os.waitpid(pid, os.WNOHANG) + if wpid != 0: + return + except ChildProcessError: + return + time.sleep(0.05) + + +# ── config ────────────────────────────────────────────────────────────────── + + +def _write_throwaway_config(agon_home: str) -> None: + """Write a minimal throwaway /config.json. + + Shape mirrors the real config's structure (see + packages/core/src/kern/signals/config.kern + models/types.kern) but carries + NO real values — only what the probe needs: + + - onboarded: true → skip the interactive onboarding flow that a + fresh AGON_HOME would otherwise launch + (packages/cli/src/index.ts:207), which would + block the probe forever waiting for input. + - cesarAutoModePrompted: true + cesarAutoMode: false + → skip the startup "Enable AUTO mode?" MODAL + QUESTION (app.kern:1010 returns early when + this is true). Without it the modal grabs + focus and swallows the scripted keystrokes. + - engineActivationMode: explicit + forgeEnabledEngines: [] + → keep the forge roster empty. NOTE: agon still + DETECTS installed engine CLIs on PATH (the + ChromeBar shows a non-zero "N engines"); the + real dispatch guard is the input safelist, + not the roster. A fully empty detected roster + would require stubbing PATH — out of scope + for a layout probe. + - isolationMigrationNotified: true + → suppress the one-time workspace-purity banner + (non-blocking, just cleaner frames). + """ + os.makedirs(agon_home, exist_ok=True) + config = { + "onboarded": True, + "cesarAutoModePrompted": True, + "cesarAutoMode": False, + "engineActivationMode": "explicit", + "forgeEnabledEngines": [], + "isolationMigrationNotified": True, + } + with open(os.path.join(agon_home, "config.json"), "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + + +# ── screen capture ────────────────────────────────────────────────────────── + + +class _ScreenCapture: + """Wraps a pyte Screen+ByteStream. Feeding is defensive: a pyte + exception can never wedge the probe (the overall signal.alarm ceiling is + the ultimate backstop, but we also swallow per-chunk feed errors so one + bad byte sequence doesn't abort the whole capture).""" + + def __init__(self, cols: int, rows: int) -> None: + self._screen = pyte.Screen(cols, rows) + self._stream = pyte.ByteStream(self._screen) + + def feed(self, chunk: bytes) -> None: + try: + self._stream.feed(chunk) + except Exception: + # A malformed sequence must not kill the probe. Drop it; the grid + # keeps whatever state it had. The alarm ceiling guards true hangs. + pass + + def grid(self) -> str: + # screen.display is a list of fixed-width rows (space-padded). Rstrip + # each line's trailing spaces, then join. Do NOT strip leading spaces — + # layout/indentation is exactly what a layout probe must preserve. + try: + lines = self._screen.display + except Exception: + return "" + return "\n".join(line.rstrip() for line in lines) + + def contains(self, needle: str) -> bool: + return needle in self.grid() + + +# ── probe config ──────────────────────────────────────────────────────────── + + +@dataclass +class ProbeConfig: + cols: int = 120 + rows: int = 40 + chunk_size: int = 16384 + poll_interval_s: float = 0.05 + boot_min_ms: int = 800 + ready_marker: str = "AGON" # ChromeBar, app-views.kern:144 (see module docstring) + ready_settle_idle_ms: int = 400 + response_idle_ms: int = 1200 + overall_timeout_s: float = 45.0 + sigterm_grace_s: float = 2.0 + agon_bin: str = field(default_factory=_default_agon_bin) + + +@dataclass +class ProbeResult: + frame: str + duration_ms: int + state: str + state_history: list[str] + + +# ── env ───────────────────────────────────────────────────────────────────── + + +def _sanitize_child_env(agon_home: str) -> None: + """Runs in the forked child before exec. Strip session-leak env vars, then + pin the isolation vars. Called after fork so it mutates the child's copy of + os.environ only.""" + # Drop anything that would make a child think it is inside an existing + # Claude Code / agon session, or that points at the real agon home. + for var in list(os.environ.keys()): + if var in ("CLAUDECODE",) or var.startswith("CLAUDE_CODE_"): + os.environ.pop(var, None) + elif var.startswith("AGON_"): + # Strip ALL AGON_* — we re-set exactly AGON_HOME below. This clears + # AGON_CONTINUE / AGON_PERF / AGON_NO_EVENT_LOG etc. from the parent. + os.environ.pop(var, None) + os.environ["AGON_HOME"] = agon_home + os.environ["TERM"] = "xterm-256color" + os.environ.setdefault("LANG", "en_US.UTF-8") + + +# ── main probe ────────────────────────────────────────────────────────────── + + +def run_probe( + input_text: str, + cfg: ProbeConfig, + *, + debug: Optional[object] = None, +) -> ProbeResult: + agon_home = tempfile.mkdtemp(prefix="agon-probe-home-") + child_cwd = tempfile.mkdtemp(prefix="agon-probe-cwd-") + _write_throwaway_config(agon_home) + + def _dbg(msg: str) -> None: + if debug is not None: + debug.write(msg + "\n") + debug.flush() + + _dbg(f"[setup] AGON_HOME={agon_home}") + _dbg(f"[setup] cwd={child_cwd}") + _dbg(f"[setup] agon_bin={cfg.agon_bin}") + + pid, fd = pty.fork() + if pid == 0: + # ── child ── + _sanitize_child_env(agon_home) + try: + os.chdir(child_cwd) + except OSError as e: + # The throwaway cwd is the isolation boundary — running agon in an + # inherited directory instead would silently break that contract. + sys.stderr.write(f"cannot enter isolated cwd {child_cwd}: {e}\n") + os._exit(126) + try: + os.execvp("node", ["node", cfg.agon_bin]) + except FileNotFoundError: + sys.stderr.write("node binary not found on PATH\n") + os._exit(127) + os._exit(127) + + # ── parent ── + _set_winsize(fd, cfg.rows, cfg.cols) + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + screen = _ScreenCapture(cfg.cols, cfg.rows) + to_send = input_text.encode("utf-8", errors="replace") + b"\r" + + start = time.monotonic() + last_byte_at = start + last_tick_log = start + got_bytes_since_send = False + state = "boot" + state_history: list[str] = [state] + + try: + while True: + now = time.monotonic() + elapsed = now - start + if elapsed > cfg.overall_timeout_s: + raise TimeoutError( + f"probe timeout {cfg.overall_timeout_s}s in state={state}" + ) + + rdy, _, _ = select.select([fd], [], [], cfg.poll_interval_s) + chunk = b"" + if rdy: + try: + chunk = os.read(fd, cfg.chunk_size) + except BlockingIOError: + chunk = b"" + except OSError as e: + if e.errno in (errno.EIO, errno.EBADF): + chunk = b"" + else: + raise + if chunk: + screen.feed(chunk) + last_byte_at = now + if state == "sent": + got_bytes_since_send = True + + idle_ms = (now - last_byte_at) * 1000.0 + + if debug is not None and now - last_tick_log >= 1.0: + _dbg( + f"[t={elapsed:5.1f}s {state:5s}] idle={idle_ms:5.0f}ms " + f"marker={'Y' if screen.contains(cfg.ready_marker) else 'n'}" + ) + last_tick_log = now + + if state == "boot": + # Ready == the ChromeBar marker is on the SCREEN (not just in + # the raw byte stream) AND the frame has settled for a beat. + if ( + elapsed * 1000.0 > cfg.boot_min_ms + and screen.contains(cfg.ready_marker) + and idle_ms > cfg.ready_settle_idle_ms + ): + state = "ready" + state_history.append(state) + _dbg(f"[ready] after {elapsed:.2f}s") + + elif state == "ready": + os.write(fd, to_send) + state = "sent" + state_history.append(state) + _dbg(f"[sent] {input_text!r}") + + elif state == "sent": + # Done == the post-send render has gone idle. A layout probe + # only needs the frame to stop changing; the marker must still + # be present (it always is in chat mode). + if got_bytes_since_send and idle_ms > cfg.response_idle_ms: + state = "done" + state_history.append(state) + _dbg(f"[done] after {elapsed:.2f}s") + break + + if not _is_alive(pid): + state_history.append("child-exited") + _dbg("[child-exited]") + break + + return ProbeResult( + frame=screen.grid(), + duration_ms=int((time.monotonic() - start) * 1000.0), + state=state, + state_history=state_history, + ) + finally: + _terminate(pid, cfg.sigterm_grace_s) + try: + os.close(fd) + except OSError as e: + _dbg(f"[cleanup] pty fd close failed (already closed?): {e}") + # Best-effort cleanup of the throwaway dirs. + for d in (agon_home, child_cwd): + try: + shutil.rmtree(d, ignore_errors=True) + except Exception as e: + _dbg(f"[cleanup] rmtree {d} failed: {e}") + + +# ── CLI ───────────────────────────────────────────────────────────────────── + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="PTY probe of agon's own Ink TUI") + p.add_argument("--input", default="/help", help="scripted input (safelisted)") + p.add_argument("--cols", type=int, default=120) + p.add_argument("--rows", type=int, default=40) + p.add_argument("--timeout", type=float, default=45.0) + p.add_argument("--agon-bin", default=None, help="path to agon dist/index.js") + p.add_argument("--debug", action="store_true") + return p.parse_args() + + +def _emit(obj: dict) -> None: + sys.stdout.write(json.dumps(obj)) + sys.stdout.write("\n") + sys.stdout.flush() + + +def main() -> int: + args = _parse_args() + debug = sys.stderr if args.debug else None + + if not _PYTE_AVAILABLE: + _emit({"error": "pyte not installed"}) + return 0 + + if not _input_is_safe(args.input): + _emit( + { + "error": ( + f"refused unsafe input {args.input!r}; allowed prefixes: " + + ", ".join(_ALLOWED_INPUT_PREFIXES) + ) + } + ) + return 0 + + agon_bin = args.agon_bin or _default_agon_bin() + if not os.path.isfile(agon_bin): + _emit( + { + "error": ( + f"agon bin not found: {agon_bin} " + "(run `npm run build` from the worktree root first)" + ) + } + ) + return 0 + + cfg = ProbeConfig( + cols=args.cols, + rows=args.rows, + overall_timeout_s=args.timeout, + agon_bin=agon_bin, + ) + + # HARD BACKSTOP: an OS-level alarm that fires even if the read loop or a + # pyte feed wedges. The loop enforces cfg.overall_timeout_s on its own; this + # is the belt-and-suspenders ceiling required by the spec ("the overall + # signal.alarm ceiling must always fire"). Give it slack over the loop + # timeout so the loop's own graceful TimeoutError normally wins. + def _on_alarm(_signum, _frame): + raise TimeoutError(f"hard alarm ceiling {int(args.timeout) + 8}s fired") + + prev_handler = signal.signal(signal.SIGALRM, _on_alarm) + signal.alarm(int(args.timeout) + 8) + try: + result = run_probe(args.input, cfg, debug=debug) + except TimeoutError as e: + _emit({"error": f"timeout: {e}"}) + return 0 + except Exception as e: # never leak a stack trace to the caller + _emit({"error": f"{type(e).__name__}: {e}"}) + return 0 + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, prev_handler) + + if debug is not None: + debug.write( + f"\n--- state ---\n{' -> '.join(result.state_history)}\n" + f"--- duration ---\n{result.duration_ms}ms\n" + ) + debug.flush() + + if result.state != "done": + _emit( + { + "error": ( + f"probe ended in state={result.state} " + f"(history: {' -> '.join(result.state_history)})" + ) + } + ) + return 0 + + _emit( + { + "frame": result.frame, + "durationMs": result.duration_ms, + "state": result.state, + } + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 75809b63f..39e7588f6 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -9,6 +9,7 @@ import type { EngineDefinition, RunStatusEngine } from '@kernlang/agon-core'; import { createCliAdapter } from '@kernlang/agon-adapter-cli'; import { resolveBuiltinEnginesDir } from '../generated/lib/engines-dir.js'; import { + assignReviewRoles, remainingReviewRetrySeconds, resolveReviewTarget, reviewOutcome, @@ -87,6 +88,10 @@ export const reviewCommand = defineCommand({ type: 'string', description: 'Engine that implemented the diff. Automatic routing excludes its adapter identity from required independent review seats; omission widens to high risk.', }, + roles: { + type: 'string', + description: "Role-lens review (same panel, focused attention): 'auto' deals the fixed roster (security, correctness, dryness, performance, overall) onto the selected engines in order with an overall backstop for extras, or pass a comma-separated role list zipped engine-by-engine (unknown ids fall back to overall). Composes with automatic risk routing — roles change each reviewer's lens, never the panel or the machine contract.", + }, label: { type: 'string', description: 'Human-readable suffix baked into the run dir name.', @@ -245,6 +250,18 @@ export const reviewCommand = defineCommand({ if (args.quiet) process.env.AGON_QUIET = '1'; const quiet = process.env.AGON_QUIET === '1'; + // Role-lens assignment happens AFTER routing/explicit selection so roles map + // onto the final panel. 'auto' = deal the fixed roster in order (extras land + // on the overall backstop); an explicit list is zipped engine-by-engine. + const rawRoles = args.roles != null ? String(args.roles).trim() : ''; + const roleByEngine = rawRoles + ? assignReviewRoles( + requested, + rawRoles.toLowerCase() === 'auto' + ? undefined + : rawRoles.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + ) + : null; const concurrencyNote = requested.length > 1 ? (maxParallel >= requested.length ? 'all in parallel' : `${maxParallel} at a time`) : 'single engine'; @@ -257,6 +274,9 @@ export const reviewCommand = defineCommand({ info(`Routing manifest: ${routingManifestPath}`); } info(`Engines: ${requested.join(', ')} (${concurrencyNote})`); + if (roleByEngine) { + info(`Roles: ${requested.map((id) => `${id}=${roleByEngine.get(id)?.id ?? 'overall'}`).join(' · ')}`); + } info(`Per-engine timeout: ${timeoutSec}s (auto-cancel, others unaffected)`); } @@ -282,7 +302,9 @@ export const reviewCommand = defineCommand({ catch (writeErr) { if (!quiet) console.log(`\n⚠ ${engineId}: failed to write output file (${writeErr instanceof Error ? writeErr.message : String(writeErr)})`); } }; // Flush a single labeled block so concurrent engines never interleave mid-line. - const flush = (body: string[]) => { if (!quiet && body.length) console.log(`\n▸ Reviewer: ${bold(engineId)}\n${body.join('\n')}`); }; + const reviewRoleId = roleByEngine?.get(engineId)?.id; + const roleTag = reviewRoleId ? ` [${reviewRoleId}]` : ''; + const flush = (body: string[]) => { if (!quiet && body.length) console.log(`\n▸ Reviewer: ${bold(engineId)}${roleTag}\n${body.join('\n')}`); }; // One dispatch attempt under its own wall clock. Pin the engine dispatch to // the SAME cwd the diff came from (process.cwd()). This standalone `agon // review` command never calls setSessionRoot(), so runReviewCore's @@ -299,7 +321,7 @@ export const reviewCommand = defineCommand({ let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, attemptTimeoutSec * 1000); try { - const result = await runReviewCore(target.diff, target.label, engineId, ctx, controller.signal, undefined, cwd); + const result = await runReviewCore(target.diff, target.label, engineId, ctx, controller.signal, undefined, cwd, reviewRoleId); // Keep partial text on disk for forensics, but the outcome is a timeout // regardless of what runReviewCore returned on abort. if (timedOut) { writeOutput(result.response ?? ''); return { kind: 'timeout', afterSec: attemptTimeoutSec }; } diff --git a/packages/cli/src/generated/blocks/frame-capture.ts b/packages/cli/src/generated/blocks/frame-capture.ts new file mode 100644 index 000000000..0865f8951 --- /dev/null +++ b/packages/cli/src/generated/blocks/frame-capture.ts @@ -0,0 +1,96 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/frame-capture.kern + +import { PassThrough } from 'node:stream'; + +import React from 'react'; + +import { render } from 'ink'; + +// @kern-source: frame-capture:17 +export interface PseudoTty { + stdout: any; + stderr: any; + stdin: any; + chunks: string[]; + lastFrame: () => string; + read: () => string; +} + +/** + * Strip OSC/CSI terminal control sequences and carriage returns from a captured stream. + */ +// @kern-source: frame-capture:25 +export function stripTerminalControl(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, ''); +} + +/** + * Create a fake TTY stdout/stderr/stdin trio that records each stdout write as a separate chunk. + */ +// @kern-source: frame-capture:40 +export function createPseudoTty(width: number, height: number): PseudoTty { + const stdout = new PassThrough() as PassThrough & { isTTY: boolean; columns: number; rows: number }; + stdout.isTTY = true; + stdout.columns = width; + stdout.rows = height; + const stderr = new PassThrough() as PassThrough & { isTTY: boolean }; + stderr.isTTY = true; + const stdin = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode: (mode: boolean) => void }; + stdin.isTTY = true; + stdin.setRawMode = () => {}; + const chunks: string[] = []; + stdout.on('data', (chunk: Buffer | string) => { chunks.push(chunk.toString()); }); + return { + stdout, + stderr, + stdin, + chunks, + // Final settled frame: the LAST full-frame write with real content, + // ANSI-stripped. Never the concatenation — that is a transcript artifact + // with stale renders in it. We scan backwards for the last non-empty + // chunk because Ink's unmount appends a trailing clear write (an empty + // frame), which is not the settled viewport. + lastFrame: () => { + for (let i = chunks.length - 1; i >= 0; i--) { + const stripped = stripTerminalControl(chunks[i]); + if (stripped.trim().length > 0) return stripped; + } + return ''; + }, + // Legacy accumulator: the whole transcript joined. Substring-style tests + // that predate the final-frame fix rely on this. + read: () => stripTerminalControl(chunks.join('')), + }; +} + +/** + * Render an Ink component in an isolated pseudo-TTY at the given size and return the final ANSI-stripped frame. Unmounts before returning; leaves no open handles. + */ +// @kern-source: frame-capture:77 +export async function captureSurfaceFrame(component: any, props: Record, cols: number, rows: number): Promise { + const tty = createPseudoTty(cols, rows); + const app = render(React.createElement(component, props as any), { + stdout: tty.stdout as any, + stderr: tty.stderr as any, + stdin: tty.stdin as any, + debug: true, + exitOnCtrlC: false, + patchConsole: false, + }); + let frame = ''; + try { + // Let effects flush and the final frame settle before capturing. Capture + // the settled frame BEFORE unmount so a trailing clear write can never + // race the read. + await new Promise((resolve) => setTimeout(resolve, 30)); + frame = tty.lastFrame(); + } finally { + app.unmount(); + } + // Drain the unmount write so no listener fires after we return. + await new Promise((resolve) => setTimeout(resolve, 5)); + return frame; +} diff --git a/packages/cli/src/generated/blocks/todo-list.entry.tsx b/packages/cli/src/generated/blocks/todo-list.entry.tsx index 31f2ace08..9d3b424da 100644 --- a/packages/cli/src/generated/blocks/todo-list.entry.tsx +++ b/packages/cli/src/generated/blocks/todo-list.entry.tsx @@ -1,7 +1,7 @@ #!/usr/bin/env node -// @generated by kern v3.5.3 — DO NOT EDIT. Source: src/kern/blocks/todo-list.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/todo-list.kern -// @kern-source: todo-list:14 +// @kern-source: todo-list:16 import React from 'react'; import { render } from 'ink'; diff --git a/packages/cli/src/generated/blocks/todo-list.tsx b/packages/cli/src/generated/blocks/todo-list.tsx index d9b68ffd7..d3241c299 100644 --- a/packages/cli/src/generated/blocks/todo-list.tsx +++ b/packages/cli/src/generated/blocks/todo-list.tsx @@ -1,4 +1,4 @@ -// @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/blocks/todo-list.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/todo-list.kern import React from 'react'; import { Box, Text } from 'ink'; @@ -6,7 +6,7 @@ import { Box, Text } from 'ink'; // ── Core ─────────────────────────────────────────────── import type { Todo } from '../signals/todos.js'; -// @kern-source: todo-list:14 +// @kern-source: todo-list:16 const TodoList = React.memo(function TodoList({ todos, planActive }: { todos:Todo[]; planActive?:boolean }) { if (!todos || todos.length === 0) return null; // While the bottom-chrome PlanChip is showing it already carries the plan @@ -50,5 +50,5 @@ const TodoList = React.memo(function TodoList({ todos, planActive }: { todos:Tod }); export { TodoList }; -// @kern-source: todo-list:12 +// @kern-source: todo-list:14 export const TODO_STATE_ICONS: Record = ({ pending: { icon: '○', color: '#64748b' }, running: { icon: '●', color: '#fbbf24' }, done: { icon: '✓', color: '#22c55e' }, failed: { icon: '✗', color: '#ef4444' }, cancelled: { icon: '—', color: '#64748b' } }); diff --git a/packages/cli/src/generated/cesar/session.ts b/packages/cli/src/generated/cesar/session.ts index 4b3f23d85..f3ca60a8b 100644 --- a/packages/cli/src/generated/cesar/session.ts +++ b/packages/cli/src/generated/cesar/session.ts @@ -20,6 +20,8 @@ import type { ToolContext, ToolCallResult } from '@kernlang/agon-core'; import { resolveGuardMode, readGuardModesFromConfig } from '@kernlang/agon-core'; +import { recordTextTransportDispatch, textTransportDigest } from '@kernlang/agon-core'; + import type { GuardMode } from '@kernlang/agon-core'; import type { HandlerContext } from '../../handlers/types.js'; @@ -46,7 +48,7 @@ import { recordCesarApprovalDecision, recordCesarToolTimeline, recordCesarConfid import { resolveCesarHarnessProfile, isAgenticAutoMode } from './task-controller.js'; -// @kern-source: session:25 +// @kern-source: session:26 export const CESAR_SYSTEM_PROMPT: string = `You are Cesar, Agon AI orchestrator. CHARACTER — the most trusted advisor who doesn't need you to like him. @@ -210,7 +212,7 @@ RULE 10 — TURN CLOSURE: End every turn with one clear closing line so the user /** * Compact controller prompt for agentic AUTO. Deterministic tool leases, task state, epochs, and verification enforce the mechanics; this prompt states intent instead of duplicating the implementation manual. Keep below 10,000 characters before project/tool context. */ -// @kern-source: session:188 +// @kern-source: session:189 export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ "You are Cesar, Agon's autonomous coding orchestrator. Be precise, direct, calm, and useful. Match the user's language and level. Lead with outcomes, not process narration.", '', 'TASK OWNERSHIP', @@ -247,25 +249,25 @@ export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ /** * The EXACT RULE 1 — CONFIDENCE paragraph baked into CESAR_SYSTEM_PROMPT (the every-turn ReportConfidence ceremony). Held here verbatim so the invariants-mode rewrite is an exact string replacement: strict/shadow keep CESAR_SYSTEM_PROMPT byte-identical, invariants swaps this paragraph for CESAR_RULE_1_INVARIANTS. If RULE 1's wording in CESAR_SYSTEM_PROMPT ever changes, this const MUST change in lockstep or the replacement silently no-ops (the prompt stays strict). applyInvariantsRule1 fail-safes to the strict prompt on a mismatch AND emits a one-time console.warn so the drift is observable instead of silent. */ -// @kern-source: session:222 +// @kern-source: session:223 export const CESAR_RULE_1_STRICT: string = `RULE 1 — CONFIDENCE: Call ReportConfidence(value) FIRST on every turn. If you cannot call tools, write ~X% at the very start instead. No exceptions. On the FIRST turn about a topic, low confidence is expected — investigate, then report your INFORMED confidence. BUT you carry the whole conversation: files you already read, searches you already ran, and conclusions you already reached EARLIER THIS SESSION are still valid context — build on them and report informed confidence immediately. Re-read a file ONLY if it changed or you never saw it. Do NOT restart every turn from zero with "let me check what's going on" when the answer is already in your history — re-discovering what you already know makes you look lost and wastes the user's time.`; /** * RULE 1 rewrite for guard mode 'invariants'. The GuardPipeline's grounded-write/evidence invariants now ENFORCE the confidence signal structurally (a well-formed Edit after a Read IS the proof), so the every-turn ReportConfidence ceremony is demoted to on-demand. RULE 1b is kept verbatim via the strict template — only this paragraph is swapped. */ -// @kern-source: session:228 +// @kern-source: session:229 export const CESAR_RULE_1_INVARIANTS: string = `RULE 1 — CONFIDENCE: Report confidence via ReportConfidence ONLY when you are about to run a risky command (Bash mutations, multi-file writes, delegation) or when genuinely uncertain. Do NOT call it ritually every turn — a well-formed Edit after reading the file IS the confidence signal.`; /** * FIX 3 (R4) — module-level once-flag for applyInvariantsRule1's drift warning. A mutable {warned} holder (mutated in place, never frozen at module load) so the console.warn fires AT MOST ONCE per process even though buildCesarSystemPrompt calls applyInvariantsRule1 on every invariants-mode prompt assembly. Resettable in tests via the exported _resetInvariantsRule1DriftWarning seam. */ -// @kern-source: session:234 +// @kern-source: session:235 export const invariantsRule1DriftState = { warned: false }; /** * Test-only seam: reset the once-flag so a unit test can re-trigger applyInvariantsRule1's drift warning with a deliberately drifted prompt. Not used in production. */ -// @kern-source: session:237 +// @kern-source: session:238 export function _resetInvariantsRule1DriftWarning(): void { invariantsRule1DriftState.warned = false; } @@ -273,7 +275,7 @@ export function _resetInvariantsRule1DriftWarning(): void { /** * Rewrite the every-turn RULE 1 — CONFIDENCE ceremony to the on-demand 'invariants' form. Pure string transform on the assembled CESAR_SYSTEM_PROMPT: replaces the exact CESAR_RULE_1_STRICT paragraph with CESAR_RULE_1_INVARIANTS, leaving RULE 1b and everything else byte-identical. Only called on guard mode 'invariants' — strict/shadow never reach here, so the base prompt stays byte-identical for them by construction. If the strict text isn't found (RULE 1 wording in CESAR_SYSTEM_PROMPT drifted out of sync with the CESAR_RULE_1_STRICT const) it FAILS SAFE: it returns the prompt UNCHANGED (serving the stricter every-turn ceremony rather than silently dropping RULE 1) AND emits a one-time console.warn so the drift is observable instead of passing unnoticed. The warning is gated by a module-level once-flag (invariantsRule1DriftState) so it fires at most once per process despite the per-turn call cadence. */ -// @kern-source: session:243 +// @kern-source: session:244 export function applyInvariantsRule1(prompt: string): string { if (!prompt.includes(CESAR_RULE_1_STRICT)) { if (!invariantsRule1DriftState.warned) { @@ -288,19 +290,19 @@ export function applyInvariantsRule1(prompt: string): string { /** * FIX 6a — re-read ~/.agon/config.json's guardModes at most once per 60s. resolveCesarGuardMode runs on EVERY prompt assembly; the config rarely changes mid-session, so a minute-stale view is fine and keeps the synchronous file read off the per-turn prompt-build path. Mirrors GUARD_TELEMETRY_SNAPSHOT_TTL_MS in status-helpers. */ -// @kern-source: session:256 +// @kern-source: session:257 export const GUARD_MODES_CONFIG_TTL_MS: number = 60 * 1000; /** * FIX 6a — module-level {at, home, value} memo for readGuardModesFromConfig(). `home` keys the entry to the AGON_HOME that produced it so an in-process AGON_HOME change (tests, embedded use) can never serve another home's config for up to a TTL. Mutated in place; never frozen at module load. */ -// @kern-source: session:259 +// @kern-source: session:260 export const guardModesConfigCache = { at: 0, home: '', value: null as (ReturnType) }; /** * FIX 6a — memoized wrapper over readGuardModesFromConfig(): the synchronous ~/.agon/config.json read happens at most once per GUARD_MODES_CONFIG_TTL_MS, keyed by AGON_HOME so an in-process home change invalidates. Best-effort: a read failure caches null for the TTL. Mirrors loadGuardTelemetrySnapshot's memo pattern in status-helpers.kern. */ -// @kern-source: session:262 +// @kern-source: session:263 function readGuardModesFromConfigMemoized(): ReturnType { const now = Date.now(); const home = process.env.AGON_HOME?.trim() ?? ''; @@ -326,7 +328,7 @@ function readGuardModesFromConfigMemoized(): ReturnTypePromise): Promise { const targetCwd = cwd ?? resolveWorkingDir(); let spine = ''; @@ -587,19 +589,19 @@ export async function prepareCesarSystemPrompt(ctx: HandlerContext, cwd?: string return buildCesarSystemPrompt(ctx); } -// @kern-source: session:544 +// @kern-source: session:545 export const CESAR_SNAPSHOT_MSG_CHAR_CAP: number = 4000; -// @kern-source: session:546 +// @kern-source: session:547 export const CONFIDENCE_BLOCK_LIMIT: number = 2; -// @kern-source: session:548 +// @kern-source: session:549 export const SEARCH_NUDGE_THRESHOLD: number = 40; /** * Bound one message's text to CESAR_SNAPSHOT_MSG_CHAR_CAP with a truncation marker. Applied on BOTH snapshot paths (direct session history AND the chat-transcript fallback) so oversized content never floods Cesar's continuity context regardless of which path produced it. */ -// @kern-source: session:550 +// @kern-source: session:551 export function capSnapshotMessageContent(content: string): string { if (content.length <= CESAR_SNAPSHOT_MSG_CHAR_CAP) return content; return `${content.slice(0, CESAR_SNAPSHOT_MSG_CHAR_CAP)}\n… [${content.length - CESAR_SNAPSHOT_MSG_CHAR_CAP} chars truncated for Cesar context]`; @@ -608,7 +610,7 @@ export function capSnapshotMessageContent(content: string): string { /** * Render the `command` string shown in a Cesar permission prompt for a tool call. SaveMemory renders the human-readable '[
] ' (the durable fact the user is confirming) instead of an opaque JSON args blob; every other tool keeps the existing precedence: args.command -> args.file_path -> JSON.stringify(args). Shared across the API-native and both XML-loop permission builders so they render identically (the MCP watcher in brain.kern already special-cases SaveMemory the same way). Pure; tolerant of non-object args. */ -// @kern-source: session:557 +// @kern-source: session:558 export function renderToolPermissionCommand(tool: string, args: unknown): string { const a = (args && typeof args === 'object') ? (args as Record) : {}; if (tool === 'SaveMemory') { @@ -626,7 +628,7 @@ export function renderToolPermissionCommand(tool: string, args: unknown): string /** * Build a normalized continuity snapshot. Prefer the session's internal history; fall back to the visible chat transcript. Per-message string content is capped on EITHER path so review/brainstorm spam (or a huge tool result) doesn't flood Cesar's context; tool_calls/tool_call_id and non-string content are preserved untouched. */ -// @kern-source: session:573 +// @kern-source: session:574 export function buildCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): Array<{role:string,content:any,tool_calls?:any[],tool_call_id?:string}> { const directHistory = session?.getMessageHistory?.() ?? []; if (directHistory.length > 0) { @@ -659,7 +661,7 @@ export function buildCesarConversationSnapshot(session: PersistentSession|null, /** * Persist the active Cesar conversation before the session is discarded. */ -// @kern-source: session:598 +// @kern-source: session:599 export function saveCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): void { if (!session) return; const snapshot = buildCesarConversationSnapshot(session, chatSession); @@ -681,7 +683,7 @@ export function saveCesarConversationSnapshot(session: PersistentSession|null, c /** * Build the onToolCall callback for API engines with native function calling. */ -// @kern-source: session:618 +// @kern-source: session:619 export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, config: any): ((name:string, args:Record, callId:string, controlPlane?:any) => Promise) | undefined { const cwd = resolveWorkingDir(); const fsc = getProjectFileStateCache(cwd); @@ -784,8 +786,16 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, signal: sharedToolCtx.abortSignal, }); + // Delegate tool ledger (2b): the Delegate seam returns only adapter + // stdout (text), so Cesar has NO per-call tool visibility here — record + // it as a text transport with a single 'unknown', never a fabricated + // ok/error parsed from prose. Companion-backed engines are labeled as + // such; everything else is a CLI --print text transport. Best-effort. + const delegateBackend = (targetEngine as any)?.companion ? 'companion' : 'cli-print'; + recordTextTransportDispatch(targetId, mode, delegateBackend); + if (!result.stdout.trim()) { - return `[Delegate → ${targetId}] Engine returned empty response.`; + return `[Delegate → ${targetId}] Engine returned empty response.\n[tool ledger: ${textTransportDigest()}]`; } // Strip blocks from response @@ -798,10 +808,18 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, tracker.record(targetId, { prompt: task, response: cleaned }); } - return `[Delegate → ${targetId}]\n${cleaned}`; + return `[Delegate → ${targetId}]\n${cleaned}\n\n[tool ledger: ${textTransportDigest()}]`; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - return `[Delegate → ${targetId}] Error: ${msg}`; + // A FAILED dispatch is exactly what the reliability ledger exists to + // expose — record it too (zai + kimi convergent review finding: the + // success-only recording made engine failures invisible). Same honest + // text-transport shape; the failure itself is the signal. + try { + const failedBackend = (targetEngine as any)?.companion ? 'companion' : 'cli-print'; + recordTextTransportDispatch(targetId, mode, failedBackend, { dispatchFailed: true }); + } catch { /* ledger append is best-effort — never mask the dispatch error */ } + return `[Delegate → ${targetId}] Error: ${msg}\n[tool ledger: dispatch failed — recorded]`; } } @@ -1078,7 +1096,7 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, /** * Build the onApproval callback for engine tool approvals. Returns true to approve, false to deny silently, or a string to deny with a reason the engine can see. */ -// @kern-source: session:1013 +// @kern-source: session:1030 export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:string, command:string, controlPlane?:any) => Promise { const engine = ctx.registry.get(engineId); const evaluateApproval = async (tool: string, command: string): Promise => { @@ -1286,7 +1304,7 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st }; } -// @kern-source: session:1222 +// @kern-source: session:1239 export function normalizeCesarMcpServers(raw: unknown): Array> { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); @@ -1320,7 +1338,7 @@ export function normalizeCesarMcpServers(raw: unknown): Array>|undefined { if (!(config as any).cesarMcpEnabled) return undefined; @@ -1344,7 +1362,7 @@ export function loadCesarMcpServers(config: any, cwd: string): Array/mcp/index.js (see tsup.config.ts), so the published install is self-contained — no @kernlang/agon-mcp npm dependency. Resolution order: (0) the bundled sibling /mcp/index.js (the published, self-contained path), (1) node module resolution of @kernlang/agon-mcp (monorepo-via-symlink / legacy installs), (2) walk up to the repo root containing packages/mcp/dist/index.js (monorepo without a symlink), (3) the original relative guess as a last resort. `fromUrl` is for tests; defaults to this module's URL. */ -// @kern-source: session:1305 +// @kern-source: session:1322 export function resolveAgonMcpServerPath(fromUrl?: string): string { const raw = fromUrl ?? import.meta.url; // Accept either a file: URL (normal) or a bare path (defensive): fileURLToPath @@ -1410,7 +1428,7 @@ export function resolveAgonMcpServerPath(fromUrl?: string): string { /** * Single source of truth for which backend a Cesar engine will actually use. Honours config.cesarBackend preference ('auto' | 'cli' | 'api'). Pure — no side effects beyond registry lookups. Returns backend='none' when the engine has neither a usable binary nor an API key; callers decide how to handle that. */ -// @kern-source: session:1337 +// @kern-source: session:1354 export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { backend: 'cli'|'api'|'none', binaryPath: string, hasBinary: boolean, hasApi: boolean, engine: any } { const config = ctx.config; const cesarEngineId = engineId ?? (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; @@ -1435,7 +1453,7 @@ export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { b return { backend: 'none', binaryPath: '', hasBinary, hasApi, engine }; } -// @kern-source: session:1363 +// @kern-source: session:1380 export async function ensureCesarSession(ctx: HandlerContext): Promise { const config = ctx.config; const cesarEngineId = (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; diff --git a/packages/cli/src/generated/cesar/tool-engine-reliability.ts b/packages/cli/src/generated/cesar/tool-engine-reliability.ts new file mode 100644 index 000000000..76a42eebd --- /dev/null +++ b/packages/cli/src/generated/cesar/tool-engine-reliability.ts @@ -0,0 +1,76 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/tool-engine-reliability.kern + +import type { ToolDefinition, ToolHandler, ToolContext, ToolResult, PermissionDecision } from '@kernlang/agon-core'; + +import { summarizeDelegateReliabilityByEngine, formatAllDelegateReliability } from '@kernlang/agon-core'; + +import { readCesarToolReliability, summarizeAllCesarToolReliability, formatCesarReliabilityLine, emptyCesarToolReliability } from './reliability.js'; + +import type { CesarToolReliability } from './reliability.js'; + +/** + * Render the CESAR OWN-TURN RELIABILITY lines. With an engineId, one line for that engine (an engine with no logged turns summarizes to an empty 'calibrating' record — never an error). Without one, a line per observed engine, or a single all/all empty line when nothing is logged yet. + */ +// @kern-source: tool-engine-reliability:18 +export function buildOwnReliabilityLines(engineId: string|undefined): string[] { + const ownLines: string[] = []; + if (engineId) { + // A specific engine with zero logged turns summarizes to an empty reliability record labeled 'calibrating' — never an error. + ownLines.push(formatCesarReliabilityLine(readCesarToolReliability(engineId))); + } else { + const summaries: CesarToolReliability[] = summarizeAllCesarToolReliability(); + if (summaries.length === 0) { + ownLines.push(formatCesarReliabilityLine(emptyCesarToolReliability('all', 'all'))); + } else { + for (const summary of summaries) { + ownLines.push(formatCesarReliabilityLine(summary)); + } + } + } + return ownLines; +} + +/** + * Static ToolDefinition for the EngineReliability tool (read-only, two labeled reliability sections). + */ +// @kern-source: tool-engine-reliability:34 +export function engineReliabilityDefinition(): ToolDefinition { + return { name: 'EngineReliability', description: 'Report observed tool reliability per engine. Returns two labeled sections: CESAR OWN-TURN RELIABILITY (tools Cesar itself produced, from logged decision turns) and DELEGATED DISPATCH LEDGER (per-call outcomes for engines Cesar delegated to). Read-only. Optional engineId narrows the own-turn section to one engine (an engine with no logged turns reports "calibrating", not an error).', inputSchema: { type: 'object', properties: { engineId: { type: 'string', description: 'Optional engine id to narrow the own-turn reliability section. Omit for every observed engine.' }, scope: { type: 'string', enum: ['summary', 'turns', 'all'], description: 'Optional reporting scope. Reserved for future turn-level detail; the default digest is always returned.' } }, required: [] }, maxResultSizeChars: 20000, isReadOnly: true, isConcurrencySafe: true }; +} + +/** + * Factory for the EngineReliability tool — a read-only digest of Cesar own-turn tool reliability plus a placeholder for the delegated dispatch ledger. + */ +// @kern-source: tool-engine-reliability:39 +export function createEngineReliabilityTool(): ToolHandler { + return { definition: engineReliabilityDefinition(), validate: engineReliabilityValidate, checkPermission: engineReliabilityCheckPermission, execute: engineReliabilityExecute }; +} + +/** + * EngineReliability accepts any (optional) input — nothing to reject. + */ +// @kern-source: tool-engine-reliability:44 +export function engineReliabilityValidate(_input: Record, _ctx: ToolContext): string|null { + return null; +} + +/** + * EngineReliability is read-only — always allowed. + */ +// @kern-source: tool-engine-reliability:49 +export function engineReliabilityCheckPermission(_input: Record, _ctx: ToolContext): PermissionDecision { + return { behavior: 'allow' }; +} + +/** + * Render the two labeled reliability sections: CESAR OWN-TURN RELIABILITY and the per-engine DELEGATED DISPATCH LEDGER. + */ +// @kern-source: tool-engine-reliability:54 +export async function engineReliabilityExecute(input: Record, _ctx: ToolContext): Promise { + const engineId = (typeof input.engineId === 'string' && input.engineId.trim()) ? input.engineId.trim() : undefined; + const ownLines = buildOwnReliabilityLines(engineId); + // Delegated dispatch ledger (2b) — grouped engineId × backend. An engineId narrows the ledger to that engine; without one, EVERY engine is rendered on its own line(s) so two engines' api-loop stats are never merged into a single number. An engine with no ledger records (or an empty ledger) renders "no ledger records yet", never an error. + const ledgerLines = formatAllDelegateReliability(summarizeDelegateReliabilityByEngine(engineId)); + const content = ['CESAR OWN-TURN RELIABILITY', ownLines.join('\n'), '', 'DELEGATED DISPATCH LEDGER', ledgerLines.join('\n')].join('\n'); + return { ok: true, content: content }; +} diff --git a/packages/cli/src/generated/cesar/tool-render-probe.ts b/packages/cli/src/generated/cesar/tool-render-probe.ts new file mode 100644 index 000000000..9d108b570 --- /dev/null +++ b/packages/cli/src/generated/cesar/tool-render-probe.ts @@ -0,0 +1,127 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/tool-render-probe.kern + +import type { ToolDefinition, ToolHandler, ToolContext, ToolResult, PermissionDecision } from '@kernlang/agon-core'; + +import { captureSurfaceFrame } from '../blocks/frame-capture.js'; + +import { StatusBar } from '../surfaces/status.js'; + +import { TodoList } from '../blocks/todo-list.js'; + +import { ChromeBar } from '../surfaces/app-views.js'; + +// @kern-source: tool-render-probe:16 +export interface RenderFixture { + component: any; + defaults: Record; +} + +/** + * Fixture registry mapping RenderProbe surface ids to their generated component and default props. + */ +// @kern-source: tool-render-probe:20 +export function renderProbeFixtures(): Record { + return { + StatusBar: { + component: StatusBar, + defaults: { + cesarId: 'cesar-engine', + chatMessageCount: 0, + totalTokens: 0, + totalCostUsd: 0, + meteredCostUsd: 0, + hasPlanApiUsage: false, + hasCliUsage: false, + cwd: '~/workspace', + branch: 'main', + explorationMode: false, + autoModeQueued: false, + telemetryVitals: new Map(), + context: { pct: 0, used: 0, limit: 100000, compacted: 0, cached: 0, source: 'estimate' }, + termWidth: 100, + }, + }, + TodoList: { + component: TodoList, + defaults: { + todos: [], + planActive: false, + }, + }, + ChromeBar: { + component: ChromeBar, + defaults: { + mode: 'chat', + cwdLabel: 'workspace', + engineCount: 0, + replState: 'idle', + runningJobs: [], + }, + }, + }; +} + +/** + * Render one fixture surface — caller props shallow-merged over the fixture defaults — and return its final text frame. A render error becomes an error ToolResult, never a throw. + */ +// @kern-source: tool-render-probe:63 +export async function renderProbeSurface(surface: string, component: any, defaults: Record, overrides: Record, cols: number, rows: number): Promise { + const props = Object.assign({}, defaults, overrides); + try { + const frame = await captureSurfaceFrame(component, props, cols, rows); + return { ok: true, content: frame }; + } catch (err) { + return { ok: false, content: '', error: `RenderProbe failed to render '${surface}': ${(err instanceof Error) ? err.message : String(err)}` }; + } +} + +/** + * ToolDefinition for the RenderProbe tool, parameterized by the valid surface ids so the description and schema advertise the live fixture set. + */ +// @kern-source: tool-render-probe:73 +export function renderProbeDefinition(validIds: string[]): ToolDefinition { + return { name: 'RenderProbe', description: `Render a known Ink surface in-process and return its ANSI-stripped text frame so you can verify layout. Valid surface ids: ${validIds.join(', ')}. Input: { surface, cols?=100, rows?=30, props? } — props are shallow-merged over the fixture defaults. Read-only.`, inputSchema: { type: 'object', properties: { surface: { type: 'string', description: `Surface id to render. One of: ${validIds.join(', ')}.` }, cols: { type: 'number', description: 'Terminal columns. Optional, defaults to 100.' }, rows: { type: 'number', description: 'Terminal rows. Optional, defaults to 30.' }, props: { type: 'object', description: 'Optional props shallow-merged over the fixture defaults.' } }, required: ['surface'] }, maxResultSizeChars: 40000, isReadOnly: true, isConcurrencySafe: true }; +} + +/** + * Factory for the RenderProbe tool — renders a known Ink surface fixture and returns its final text frame. + */ +// @kern-source: tool-render-probe:78 +export function createRenderProbeTool(): ToolHandler { + return { definition: renderProbeDefinition(Object.keys(renderProbeFixtures())), validate: renderProbeValidate, checkPermission: renderProbeCheckPermission, execute: renderProbeExecute }; +} + +/** + * Require a non-empty `surface` id. + */ +// @kern-source: tool-render-probe:83 +export function renderProbeValidate(input: Record, _ctx: ToolContext): string|null { + return (typeof input.surface === 'string' && input.surface.trim()) ? null : 'Missing required parameter: surface'; +} + +/** + * RenderProbe is read-only — always allowed. + */ +// @kern-source: tool-render-probe:88 +export function renderProbeCheckPermission(_input: Record, _ctx: ToolContext): PermissionDecision { + return { behavior: 'allow' }; +} + +/** + * Resolve the requested fixture, clamp dimensions, and render its final text frame. An unknown surface returns the list of known ids rather than guessing. + */ +// @kern-source: tool-render-probe:93 +export async function renderProbeExecute(input: Record, _ctx: ToolContext): Promise { + const fixtures = renderProbeFixtures(); + const validIds = Object.keys(fixtures); + const surface = String(input.surface ?? '').trim(); + const fixture = fixtures[surface]; + if (!fixture) { + return { ok: false, content: '', error: `Unknown surface '${surface}'. Valid surface ids: ${validIds.join(', ')}.` }; + } + // Clamp dimensions: an in-process Ink render allocates per-cell state, so model-controlled unbounded cols/rows is a memory-exhaustion vector (agon-review finding). 400x200 covers any real terminal. + const cols = (typeof input.cols === 'number' && input.cols > 0) ? Math.min(Math.max(1, Math.floor(input.cols)), 400) : 100; + const rows = (typeof input.rows === 'number' && input.rows > 0) ? Math.min(Math.max(1, Math.floor(input.rows)), 200) : 30; + const overrides = (input.props && typeof input.props === 'object' && !Array.isArray(input.props)) ? (input.props as Record) : {}; + return renderProbeSurface(surface, fixture.component, fixture.defaults, overrides, cols, rows); +} diff --git a/packages/cli/src/generated/cesar/tool-tui-probe.ts b/packages/cli/src/generated/cesar/tool-tui-probe.ts new file mode 100644 index 000000000..e9120a964 --- /dev/null +++ b/packages/cli/src/generated/cesar/tool-tui-probe.ts @@ -0,0 +1,121 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/tool-tui-probe.kern + +import { fileURLToPath } from 'node:url'; + +import { join, dirname } from 'node:path'; + +import { existsSync } from 'node:fs'; + +import { spawnWithTimeout } from '@kernlang/agon-core'; + +import type { ToolDefinition, ToolHandler, ToolContext, ToolResult, PermissionDecision } from '@kernlang/agon-core'; + +// @kern-source: tool-tui-probe:16 +export const TUI_PROBE_INPUT_SAFELIST: readonly string[] = ['/help', '/status', '/todos', '/plans', '/checkpoints'] as const; + +/** + * Locate py/agon-tui-probe.py and dist/index.js relative to this compiled module. PACKAGED layout: tsup bundles this module into a flat chunk directly under /dist/, so the package root is ONE level up. Dev/vitest layout: /src/generated/cesar/ → three levels up. All candidates probed with existsSync, mirroring resolveModelProbeScript's walk in agon-core. + */ +// @kern-source: tool-tui-probe:18 +export function resolveTuiProbePaths(): { script: string|null, agonBin: string|null } { + const here = dirname(fileURLToPath(import.meta.url)); + // Candidate package roots, most-specific first: dist/.js → pkg root (PACKAGED layout — tsup emits flat chunks directly under dist/; agon-review blocking finding); src/generated/cesar → pkg root (vitest/dev layout); then two more fallback depths. + const roots = [join(here, '..'), join(here, '..', '..', '..'), join(here, '..', '..'), join(here, '..', '..', '..', '..')]; + let script = null as string | null; + let agonBin = null as string | null; + for (const root of roots) { + const s = join(root, 'py', 'agon-tui-probe.py'); + if (!script && existsSync(s)) { + script = s; + } + const b = join(root, 'dist', 'index.js'); + if (!agonBin && existsSync(b)) { + agonBin = b; + } + } + return { script: script, agonBin: agonBin }; +} + +/** + * Factory for the TuiProbe tool — spawns a throwaway isolated agon under a PTY, drives one safelisted input, and returns the final pyte-emulated screen grid. + */ +// @kern-source: tool-tui-probe:35 +export function createTuiProbeTool(): ToolHandler { + const definition: ToolDefinition = { + name: 'TuiProbe', + description: `Launch a throwaway isolated agon instance under a PTY, drive one scripted input, and return the FINAL rendered terminal frame (pyte screen state) so you can verify the real UI layout end-to-end. Input: { input?='/help', cols?=120, rows?=40, timeoutSec?=45 }. input must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')} (layout probe — never dispatches engines). Read-only from the real install's perspective (isolated AGON_HOME + cwd). Requires a built agon (dist/) and python3 with pyte.`, + inputSchema: { + type: 'object', + properties: { + input: { type: 'string', description: `Scripted input to drive. Must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')}. Defaults to /help.` }, + cols: { type: 'number', description: 'Terminal columns. Optional, defaults to 120.' }, + rows: { type: 'number', description: 'Terminal rows. Optional, defaults to 40.' }, + timeoutSec: { type: 'number', description: 'Probe timeout in seconds. Optional, defaults to 45.' }, + }, + }, + maxResultSizeChars: 60000, + isReadOnly: true, + isConcurrencySafe: false, + }; + + const validate = (input: Record, _ctx: ToolContext): string | null => { + const scripted = typeof input.input === 'string' ? input.input.trim() : '/help'; + // Control characters (incl. \n/\r) are rejected outright: the PTY treats a + // newline as "submit", so a safelisted first line could smuggle a second, + // engine-dispatching command past a prefix check (agon-review blocking + // finding). The python probe enforces the same rule — defense in depth. + if (/[\u0000-\u001f\u007f]/.test(scripted)) { + return 'TuiProbe input must be a single line without control characters'; + } + if (!TUI_PROBE_INPUT_SAFELIST.some((allowed) => scripted === allowed || scripted.startsWith(`${allowed} `))) { + return `TuiProbe input must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')} (v1 is a layout probe and never dispatches engines)`; + } + return null; + }; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + const { script, agonBin } = resolveTuiProbePaths(); + if (!script) { + return { ok: false, content: '', error: 'TuiProbe: py/agon-tui-probe.py not found relative to the agon package.' }; + } + if (!agonBin) { + return { ok: false, content: '', error: 'TuiProbe: agon dist/index.js not found — build the package first (npm run build).' }; + } + const scripted = typeof input.input === 'string' && input.input.trim() ? input.input.trim() : '/help'; + const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.min(Math.max(1, Math.floor(input.cols)), 400) : 120; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(Math.max(1, Math.floor(input.rows)), 200) : 40; + const timeoutSec = typeof input.timeoutSec === 'number' && input.timeoutSec > 0 ? Math.floor(input.timeoutSec) : 45; + try { + const result = await spawnWithTimeout({ + command: 'python3', + args: [script, '--input', scripted, '--cols', String(cols), '--rows', String(rows), '--timeout', String(timeoutSec), '--agon-bin', agonBin], + // The python wrapper's own cwd is irrelevant — the script mkdtemps an + // isolated cwd for the child agon; SpawnOptions just requires one. + cwd: dirname(script), + timeout: (timeoutSec + 15) * 1000, + }); + if (result.timedOut) { + return { ok: false, content: '', error: `TuiProbe timed out after ${timeoutSec + 15}s (outer guard).` }; + } + let parsed: { frame?: string; durationMs?: number; state?: string; error?: string }; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch { + return { ok: false, content: '', error: `TuiProbe: probe emitted non-JSON output: ${result.stdout.slice(0, 400)}${result.stderr ? ` | stderr: ${result.stderr.slice(0, 400)}` : ''}` }; + } + if (parsed.error || typeof parsed.frame !== 'string') { + return { ok: false, content: '', error: `TuiProbe: ${parsed.error ?? 'probe returned no frame'}` }; + } + return { + ok: true, + content: `[TuiProbe · input=${scripted} · ${cols}x${rows} · ${parsed.durationMs ?? '?'}ms · final pyte screen state]\n\n${parsed.frame}`, + }; + } catch (err) { + return { ok: false, content: '', error: `TuiProbe failed: ${err instanceof Error ? err.message : String(err)}` }; + } + }; + + return { definition, validate, checkPermission, execute }; +} diff --git a/packages/cli/src/generated/cesar/tools.ts b/packages/cli/src/generated/cesar/tools.ts index 2650f7261..82b9436a1 100644 --- a/packages/cli/src/generated/cesar/tools.ts +++ b/packages/cli/src/generated/cesar/tools.ts @@ -8,6 +8,12 @@ import type { Dispatch, HandlerContext } from '../../handlers/types.js'; import { createCouncilTool } from './council-tool.js'; +import { createEngineReliabilityTool } from './tool-engine-reliability.js'; + +import { createRenderProbeTool } from './tool-render-probe.js'; + +import { createTuiProbeTool } from './tool-tui-probe.js'; + import { isTaskFileMutationAction, taskActionApprovalMessage, isApprovedPermissionResponse } from './task-execution-lease.js'; import { authorizeResolvedTaskAction } from './permission-resolver.js'; @@ -21,7 +27,7 @@ import { isBashToolName } from './brain-helpers.js'; /** * Create and populate the standard Cesar tool registry. Single source of truth — no more duplication. */ -// @kern-source: tools:11 +// @kern-source: tools:14 export function createCesarToolRegistry(engineId?: string): ToolRegistry { const toolRegistry = new ToolRegistry(); toolRegistry.register(createReadTool()); @@ -50,13 +56,16 @@ export function createCesarToolRegistry(engineId?: string): ToolRegistry { toolRegistry.register(createExitPlanModeTool()); toolRegistry.register(createListPlansTool()); toolRegistry.register(createRetrieveResultTool(engineId)); + toolRegistry.register(createEngineReliabilityTool()); + toolRegistry.register(createRenderProbeTool()); + toolRegistry.register(createTuiProbeTool()); return toolRegistry; } /** * Create a shared ToolContext for eager tool execution during streaming. */ -// @kern-source: tools:43 +// @kern-source: tools:49 export function createEagerToolContext(ctx: HandlerContext, config: any, signal: AbortSignal, dispatch: Dispatch): ToolContext { const cwd = resolveWorkingDir(); const fsc = getProjectFileStateCache(cwd); @@ -67,7 +76,7 @@ export function createEagerToolContext(ctx: HandlerContext, config: any, signal: /** * Parse a streaming tool input into a JSON object. Malformed input is returned as an explicit retryable error instead of being silently coerced. */ -// @kern-source: tools:51 +// @kern-source: tools:57 export function parseEagerToolInput(toolName: string, input: unknown): {ok:boolean,input?:Record,error?:string,raw:string} { const raw = typeof input === 'string' ? input @@ -119,7 +128,7 @@ export function parseEagerToolInput(toolName: string, input: unknown): {ok:boole /** * Execute a tool eagerly during streaming — parse input, run, dispatch result. */ -// @kern-source: tools:101 +// @kern-source: tools:107 export async function executeEagerTool(toolName: string, meta: Record, toolRegistry: ToolRegistry, toolCtx: ToolContext, dispatch: Dispatch, cesarEngineId: string): Promise { const callId = (meta.toolCallId as string) ?? `eager-${Date.now()}`; const parsed = parseEagerToolInput(toolName, meta.input); diff --git a/packages/cli/src/generated/commands/call.ts b/packages/cli/src/generated/commands/call.ts index 309cf11eb..3bb9499ce 100644 --- a/packages/cli/src/generated/commands/call.ts +++ b/packages/cli/src/generated/commands/call.ts @@ -258,6 +258,9 @@ export function buildCallCommands(opts: CallCommandOptions): BuiltCallCommands { 'review', opts.input?.trim() || 'uncommitted', ...textFlag('--engine', opts.engine), + // Role-lens review: 'auto' deals the fixed roster (security, correctness, + // dryness, performance, overall backstop); a comma list zips per engine. + ...textFlag('--roles', opts.roles), ...timeout, ...engines, ]); @@ -317,12 +320,12 @@ export function buildCallCommands(opts: CallCommandOptions): BuiltCallCommands { return { cwd, commands }; } -// @kern-source: call:305 +// @kern-source: call:308 export function writeJsonl(event: Record): void { process.stdout.write(`${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`); } -// @kern-source: call:310 +// @kern-source: call:313 export async function runCommand(command: string, args: string[], cwd: string, jsonl: boolean, workflowMeta?: WorkflowCallMeta): Promise { return new Promise((resolve) => { const startedAt = Date.now(); @@ -366,7 +369,7 @@ export async function runCommand(command: string, args: string[], cwd: string, j }); } -// @kern-source: call:354 +// @kern-source: call:357 export const callCommand: any = defineCommand({ meta: { name: 'call', @@ -480,7 +483,7 @@ export const callCommand: any = defineCommand({ }, roles: { type: 'string', - description: 'For council: override advisor roles (comma-separated, priority order)', + description: "For council: override advisor roles (comma-separated, priority order). For review: role-lens review — 'auto' or a comma-separated role list (security, correctness, dryness, performance, overall)", }, chairman: { type: 'string', diff --git a/packages/cli/src/generated/handlers/review.ts b/packages/cli/src/generated/handlers/review.ts index 48e979ed4..7c883728d 100644 --- a/packages/cli/src/generated/handlers/review.ts +++ b/packages/cli/src/generated/handlers/review.ts @@ -26,7 +26,54 @@ import { stripReasoning, stripTuiChrome } from '../blocks/engine-helpers.js'; import { hostNowMs } from '../lib/kern-host.js'; +/** + * True when `ref^{commit}` resolves to a real commit in the given repo (best-effort: a git failure means it does not). + */ // @kern-source: review:18 +function refResolvesToCommit(ref: string, cwd: string): boolean { + try { + execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd: cwd, encoding: 'utf-8' }); + return true; + } catch (e) { + return false; + } +} + +/** + * Resolve the base ref for reviewing the currently checked-out branch: the repo's default branch via origin/HEAD, falling back to origin/main, origin/master, main, master. Returns null when the only candidates ARE the branch being reviewed (i.e. you are on the default branch) or nothing resolves — callers keep the loud no-base error for that case. + */ +// @kern-source: review:27 +export function resolveAutoReviewBase(cwd: string, branch: string): string|null { + const stripOrigin = (ref: string) => ref.replace(/^origin\//, ''); + try { + const sym = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: cwd, encoding: 'utf-8' }).trim(); + if (sym) { + // origin/main + const cand = sym.replace(/^refs\/remotes\//, ''); + // The reviewed branch IS the repo default branch → there is no base; return null (loud error) instead of falling through to a possibly unrelated legacy main/master (agon-review blocking finding). + if (stripOrigin(cand) === branch || cand === branch) { + return null; + } + // A stale origin/HEAD pointing at a pruned ref must not short-circuit the fallback chain — verify it resolves before trusting it. + if (refResolvesToCommit(cand, cwd)) { + return cand; + } + } + } catch (e) { + // no origin/HEAD (local-only repo) — fall through to candidates + } + for (const fallbackCand of ['origin/main', 'origin/master', 'main', 'master']) { + if (stripOrigin(fallbackCand) === branch || fallbackCand === branch) { + continue; + } + if (refResolvesToCommit(fallbackCand, cwd)) { + return fallbackCand; + } + } + return null; +} + +// @kern-source: review:51 export function resolveReviewTarget(target: string|undefined, cwd: string, base: string|undefined): {diff:string, label:string} { const t = (target ?? 'uncommitted').trim(); const baseRef = (base ?? '').trim(); @@ -158,12 +205,29 @@ export function resolveReviewTarget(target: string|undefined, cwd: string, base: throw new Error(`Failed to resolve branch "${branch}": ${err instanceof Error ? err.message : String(err)}`); } if (branchSha && branchSha === headSha) { - throw new Error(`branch:${branch} points at the commit you are currently on, so diffing it against HEAD yields nothing to review. Use "branch:main" (or your base branch) to review this branch's commits, or "uncommitted" to review working-tree changes.`); - } - try { - diff = execFileSync('git', ['diff', `${branch}...HEAD`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); - } catch (err) { - throw new Error(`Failed to get branch diff for ${branch}: ${err instanceof Error ? err.message : String(err)}`); + // Targeting the branch you are currently on. The previous behavior + // was a loud error (itself a fix for the silent empty self-diff that + // read as "clean review"), but the caller's question is unambiguous — + // "this branch's commits vs its base" — so ANSWER it: auto-resolve + // the base to the repo default branch and diff merge-base...branch. + // The loud error remains only when no base can be resolved (you are + // on the default branch itself, or there is no main/master anchor). + const autoBase = resolveAutoReviewBase(cwd, branch); + if (!autoBase) { + throw new Error(`branch:${branch} points at the commit you are currently on and no base branch could be auto-resolved (are you on the default branch?). Use "range:BASE...${branch}" for an explicit two-ref diff, or "uncommitted" to review working-tree changes.`); + } + label = `branch ${branch} vs ${autoBase} (auto-base)`; + try { + diff = execFileSync('git', ['diff', `${autoBase}...${branch}`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); + } catch (err) { + throw new Error(`Failed to diff ${autoBase}...${branch}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + try { + diff = execFileSync('git', ['diff', `${branch}...HEAD`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); + } catch (err) { + throw new Error(`Failed to get branch diff for ${branch}: ${err instanceof Error ? err.message : String(err)}`); + } } } } else if (t.startsWith('commit:')) { @@ -192,7 +256,7 @@ export function resolveReviewTarget(target: string|undefined, cwd: string, base: // ── Module: ReviewEngineSelection ── -// @kern-source: review:183 +// @kern-source: review:233 export function selectReviewEngine(requestedEngine: string|undefined, ctx: HandlerContext): string { const allActive = ctx.activeEngines(); @@ -240,7 +304,7 @@ export function selectReviewEngine(requestedEngine: string|undefined, ctx: Handl /** * Select the Review panel. With no explicit list, return EVERY active engine, preserving active-engine order; Review uses generic exec prompts, so a dedicated review block is not required. An explicit list is resolved through EngineRegistry.partitionRoster: hidden engines remain usable when named, removed engines fail loudly, unknown/unavailable engines are rejected, and aliases are deduplicated. This is the standard Review contract: the full active panel runs unless the caller explicitly narrows it with --engine/--engines. */ -// @kern-source: review:228 +// @kern-source: review:278 export function selectReviewEngines(requestedEngines: string[]|undefined, ctx: HandlerContext): string[] { const allActive = ctx.activeEngines(); if (requestedEngines !== undefined) { @@ -272,7 +336,7 @@ export function selectReviewEngines(requestedEngines: string[]|undefined, ctx: H throw new Error('No active engines available for Review. Run `agon doctor review` to diagnose availability or pass an explicit engine.'); } -// @kern-source: review:261 +// @kern-source: review:311 export interface ReviewCoreResult { response: string; blocking: boolean; @@ -282,10 +346,10 @@ export interface ReviewCoreResult { usage?: {promptTokens:number,completionTokens:number,totalTokens:number,source:'sdk'|'cli-reported'|'estimated'}; } -// @kern-source: review:272 +// @kern-source: review:322 export const REVIEW_SENTINEL: string = ''; -// @kern-source: review:274 +// @kern-source: review:324 export interface ReviewSeverityCounts { blocking: number; important: number; @@ -296,7 +360,7 @@ export interface ReviewSeverityCounts { /** * Sentinel-anchored, fail-closed extraction of the findings array — the single chokepoint shared by parseReviewBlocking (the blocking gate) and summarizeReviewFindings (severity counts). Returns the parsed array (possibly empty []) or null when no parseable block follows the LAST sentinel. Anti-injection: only text after the LAST sentinel is considered, so attacker brackets quoted earlier in the diff are ignored. Tolerant of almost-JSON (trailing commas, line and block JS-style comments) and fenced json code blocks. */ -// @kern-source: review:280 +// @kern-source: review:330 export function extractReviewFindings(response: string): Array<{severity?:string, blocking?:boolean}> | null { if (!response || response.trim().length === 0) return null; @@ -396,7 +460,7 @@ export function extractReviewFindings(response: string): Array<{severity?:string /** * Sentinel-anchored, fail-closed parser. The engine MUST end its response with a unique sentinel followed by a JSON array of findings. Without a parseable block the response is treated as blocking + parseFailed, so the user must explicitly approve. This blocks the prompt-injection attack where an attacker echoes `[{"blocking":false}]` inside diff content — only the engine's real structured output after the LAST sentinel is considered. Thin wrapper over extractReviewFindings. */ -// @kern-source: review:378 +// @kern-source: review:428 export function parseReviewBlocking(response: string): {blocking:boolean, parseFailed:boolean} { const findings = extractReviewFindings(response); if (findings === null) return { blocking: true, parseFailed: true }; @@ -407,7 +471,7 @@ export function parseReviewBlocking(response: string): {blocking:boolean, parseF /** * Count findings by severity from the structured block, for human summaries like 'claude: ok, 1 important, 3 nits'. Returns all-zero when there is no parseable findings block (the caller renders that as unstructured/empty). A finding counts as blocking if blocking===true or severity==='blocking'; otherwise by its severity, with anything not 'important' falling to nit. */ -// @kern-source: review:387 +// @kern-source: review:437 export function summarizeReviewFindings(response: string): ReviewSeverityCounts { const findings = extractReviewFindings(response); if (!findings) return { blocking: 0, important: 0, nit: 0, total: 0 }; @@ -426,7 +490,7 @@ export function summarizeReviewFindings(response: string): ReviewSeverityCounts /** * Resolve the per-dispatch Review output budget. Positive reviewMaxTokens is an explicit user override. Zero/missing means automatic: keep an 8192 floor for CLI engines and honor a larger native api.maxTokens for reasoning-heavy API engines. */ -// @kern-source: review:404 +// @kern-source: review:454 export function resolveReviewMaxTokens(config: any, engine: any): number { const configured = Number(config?.reviewMaxTokens); if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); @@ -437,7 +501,7 @@ export function resolveReviewMaxTokens(config: any, engine: any): number { /** * Return the whole seconds still available inside a Review engine's original wall-clock budget, clamped to that original budget so a backward clock adjustment cannot extend the advertised timeout. */ -// @kern-source: review:413 +// @kern-source: review:463 export function remainingReviewRetrySeconds(startedAtMs: number, timeoutSec: number, nowMs?: number): number { const now = nowMs ?? Date.now(); const limit = Math.max(0, Math.floor(timeoutSec)); @@ -447,7 +511,7 @@ export function remainingReviewRetrySeconds(startedAtMs: number, timeoutSec: num /** * Retry one hard dispatch error only when at least five seconds remain in the original wall-clock budget. Timeouts are final: the outer attempt already consumed the budget, and reserving retry time would prematurely abort legitimately slow reviewers. */ -// @kern-source: review:421 +// @kern-source: review:471 export function shouldRetryReviewAttempt(kind: 'ok'|'timeout'|'error', remainingSec: number): boolean { return kind === 'error' && remainingSec >= 5; } @@ -455,7 +519,7 @@ export function shouldRetryReviewAttempt(kind: 'ok'|'timeout'|'error', remaining /** * Repair pass (B): re-ask the engine for ONLY a bare JSON array of the findings it already wrote in prose. Asking for a bare array (no sentinel, no prose, no fence) is the format LLMs comply with most reliably — far better than 'an HTML-comment marker followed by JSON', which engines routinely truncate to just the marker. The caller (runReviewCore) prepends the sentinel itself before parsing, so the anti-injection anchor is preserved. Best-effort: if this still doesn't parse, the fail-closed/unstructured result stands. cwdOverride must match the main dispatch's cwd so the repair engine runs in the SAME repo (goal worktree / process.cwd()), never the active workspace. */ -// @kern-source: review:427 +// @kern-source: review:477 export async function runReviewRepair(priorReview: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal, cwdOverride?: string): Promise { const config = ctx.config; const cwd = cwdOverride ?? resolveWorkingDir(); @@ -505,7 +569,7 @@ export async function runReviewRepair(priorReview: string, engineId: string, ctx /** * Repo grounding: read the CURRENT full content of each source file the diff touches and format it as a context block. A diff shows only the changed hunks, so reviewers raise false alarms that reading the whole file would kill instantly ('X is unhandled' when the wrapper handles it three lines down; 'unimported' when it's imported at the top). Bounded hard (per-file + total caps) to protect prompt size / TTFT, and skips generated/dist/min files (derived noise that would blow the budget). Best-effort: deleted/binary/unreadable files are skipped — the diff still covers them. */ -// @kern-source: review:465 +// @kern-source: review:515 export function gatherReviewFileContext(diff: string, cwd: string): string { const PER_FILE_MAX = 20_000; const TOTAL_MAX = 60_000; @@ -549,11 +613,84 @@ export function gatherReviewFileContext(diff: string, cwd: string): string { return sections.length ? sections.join('\n\n') : ''; } +// @kern-source: review:566 +export interface ReviewRole { + id: string; + title: string; + focus: string; +} + +// @kern-source: review:571 +export const REVIEW_ROLES: readonly ReviewRole[] = [ + { id: 'security', title: 'Security', focus: 'injection, authN/authZ, secret or credential exposure, unsafe deserialization, path traversal, SSRF, XSS, insecure crypto, data exfiltration, and trusting attacker-controlled input. Trace untrusted data from entry to sink.' }, + { id: 'correctness', title: 'Correctness', focus: 'logic errors, broken conditionals, off-by-one and boundary mistakes, null/undefined handling, error and exception paths, async/race conditions, and edge cases the change does not cover. This is the deepest lens — verify each suspected bug against the real code before flagging.' }, + { id: 'dryness', title: 'Dryness & Modularity', focus: 'duplication that should be shared, leaked abstractions, misplaced responsibilities, tight coupling between modules, and functions or files doing too much. Judge whether the change fits the surrounding architecture.' }, + { id: 'performance', title: 'Performance', focus: 'unnecessary allocation, O(n²) or worse hot paths, repeated work in loops, blocking the event loop, unbounded growth (memory, listeners, caches), and N+1-style patterns. Only flag a cost you can justify from the code, not a theoretical one.' }, + { id: 'overall', title: 'Overall (generalist backstop)', focus: 'the whole change with no narrowed lens — bugs, security, performance, quality, and missing edge cases. You are the safety net: catch whatever the focused roles miss.' }, + ] as const; + +// @kern-source: review:579 +export const REVIEW_ROLE_OUTSIDE_TAIL: string = "Even though that is your focus, if you notice a BLOCKING issue OUTSIDE your role, flag it too — never let a real blocker fall through the cracks."; + /** - * Core review flow with no ctx side effects. Used by both handleReview (with streaming dispatch) and the plan executor's review step (silent). Does NOT touch ctx.setActiveAbort, ctx.lastReviewResult, ctx.chatSession, or tracker. signal is optional: callers that don't have an abort controller can pass undefined. cwdOverride pins the working directory the review engine runs in AND the repo file-context is gathered from — goal passes the per-task worktree so review engines never operate in (and write to) the parent repo; defaults to resolveWorkingDir() for the interactive/CLI review paths. + * Look up a role by id (case-insensitive). Returns undefined for none/unknown so callers can fall back to the generic prompt. */ -// @kern-source: review:510 -export async function runReviewCore(diff: string, label: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal, onProgress?: (chunk:string)=>void, cwdOverride?: string): Promise { +// @kern-source: review:581 +export function resolveReviewRole(roleId: string|undefined): ReviewRole|undefined { + if (!roleId) { + return undefined; + } + const needle = roleId.trim().toLowerCase(); + for (const r of REVIEW_ROLES) { + if (r.id === needle) { + return r; + } + } + return undefined; +} + +/** + * Map each engine to a role. With an explicit roleIds list, zip engine i → roleIds[i] (extra engines cycle from the start; unknown ids → overall). Without one, seat the 'overall' generalist backstop FIRST whenever there are 2+ engines (a small panel must never lose the catch-all), then deal the specialist lenses (security, correctness, dryness, performance) in order; any engine past the roster also lands on 'overall'. A single engine gets the deepest lens (security) — it IS the whole panel. + */ +// @kern-source: review:592 +export function assignReviewRoles(engineIds: string[], roleIds: string[]|undefined): Map { + const out: Map = new Map(); + const fallback = resolveReviewRole('overall') ?? REVIEW_ROLES[REVIEW_ROLES.length - 1]; + if (roleIds && roleIds.length > 0) { + let idx: number = 0; + for (const engineId of engineIds) { + const picked = resolveReviewRole(roleIds[idx % roleIds.length]) ?? fallback; + out.set(engineId, picked); + idx += 1; + } + return out; + } + const specialists = REVIEW_ROLES.filter((r) => r.id !== 'overall'); + const multi = engineIds.length >= 2; + let i2: number = 0; + for (const engineId2 of engineIds) { + const isBackstopSeat = multi && i2 === 0; + const specIdx = multi ? (i2 - 1) : i2; + const role = isBackstopSeat ? fallback : ((specIdx < specialists.length) ? specialists[specIdx] : fallback); + out.set(engineId2, role); + i2 += 1; + } + return out; +} + +/** + * Role-scoped replacement for the INSTRUCTIONS lead. Keeps the same word/severity/confidence discipline and points at the SAME mandatory machine block the generic prompt uses (the caller appends the shared block verbatim after this). + */ +// @kern-source: review:616 +export function buildRoleInstructions(role: ReviewRole): string { + return `You are the ${role.title} reviewer on a multi-role review panel. Focus your review on: ${role.focus}\n\n${REVIEW_ROLE_OUTSIDE_TAIL}\n\nReport every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep the prose under 1200 words. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\n\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding 'blocking' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.`; +} + +/** + * Core review flow with no ctx side effects. Used by both handleReview (with streaming dispatch) and the plan executor's review step (silent). Does NOT touch ctx.setActiveAbort, ctx.lastReviewResult, ctx.chatSession, or tracker. signal is optional: callers that don't have an abort controller can pass undefined. cwdOverride pins the working directory the review engine runs in AND the repo file-context is gathered from — goal passes the per-task worktree so review engines never operate in (and write to) the parent repo; defaults to resolveWorkingDir() for the interactive/CLI review paths. roleId is optional: when it resolves to a known role the engine reviews through that focused lens (a ## ROLE block + role-scoped INSTRUCTIONS lead) over the SAME diff, grounding, and machine-block contract; when undefined/unknown the generic prompt is used unchanged. + */ +// @kern-source: review:621 +export async function runReviewCore(diff: string, label: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal, onProgress?: (chunk:string)=>void, cwdOverride?: string, roleId?: string): Promise { const cwd = cwdOverride ?? resolveWorkingDir(); const config = ctx.config; const projectCtx = scanProjectContext(cwd, config.projectContext || undefined, config.contextFormat as any); @@ -569,7 +706,11 @@ export async function runReviewCore(diff: string, label: string, engineId: strin parts.push(`## CURRENT FILE CONTENTS\nFull current content of the changed source files, for grounding. Verify each finding against this real code — e.g. check whether an error is actually handled, a symbol actually unused, or an import actually missing — before flagging it. The DIFF below shows only what changed.\n\n${fileContext}`); } parts.push(`## DIFF\n\`\`\`diff\n${diff}\n\`\`\``); - parts.push(`## INSTRUCTIONS\nProvide a thorough but concise code review: bugs and logic errors, security vulnerabilities, performance issues, code quality, and missing edge cases. Keep the prose under 1200 words. Report every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep each problem and fix concise. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\n\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding 'blocking' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.\n\n## REQUIRED MACHINE BLOCK\nAfter your prose review you MUST append a machine-readable findings block. This is mandatory — a review without it is discarded. The block is the sentinel line, then a fenced JSON code block, as the very last thing in your response. Do NOT stop at the sentinel line: the JSON array after it is required.\n\n\n\`\`\`json\n[{"file":"src/auth.ts","lines":"42","severity":"important","blocking":false,"confidence":0.7,"problem":"missing null check","minimalFix":"guard before deref"}]\n\`\`\`\n\nReplace the example with your real findings. If you found no issues, the array MUST be []. Emit the sentinel + JSON block exactly once, at the end.`); + const role = resolveReviewRole(roleId); + if (role) { + parts.push(`## ROLE\n${role.title}`); + } + parts.push(`## INSTRUCTIONS\n${role ? buildRoleInstructions(role) : 'Provide a thorough but concise code review: bugs and logic errors, security vulnerabilities, performance issues, code quality, and missing edge cases. Keep the prose under 1200 words. Report every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep each problem and fix concise. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\n\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding \'blocking\' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.'}\n\n## REQUIRED MACHINE BLOCK\nAfter your prose review you MUST append a machine-readable findings block. This is mandatory — a review without it is discarded. The block is the sentinel line, then a fenced JSON code block, as the very last thing in your response. Do NOT stop at the sentinel line: the JSON array after it is required.\n\n\n\`\`\`json\n[{"file":"src/auth.ts","lines":"42","severity":"important","blocking":false,"confidence":0.7,"problem":"missing null check","minimalFix":"guard before deref"}]\n\`\`\`\n\nReplace the example with your real findings. If you found no issues, the array MUST be []. Emit the sentinel + JSON block exactly once, at the end.`); const prompt = parts.join('\n\n'); const engine = ctx.registry.get(engineId); const outputDir = join(RUNS_DIR, `review-${hostNowMs()}`); @@ -664,7 +805,7 @@ export async function runReviewCore(diff: string, label: string, engineId: strin /** * Strip the trailing machine-readable findings block (sentinel + JSON) from a review so the Ctrl+R results pager shows clean prose — the consensus summary already encodes those findings. Cesar's copy (ctx.lastReviewResult.reviewOutput) keeps the full response, so 'fix it' still has the structured file/line/minimalFix data. No-op when there's no sentinel. */ -// @kern-source: review:600 +// @kern-source: review:714 export function stripMachineBlock(response: string): string { const idx = response.lastIndexOf(REVIEW_SENTINEL); if (idx < 0) return response; @@ -674,7 +815,7 @@ export function stripMachineBlock(response: string): string { /** * Build a consensus EngineOutcome from one engine's review. status!=='ok' yields an empty-findings failure lane (never a phantom blocker), carrying any diagnostic note (error message / timeout detail) through to ConsensusReport.engineFailures; 'ok' parses the engine's structured findings into RawFindings. Shared by the single- and multi-engine paths so the mapping lives in one place. */ -// @kern-source: review:608 +// @kern-source: review:722 export function reviewOutcome(engineId: string, response: string, status: string, note?: string): any { if (status !== 'ok') return { engine: engineId, status, findings: [], note }; // Guard against a model emitting a non-object element (e.g. `[null]` or a @@ -693,7 +834,7 @@ export function reviewOutcome(engineId: string, response: string, status: string /** * Render a consensus report into the compact, human-facing summary lines (tiered: verified / needs-check / speculative / nits / failed). The single source of the summary text shown inline AND stored as ReviewResultData.consensusSummary, so the transcript and the Ctrl+R pager always agree. Each finding row carries compact engine badges ([codex][kimi]) instead of ×N, and disputed clusters get a `⚠ DISPUTED` prefix + indented per-engine stance lines — both via the shared formatConsensusRow so the REPL and the CLI render identically. */ -// @kern-source: review:625 +// @kern-source: review:739 export function buildReviewConsensusLines(consensus: any): string[] { const lines: string[] = [`Consensus — ${consensus.summary}`]; if (consensus.verified.length) { lines.push('VERIFIED (actionable):'); for (const f of consensus.verified) for (const l of formatConsensusRow(f)) lines.push(l); } @@ -707,7 +848,7 @@ export function buildReviewConsensusLines(consensus: any): string[] { /** * One-line severity tail for a single engine's review: '2 important, 3 nits' (zero categories omitted; 'no findings' when empty). */ -// @kern-source: review:637 +// @kern-source: review:751 export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { if (!c || c.total === 0) return 'no findings'; const parts: string[] = []; @@ -717,7 +858,7 @@ export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { return parts.join(', '); } -// @kern-source: review:648 +// @kern-source: review:762 export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngine?: string): Promise { const abort = new AbortController(); try { @@ -846,7 +987,7 @@ export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, targ /** * Make the review's actual target unmistakable BEFORE engines run. Prints the repo name/path/branch being reviewed, and — critically — warns when the directory you're standing in is a DIFFERENT git repo than the one being reviewed. resolveWorkingDir() is session-scoped (set at launch to process.cwd(), or moved by an explicit /workspace switch mid-session) — it no longer silently inherits a stale workspace pinned by a PRIOR session/directory, but an explicit mid-session /workspace switch can still leave your shell's cwd pointed somewhere else. That divergence used to be silent (a launch in repo X kept reviewing whatever repo a previous session had pinned, producing a 6-engine review of agon's own repo instead of the user's code); this turns any remaining divergence into a loud, actionable signal instead of a silent wrong-repo pass. */ -// @kern-source: review:773 +// @kern-source: review:887 function announceReviewTarget(dispatch: Dispatch, cwd: string, label: string): void { let reviewRoot = cwd; try { reviewRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not a git repo — keep cwd */ } @@ -865,7 +1006,7 @@ function announceReviewTarget(dispatch: Dispatch, cwd: string, label: string): v /** * Run Review with the full active engine panel by default, or an explicitly requested subset. With 2+ engines they run in PARALLEL — each gets its own hard timeout, so a slow-but-excellent reviewer (codex) never blocks the others and a hung engine can't wedge the whole review. Each engine's block is dispatched as it finishes; findings are combined into ctx.lastReviewResult for Cesar follow-up/fix planning. A one-engine eligible/explicit panel delegates to the streaming handleReview path. */ -// @kern-source: review:790 +// @kern-source: review:904 export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngines?: string[]): Promise { const abort = new AbortController(); try { @@ -1017,3 +1158,140 @@ export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext, ctx.setActiveAbort(null); } } + +/** + * Run /review role — the same parallel multi-engine review as handleReviewMany, but each engine reviews through a focused ROLE lens (security / correctness / dryness / performance) plus an 'overall' generalist backstop, so coverage is never partitioned away. Roles come from assignReviewRoles: an explicit roleIds list zips engine i → roleIds[i]; otherwise the fixed roster is assigned in order and extra engines fall back to 'overall'. The diff, grounding, sentinel JSON machine block, consensus merge, and results pager are identical to a normal review — roles only narrow each engine's ATTENTION via an extra ## ROLE block + role-scoped INSTRUCTIONS lead. + */ +// @kern-source: review:1057 +export async function handleReviewRoles(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngines?: string[], roleIds?: string[]): Promise { + const abort = new AbortController(); + try { + ensureAgonHome(); + const cwd = resolveWorkingDir(); + let engineIds: string[]; + try { + engineIds = selectReviewEngines(requestedEngines, ctx); + } catch (err) { + dispatch({ type: 'error', message: err instanceof Error ? err.message : String(err) }); + return; + } + + // Resolve the diff once — every role reviews the same target. + let diff: string; + let label: string; + try { + ({ diff, label } = resolveReviewTarget(target, cwd, undefined)); + } catch (err) { + dispatch({ type: 'error', message: err instanceof Error ? err.message : String(err) }); + return; + } + announceReviewTarget(dispatch, cwd, label); + if (!diff.trim()) { + dispatch({ type: 'info', message: `No changes to review (${label}).` }); + return; + } + + const roleByEngine = assignReviewRoles(engineIds, roleIds); + dispatch({ type: 'info', message: `Roles: ${engineIds.map((id) => `${id}=${roleByEngine.get(id)?.id ?? 'overall'}`).join(' · ')}` }); + + const config = ctx.config as any; + const timeoutSec = config.reviewTimeout ?? config.agentTimeout ?? 420; + interface Collected { engineId: string; reviewOutput: string; unstructured: boolean; status: string; note?: string } + const controllers: AbortController[] = []; + const onMasterAbort = () => { for (const c of controllers) c.abort(); }; + ctx.setActiveAbort(abort); + if (abort.signal.aborted) onMasterAbort(); + else abort.signal.addEventListener('abort', onMasterAbort, { once: true }); + + const reviewOne = async (engineId: string): Promise => { + const controller = new AbortController(); + controllers.push(controller); + let timedOut = false; + let timer: ReturnType | undefined; + const role = roleByEngine.get(engineId); + try { + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + resolve(null); + }, timeoutSec * 1000); + }); + const corePromise = runReviewCore(diff, label, engineId, ctx, controller.signal, undefined, undefined, role?.id); + corePromise.catch(() => undefined); + const result = await Promise.race([corePromise, timeoutPromise]); + if (result === null || timedOut) { + dispatch({ type: 'warning', message: `${engineId}: timed out after ${timeoutSec}s — skipped.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'timeout' }; + } + const response = (result.response ?? '').trim(); + if (!response) { + dispatch({ type: 'warning', message: `${engineId} returned no review output.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'error', note: 'no output' }; + } + const status = result.unstructured ? 'unstructured' : 'ok'; + const roleTag = role ? ` [${role.id}]` : ''; + dispatch({ type: 'info', message: result.unstructured + ? `${icons().success} ${engineId}${roleTag}: unstructured (no machine verdict)` + : `${icons().success} ${engineId}${roleTag}: ${formatReviewCounts(result.severityCounts)}` }); + appendMessage(ctx.chatSession, { role: 'engine', engineId, content: response, timestamp: new Date().toISOString() }); + tracker.record(engineId, { prompt: `[review${roleTag} ${label}]`, response }); + return { engineId, reviewOutput: response, unstructured: result.unstructured, status }; + } catch (err) { + if (timedOut) { + dispatch({ type: 'warning', message: `${engineId}: timed out after ${timeoutSec}s — skipped.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'timeout' }; + } + const msg = err instanceof Error ? err.message : String(err); + dispatch({ type: 'error', message: `${engineId}: ${msg}` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'error', note: msg }; + } finally { + if (timer) clearTimeout(timer); + } + }; + + appendMessage(ctx.chatSession, { role: 'user', content: `[review role ${label}]`, timestamp: new Date().toISOString() }); + const all = await Promise.all(engineIds.map((id) => reviewOne(id))); + const collected = all.filter((c) => c.reviewOutput); + + if (collected.length === 0) { + dispatch({ type: 'warning', message: `No review output returned from ${engineIds.join(', ')}.` }); + ctx.setActiveAbort(null); + return; + } + + const outcomes = all.map((c) => reviewOutcome(c.engineId, c.reviewOutput, c.status, c.note)); + const consensus = buildConsensus(outcomes as any); + const consensusSummary = buildReviewConsensusLines(consensus).join('\n'); + if (consensus.degraded) dispatch({ type: 'warning', message: consensus.degraded.warning }); + dispatch({ type: consensus.autoBlock ? 'warning' : 'info', message: consensusSummary }); + + const anyUnstructured = collected.some((c) => c.unstructured); + ctx.lastReviewResult = { + engineId: collected.map((r) => r.engineId).join(', '), + target: target ?? 'uncommitted', + label: `${label} (role review)`, + diff, + reviewOutput: collected.map((r) => `## ${r.engineId} [${roleByEngine.get(r.engineId)?.id ?? 'overall'}]\n\n${r.reviewOutput}`).join('\n\n---\n\n'), + timestamp: Date.now(), + }; + + sessionResultStore.add({ + type: 'review', + timestamp: new Date().toISOString(), + question: `${label} (role review)`, + engines: collected.map((r) => r.engineId), + winner: null, + data: { + label: `${label} (role review)`, + consensusSummary, + blocking: consensus.autoBlock, + reviews: collected.map((r) => ({ engineId: `${r.engineId} [${roleByEngine.get(r.engineId)?.id ?? 'overall'}]`, status: r.status, reviewOutput: stripMachineBlock(r.reviewOutput) })), + }, + }); + + dispatch({ type: 'info', message: `Role review complete (${collected.map((r) => `${r.engineId}=${roleByEngine.get(r.engineId)?.id ?? 'overall'}`).join(', ')}).${anyUnstructured ? ' Some reviews were unstructured (no machine verdict) but valid.' : ''} Ctrl+R for the full reviews · say "fix it" or "fix it with " to address the findings.` }); + } finally { + ctx.setActiveAbort(null); + } +} diff --git a/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts b/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts index cf4fe5a9b..ba99a41cb 100644 --- a/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts +++ b/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts @@ -8,7 +8,7 @@ import type { Dispatch } from '../../../handlers/types.js'; import { ENGINE_COLORS } from '../../blocks/output-format.js'; -import { handleForge, handleBrainstorm, handleCampfire, handleTribunal, handleThink, handleCouncil, handleSynthesis, handleNeroChallenge, handleResearch, handleChrome, handleConquer, handleBuild, handleReviewMany, runAgentMode, runAgentTeam } from '../../../handlers/index.js'; +import { handleForge, handleBrainstorm, handleCampfire, handleTribunal, handleThink, handleCouncil, handleSynthesis, handleNeroChallenge, handleResearch, handleChrome, handleConquer, handleBuild, handleReviewMany, handleReviewRoles, runAgentMode, runAgentTeam } from '../../../handlers/index.js'; import { handleTeamTribunal } from '../../handlers/team-tribunal.js'; diff --git a/packages/cli/src/generated/signals/intent.ts b/packages/cli/src/generated/signals/intent.ts index 47d25754f..68d904ee8 100644 --- a/packages/cli/src/generated/signals/intent.ts +++ b/packages/cli/src/generated/signals/intent.ts @@ -54,54 +54,55 @@ export interface Intent { reasoning: string|undefined; count: number|undefined; last: boolean|undefined; + roles: string[]|undefined; } -// @kern-source: intent:52 -export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGENTS.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; +// @kern-source: intent:53 +export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/review role', desc: '[security|correctness|dryness|performance] [] — multi-role review: each engine a focused lens + overall backstop' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGENTS.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; -// @kern-source: intent:54 +// @kern-source: intent:55 export const FITNESS_PATTERN: RegExp = /\b(?:test with|test:|--test|fitness:)\s+(.+)/i; -// @kern-source: intent:57 +// @kern-source: intent:58 export const LEADERBOARD_KEYWORDS: RegExp = /\b(leaderboard|elo|rankings?)\b/i; -// @kern-source: intent:59 +// @kern-source: intent:60 export const HISTORY_KEYWORDS: RegExp = /\b(history|last runs?|recent)\b/i; -// @kern-source: intent:61 +// @kern-source: intent:62 export const ENGINES_KEYWORDS: RegExp = /\b(engines?|what engines)\b/i; -// @kern-source: intent:63 +// @kern-source: intent:64 export const CONFIG_KEYWORDS: RegExp = /\b(config|settings?)\b/i; -// @kern-source: intent:65 +// @kern-source: intent:66 export const HELP_KEYWORDS: RegExp = /^(help|\?)$/i; -// @kern-source: intent:67 +// @kern-source: intent:68 export const EXIT_KEYWORDS: RegExp = /^(exit|quit|bye)$/i; -// @kern-source: intent:69 +// @kern-source: intent:70 export const SENTENCE_PREFIX: RegExp = /^(do|does|did|is|are|was|were|have|has|had|can|could|would|should|will|shall|i\s)/i; -// @kern-source: intent:71 +// @kern-source: intent:72 export const QUESTION_PATTERN: RegExp = /^(what|how|why|where|when|who|which|explain|describe|tell|show|list|is there|does|can you explain|walk me through)\b/i; -// @kern-source: intent:73 +// @kern-source: intent:74 export const CODE_TASK_PATTERN: RegExp = /^(fix|add|implement|refactor|debug|create|build|write|update|change|remove|delete|rename|move|test|deploy|install|upgrade|migrate|convert|extract|inline|optimize|port)\b/i; -// @kern-source: intent:75 +// @kern-source: intent:76 export const CODE_ARTIFACT_PATTERN: RegExp = /(?:at \w+.*:\d+|\.[tj]sx?\b|\.[a-z]{2,4}:\d+|^[+-]{3}\s)/m; -// @kern-source: intent:77 +// @kern-source: intent:78 export const AGENT_TRIGGER_PATTERN: RegExp = /^(?:agent(?:\s+mode)?|autonomous(?:\s+agent)?|run\s+agent)\s+([\s\S]+)$/i; -// @kern-source: intent:80 +// @kern-source: intent:81 export const AUTOCREDIT_OFF_KEYWORDS: RegExp = /\b(?:schalt(?:e|)?\s+(?:das|es|autoCredit)\s+ab|mach(?:e|)?\s+(?:das|es|autoCredit)\s+(?:aus|weg)|das\s+nervt|(?:autoCredit|co[\s-]?authored?|contributor)\s+(?:aus|ab|weg|nervt))\b/i; -// @kern-source: intent:82 +// @kern-source: intent:83 export const AUTOCREDIT_ON_KEYWORDS: RegExp = /\b(?:schalt(?:e|)?\s+(?:das|es|autoCredit)\s+an|mach(?:e|)?\s+(?:das|es|autoCredit)\s+an|(?:autoCredit|co[\s-]?authored?|contributor)\s+an)\b/i; -// @kern-source: intent:85 +// @kern-source: intent:86 export function classifyTask(input: string): 'code'|'question'|'ambiguous' { if (hostRegexObjectTest(QUESTION_PATTERN, input)) { return 'question'; @@ -115,7 +116,7 @@ export function classifyTask(input: string): 'code'|'question'|'ambiguous' { return 'ambiguous'; } -// @kern-source: intent:95 +// @kern-source: intent:96 function parseForgeInput(input: string): Intent { // Only match --hardened as a standalone flag (not inside task text or test args) const hardenedMatch = ((__m) => __m === null ? null : { full: __m[0], groups: Array.from(__m).slice(1).map((g) => g === undefined ? null : g), index: __m.index, named: __m.groups ? Object.fromEntries(Object.entries(__m.groups).map(([__k, __v]) => [__k, __v === undefined ? null : __v])) : {} })(input.match(/^(--hardened)[ \t\n\r\f\v]+(.*)$/i)) || ((__m) => __m === null ? null : { full: __m[0], groups: Array.from(__m).slice(1).map((g) => g === undefined ? null : g), index: __m.index, named: __m.groups ? Object.fromEntries(Object.entries(__m.groups).map(([__k, __v]) => [__k, __v === undefined ? null : __v])) : {} })(input.match(/^(.*?)[ \t\n\r\f\v]+(--hardened)[ \t\n\r\f\v]*$/i)); @@ -127,7 +128,7 @@ function parseForgeInput(input: string): Intent { return { type: 'forge', task: task, fitnessCmd: fitnessCmd, hardened: hardened } as Intent; } -// @kern-source: intent:106 +// @kern-source: intent:107 function parseAgentShortcut(input: string): Intent|null { const match = hostRegexMatch(AGENT_TRIGGER_PATTERN, input); if (!match) { @@ -140,17 +141,17 @@ function parseAgentShortcut(input: string): Intent|null { return { type: 'agent', input: task } as Intent; } -// @kern-source: intent:116 +// @kern-source: intent:117 function stripCollaborationLeadIn(input: string): string { return input.replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:ask|have|get)[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:to[ \t\n\r\f\v]+)?/i, '').replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?what[ \t\n\r\f\v]+do[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:think[ \t\n\r\f\v]+about[ \t\n\r\f\v]+|say[ \t\n\r\f\v]+about[ \t\n\r\f\v]+|recommend[ \t\n\r\f\v]+for[ \t\n\r\f\v]+)?/i, '').replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:talk|think)[ \t\n\r\f\v]+(?:it|this)?[ \t\n\r\f\v]*(?:through[ \t\n\r\f\v]+)?with[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]*/i, '').trim(); } -// @kern-source: intent:120 +// @kern-source: intent:121 function hasCollaborationAskShape(input: string): boolean { return /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:ask|have|get)[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)\b/i.test(input) || /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?what[ \t\n\r\f\v]+do[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:think|say|recommend)\b/i.test(input) || /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:brainstorm|compare|weigh[ \t\n\r\f\v]+in)[ \t\n\r\f\v]+(?:this|it)?[ \t\n\r\f\v]*(?:with[ \t\n\r\f\v]+)?(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)\b/i.test(input); } -// @kern-source: intent:124 +// @kern-source: intent:125 function parseSemanticCollaborationShortcut(input: string): Intent|null { const question = stripCollaborationLeadIn(input); if (/\b(?:debate|argue|tribunal|red-team|red[ \t\n\r\f\v]+team)\b/i.test(input)) { @@ -166,7 +167,7 @@ function parseSemanticCollaborationShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:136 +// @kern-source: intent:137 function parseSemanticForgeShortcut(input: string): Intent|null { const explicitForgeImperative = /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?forge\b/i.test(input) && !/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?forge[ \t\n\r\f\v]+(?:is|was|seems?|looks?|does|did|can|should|would|will|not|still)\b/i.test(input); const hasForgeShape = explicitForgeImperative || /\b(?:forge[ \t\n\r\f\v]+this|forge[ \t\n\r\f\v]+it|have[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:engines|models|team|others)[ \t\n\r\f\v]+compete|make[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:engines|models|team|others)[ \t\n\r\f\v]+compete|competitive[ \t\n\r\f\v]+(?:build|implementation|fix))\b/i.test(input); @@ -181,23 +182,23 @@ function parseSemanticForgeShortcut(input: string): Intent|null { /** * Plain text must not start orchestration. Brainstorm, tribunal, campfire, forge, and review are slash-only from chat input; mention words like 'tribunal' or 'forge' should reach Cesar as normal text unless the user uses /tribunal, /forge, etc. */ -// @kern-source: intent:146 +// @kern-source: intent:147 function parseSemanticDelegationShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:151 +// @kern-source: intent:152 function splitReviewArgs(input: string): string[] { return input.split(/[ \t\n\r\f\v]+/).flatMap((part) => part.split(',')).map((part) => part.trim()).filter(Boolean); } -// @kern-source: intent:155 +// @kern-source: intent:156 function isReviewTargetArg(part: string): boolean { const lower = part.toLowerCase(); return lower === 'uncommitted' || lower.startsWith('branch:') || lower.startsWith('commit:'); } -// @kern-source: intent:160 +// @kern-source: intent:161 function isImplicitReviewSubjectArg(part: string): boolean { const lower = part.toLowerCase(); return lower === 'it' || lower === 'this' || lower === 'that' || lower === 'them' || lower === 'changes' || lower === 'diff'; @@ -206,14 +207,32 @@ function isImplicitReviewSubjectArg(part: string): boolean { /** * Parse review args into target + engine list. When bareWordsAreEngines is true (the explicit /review slash path), any bare word that isn't a target (uncommitted/branch:/commit:) or a keyword is treated as an engine name — so `/review codex claude` reviews with BOTH, no `with` needed. The natural-language shortcut path leaves it false so prose like `review this code` doesn't mis-bind `code` as an engine. */ -// @kern-source: intent:165 +// @kern-source: intent:166 function parseReviewInput(input: string, bareWordsAreEngines?: boolean): Intent { const reviewParts = splitReviewArgs(input); const engineIds: string[] = []; let target: string | undefined; let collectingEngines = false; - for (let i = 0; i < reviewParts.length; i += 1) { + // `/review role …` — a leading `role`/`roles` keyword switches to the focused + // multi-role review. Any following bare words that match a known role id are + // collected as the explicit role roster (engine i → role i); the rest parse + // exactly like a normal /review (target + engines). With no role names, the + // handler assigns the fixed roster automatically. + let roleMode = false; + const roleIds: string[] = []; + const KNOWN_ROLES = new Set(['security', 'correctness', 'dryness', 'performance', 'overall']); + let startIdx = 0; + if (reviewParts.length > 0 && /^(role|roles)$/i.test(reviewParts[0])) { + roleMode = true; + startIdx = 1; + while (startIdx < reviewParts.length && KNOWN_ROLES.has(reviewParts[startIdx].toLowerCase())) { + roleIds.push(reviewParts[startIdx].toLowerCase()); + startIdx += 1; + } + } + + for (let i = startIdx; i < reviewParts.length; i += 1) { const part = reviewParts[i]; const lower = part.toLowerCase(); if (lower === 'and' || lower === 'or' || lower === 'plus') { @@ -238,10 +257,13 @@ function parseReviewInput(input: string, bareWordsAreEngines?: boolean): Intent } const engineId = engineIds[0]; + if (roleMode) { + return { type: 'review-role', engineId, engineIds: engineIds.length > 0 ? engineIds : undefined, target, roles: roleIds.length > 0 ? roleIds : undefined } as Intent; + } return { type: 'review', engineId, engineIds: engineIds.length > 0 ? engineIds : undefined, target } as Intent; } -// @kern-source: intent:201 +// @kern-source: intent:223 function parseReviewShortcut(input: string): Intent|null { const match = ((__m) => __m === null ? null : { full: __m[0], groups: Array.from(__m).slice(1).map((g) => g === undefined ? null : g), index: __m.index, named: __m.groups ? Object.fromEntries(Object.entries(__m.groups).map(([__k, __v]) => [__k, __v === undefined ? null : __v])) : {} })(input.match(/^(?:review|cr)(?:[ \t\n\r\f\v]+([ \t\n\r\f\v\S]+))?$/i)); if (!match) { @@ -269,7 +291,7 @@ function parseReviewShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:223 +// @kern-source: intent:245 function parseSlashCommand(input: string, commandRegistry?: any): Intent { const stripped = input.slice(1).trim(); if (!stripped) return { type: 'slash-list' } as Intent; @@ -692,7 +714,7 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { } } -// @kern-source: intent:646 +// @kern-source: intent:668 export function detectIntent(raw: string, commandRegistry?: any): Intent { const input = raw.trim(); if (!input) { diff --git a/packages/cli/src/generated/surfaces/app.tsx b/packages/cli/src/generated/surfaces/app.tsx index 34ea554b8..435df16c3 100644 --- a/packages/cli/src/generated/surfaces/app.tsx +++ b/packages/cli/src/generated/surfaces/app.tsx @@ -1710,16 +1710,9 @@ export function App() { () => nativeLiveRows.map((row: any) => ), [nativeLiveRows], ); - // Whenever the bottom-chrome PlanChip is showing it already carries the - // plan glance (Step N/M · bar · % · current step), so the inline TodoList - // suppresses the duplicate plan-step rows (they live in the Ctrl+G rail). - // Keyed on planChipVisible — the SAME predicate that drives the chip — so - // the two surfaces stay in lockstep across every plan state (incl. the - // post-done retain window). Live (non-plan) todos always render. const lowerPanel = ( - {startupUseDashboardView && (displayRows.length === 0 || terminalMode === 'native') && ( @@ -1954,6 +1947,15 @@ export function App() { executionRailOpen={executionRailOpen} /> )} {liveSpinner && mode !== 'chat' && } + {/* Pinned todo list — docked at the END of the dynamic region so it sits + directly above the bottom chrome (plan chip + composer) instead of + floating above the live stream. When the bottom-chrome PlanChip is + showing it already carries the plan glance (Step N/M · bar · % · + current step), so TodoList suppresses duplicate plan-step rows. + Keyed on planChipVisible — the SAME predicate that drives the chip — + so the two surfaces stay in lockstep across every plan state (incl. + the post-done retain window). Live (non-plan) todos always render. */} + {!enginePickerOpen && !modelPickerOpen && !cesarPickerOpen && !railTakeover && ( { ensureAgonHome(); // Session-scoped grounding ONLY — deliberately does NOT call diff --git a/packages/cli/src/handlers/index.ts b/packages/cli/src/handlers/index.ts index 6dee50b49..de6a67197 100644 --- a/packages/cli/src/handlers/index.ts +++ b/packages/cli/src/handlers/index.ts @@ -35,7 +35,7 @@ export { handleRun } from './run.js'; export { handlePipeline } from './pipeline.js'; export { handleFlowReport, handleFlowAnalysis, autoLogFlow } from './flow.js'; export { handleCommit } from './commit.js'; -export { handleReview, handleReviewMany } from './review.js'; +export { handleReview, handleReviewMany, handleReviewRoles } from './review.js'; export { runAgentMode, runAgentTeam } from '../generated/handlers/agent.js'; export { handleThink } from '../generated/handlers/think.js'; export { handleCouncil } from '../generated/handlers/council.js'; diff --git a/packages/cli/src/handlers/review.ts b/packages/cli/src/handlers/review.ts index ae63deb7a..4a577032f 100644 --- a/packages/cli/src/handlers/review.ts +++ b/packages/cli/src/handlers/review.ts @@ -1 +1 @@ -export { handleReview, handleReviewMany } from '../generated/handlers/review.js'; +export { handleReview, handleReviewMany, handleReviewRoles } from '../generated/handlers/review.js'; diff --git a/packages/cli/src/kern/blocks/frame-capture.kern b/packages/cli/src/kern/blocks/frame-capture.kern new file mode 100644 index 000000000..fca506784 --- /dev/null +++ b/packages/cli/src/kern/blocks/frame-capture.kern @@ -0,0 +1,102 @@ +// ── Frame capture ──────────────────────────────────────────────────── +// In-process pseudo-TTY harness for rendering a generated Ink surface and +// reading back its ANSI-stripped text frame. Extracted from +// tests/unit/terminal-frame.test.ts so both the tests and the RenderProbe +// tool share one implementation. +// +// Nero-mandated semantics: Ink render with `debug: true` rewrites the FULL +// frame on every render, and the pseudo-TTY records each stdout write as a +// SEPARATE chunk. `lastFrame()` returns only the final chunk (the settled +// viewport); `read()` returns the concatenated transcript (stale intermediate +// renders survive there) and exists only for the legacy substring-style tests. + +import from="node:stream" names="PassThrough" +import from="react" default="React" +import from="ink" names="render" + +interface name=PseudoTty export=true + field name=stdout type=any + field name=stderr type=any + field name=stdin type=any + field name=chunks type="string[]" + field name=lastFrame type="() => string" + field name=read type="() => string" + +fn name=stripTerminalControl params="value:string" returns=string export=true + doc "Strip OSC/CSI terminal control sequences and carriage returns from a captured stream." + handler <<< + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, ''); + >>> + +// KERN-GAP: native-record-closure-capture — a native handler cannot return a +// record whose function-valued fields (lastFrame/read) close over a mutable +// array binding (chunks); the compiler rejects it ("stale/fresh array binding +// captured by a record field"). This factory IS a closure-over-mutable-buffer, +// so it stays a raw TS handler until KERN gains an opt-out for intentional +// mutable-capture in record fields (cf. `state safe=false` for Ink state). +fn name=createPseudoTty params="width:number, height:number" returns=PseudoTty export=true + doc "Create a fake TTY stdout/stderr/stdin trio that records each stdout write as a separate chunk." + handler <<< + const stdout = new PassThrough() as PassThrough & { isTTY: boolean; columns: number; rows: number }; + stdout.isTTY = true; + stdout.columns = width; + stdout.rows = height; + const stderr = new PassThrough() as PassThrough & { isTTY: boolean }; + stderr.isTTY = true; + const stdin = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode: (mode: boolean) => void }; + stdin.isTTY = true; + stdin.setRawMode = () => {}; + const chunks: string[] = []; + stdout.on('data', (chunk: Buffer | string) => { chunks.push(chunk.toString()); }); + return { + stdout, + stderr, + stdin, + chunks, + // Final settled frame: the LAST full-frame write with real content, + // ANSI-stripped. Never the concatenation — that is a transcript artifact + // with stale renders in it. We scan backwards for the last non-empty + // chunk because Ink's unmount appends a trailing clear write (an empty + // frame), which is not the settled viewport. + lastFrame: () => { + for (let i = chunks.length - 1; i >= 0; i--) { + const stripped = stripTerminalControl(chunks[i]); + if (stripped.trim().length > 0) return stripped; + } + return ''; + }, + // Legacy accumulator: the whole transcript joined. Substring-style tests + // that predate the final-frame fix rely on this. + read: () => stripTerminalControl(chunks.join('')), + }; + >>> + +fn name=captureSurfaceFrame async=true params="component:any, props:Record, cols:number, rows:number" returns="Promise" export=true + doc "Render an Ink component in an isolated pseudo-TTY at the given size and return the final ANSI-stripped frame. Unmounts before returning; leaves no open handles." + handler <<< + const tty = createPseudoTty(cols, rows); + const app = render(React.createElement(component, props as any), { + stdout: tty.stdout as any, + stderr: tty.stderr as any, + stdin: tty.stdin as any, + debug: true, + exitOnCtrlC: false, + patchConsole: false, + }); + let frame = ''; + try { + // Let effects flush and the final frame settle before capturing. Capture + // the settled frame BEFORE unmount so a trailing clear write can never + // race the read. + await new Promise((resolve) => setTimeout(resolve, 30)); + frame = tty.lastFrame(); + } finally { + app.unmount(); + } + // Drain the unmount write so no listener fires after we return. + await new Promise((resolve) => setTimeout(resolve, 5)); + return frame; + >>> diff --git a/packages/cli/src/kern/blocks/todo-list.kern b/packages/cli/src/kern/blocks/todo-list.kern index bb3c11913..b8a70a6d7 100644 --- a/packages/cli/src/kern/blocks/todo-list.kern +++ b/packages/cli/src/kern/blocks/todo-list.kern @@ -2,8 +2,10 @@ // Renders the rolling todo list above the composer. Mirrors the idiom of // blocks/plan-view.kern (state icons + compact rows). Renders nothing when // the list is empty; otherwise shows a small "Todos N/M" header followed -// by one row per item. Lives in the dynamic region (between ChromeBar and -// BackgroundJobRail) — see surfaces/app.kern wiring. +// by one row per item. Docked at the END of the dynamic region, directly +// above BottomChromeSection (plan chip + composer) — see surfaces/app.kern +// wiring — so it stays pinned to the bottom instead of floating above the +// live stream. import from="react" default="React" import from="ink" names="Box,Text" diff --git a/packages/cli/src/kern/cesar/session.kern b/packages/cli/src/kern/cesar/session.kern index f9e3694e7..acad5b8c0 100644 --- a/packages/cli/src/kern/cesar/session.kern +++ b/packages/cli/src/kern/cesar/session.kern @@ -8,6 +8,7 @@ import from="@kernlang/agon-core" names="PersistentSession,PersistentSessionConf import from="@kernlang/agon-core" names="EngineRegistry,loadConfig,ensureAgonHome,getAgonHome,resolveWorkingDir,scanProjectContext,buildCodebaseMap,buildKernContextSpine,buildProjectMemoryBlock,createPersistentSession,ToolRegistry,getProjectFileStateCache,buildToolSystemPrompt,toolsToOpenAIFormat,executeToolCall,RUNS_DIR,tracker,discoverMcpServers,mcpDiscoveryFingerprint,mcpServersToWireFormat,listCesarPlans,saveConversation,formatChatContextForPrompt,isReadOnlyCommand,AGON_MODE_NAMES,parsePermissionRuleSet,parseToolHooks,PERMISSION_DENIED_MESSAGE,claudeBrainUsesPty" import from="@kernlang/agon-core" names="ToolContext,ToolCallResult" types=true import from="@kernlang/agon-core" names="resolveGuardMode,readGuardModesFromConfig" +import from="@kernlang/agon-core" names="recordTextTransportDispatch,textTransportDigest" import from="@kernlang/agon-core" names="GuardMode" types=true import from="../../handlers/types.js" names="HandlerContext" types=true import from="./tools.js" names="createCesarToolRegistry" @@ -719,8 +720,16 @@ fn name=buildOnToolCall params="ctx:HandlerContext, toolRegistry:ToolRegistry, c signal: sharedToolCtx.abortSignal, }); + // Delegate tool ledger (2b): the Delegate seam returns only adapter + // stdout (text), so Cesar has NO per-call tool visibility here — record + // it as a text transport with a single 'unknown', never a fabricated + // ok/error parsed from prose. Companion-backed engines are labeled as + // such; everything else is a CLI --print text transport. Best-effort. + const delegateBackend = (targetEngine as any)?.companion ? 'companion' : 'cli-print'; + recordTextTransportDispatch(targetId, mode, delegateBackend); + if (!result.stdout.trim()) { - return `[Delegate → ${targetId}] Engine returned empty response.`; + return `[Delegate → ${targetId}] Engine returned empty response.\n[tool ledger: ${textTransportDigest()}]`; } // Strip blocks from response @@ -733,10 +742,18 @@ fn name=buildOnToolCall params="ctx:HandlerContext, toolRegistry:ToolRegistry, c tracker.record(targetId, { prompt: task, response: cleaned }); } - return `[Delegate → ${targetId}]\n${cleaned}`; + return `[Delegate → ${targetId}]\n${cleaned}\n\n[tool ledger: ${textTransportDigest()}]`; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - return `[Delegate → ${targetId}] Error: ${msg}`; + // A FAILED dispatch is exactly what the reliability ledger exists to + // expose — record it too (zai + kimi convergent review finding: the + // success-only recording made engine failures invisible). Same honest + // text-transport shape; the failure itself is the signal. + try { + const failedBackend = (targetEngine as any)?.companion ? 'companion' : 'cli-print'; + recordTextTransportDispatch(targetId, mode, failedBackend, { dispatchFailed: true }); + } catch { /* ledger append is best-effort — never mask the dispatch error */ } + return `[Delegate → ${targetId}] Error: ${msg}\n[tool ledger: dispatch failed — recorded]`; } } diff --git a/packages/cli/src/kern/cesar/tool-engine-reliability.kern b/packages/cli/src/kern/cesar/tool-engine-reliability.kern new file mode 100644 index 000000000..0d23060ec --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-engine-reliability.kern @@ -0,0 +1,62 @@ +// ── EngineReliability tool ─────────────────────────────────────────── +// Read-only tool that lets Cesar query per-engine tool reliability mid-turn +// instead of only receiving one injected line at dispatch time. It reuses the +// existing Cesar own-turn summarizers (never re-derives thresholds) and renders +// TWO explicitly labeled sections that answer different questions and must +// never be merged into a single number (nero challenge 5): +// - CESAR OWN-TURN RELIABILITY — what tools Cesar itself produced per turn. +// - DELEGATED DISPATCH LEDGER — what delegated engines did per tool call. +// Part 2b (the delegate ledger) now feeds the delegated section from +// summarizeDelegateReliability, grouped strictly per backend so an engine's +// text-transport unknowns are never blended into its api-loop reliability. + +import from="@kernlang/agon-core" names="ToolDefinition,ToolHandler,ToolContext,ToolResult,PermissionDecision" types=true +import from="@kernlang/agon-core" names="summarizeDelegateReliabilityByEngine,formatAllDelegateReliability" +import from="./reliability.js" names="readCesarToolReliability,summarizeAllCesarToolReliability,formatCesarReliabilityLine,emptyCesarToolReliability" +import from="./reliability.js" names="CesarToolReliability" types=true + +fn name=buildOwnReliabilityLines params="engineId:string|undefined" returns="string[]" export=true + doc "Render the CESAR OWN-TURN RELIABILITY lines. With an engineId, one line for that engine (an engine with no logged turns summarizes to an empty 'calibrating' record — never an error). Without one, a line per observed engine, or a single all/all empty line when nothing is logged yet." + handler lang="kern" + let name=ownLines type="string[]" value="[]" + if cond="engineId" + comment raw="// A specific engine with zero logged turns summarizes to an empty reliability record labeled 'calibrating' — never an error." + do value="ownLines.push(formatCesarReliabilityLine(readCesarToolReliability(engineId)))" + else + let name=summaries type="CesarToolReliability[]" value="summarizeAllCesarToolReliability()" + if cond="summaries.length === 0" + do value="ownLines.push(formatCesarReliabilityLine(emptyCesarToolReliability('all', 'all')))" + else + each name=summary in="summaries" + do value="ownLines.push(formatCesarReliabilityLine(summary))" + return value="ownLines" + +fn name=engineReliabilityDefinition returns=ToolDefinition export=true + doc "Static ToolDefinition for the EngineReliability tool (read-only, two labeled reliability sections)." + handler lang="kern" + return value="{ name: 'EngineReliability', description: 'Report observed tool reliability per engine. Returns two labeled sections: CESAR OWN-TURN RELIABILITY (tools Cesar itself produced, from logged decision turns) and DELEGATED DISPATCH LEDGER (per-call outcomes for engines Cesar delegated to). Read-only. Optional engineId narrows the own-turn section to one engine (an engine with no logged turns reports \"calibrating\", not an error).', inputSchema: { type: 'object', properties: { engineId: { type: 'string', description: 'Optional engine id to narrow the own-turn reliability section. Omit for every observed engine.' }, scope: { type: 'string', enum: ['summary', 'turns', 'all'], description: 'Optional reporting scope. Reserved for future turn-level detail; the default digest is always returned.' } }, required: [] }, maxResultSizeChars: 20000, isReadOnly: true, isConcurrencySafe: true }" + +fn name=createEngineReliabilityTool returns=ToolHandler export=true + doc "Factory for the EngineReliability tool — a read-only digest of Cesar own-turn tool reliability plus a placeholder for the delegated dispatch ledger." + handler lang="kern" + return value="{ definition: engineReliabilityDefinition(), validate: engineReliabilityValidate, checkPermission: engineReliabilityCheckPermission, execute: engineReliabilityExecute }" + +fn name=engineReliabilityValidate params="_input:Record, _ctx:ToolContext" returns="string|null" export=true + doc "EngineReliability accepts any (optional) input — nothing to reject." + handler lang="kern" + return value="null" + +fn name=engineReliabilityCheckPermission params="_input:Record, _ctx:ToolContext" returns=PermissionDecision export=true + doc "EngineReliability is read-only — always allowed." + handler lang="kern" + return value="{ behavior: 'allow' }" + +fn name=engineReliabilityExecute async=true params="input:Record, _ctx:ToolContext" returns="Promise" export=true + doc "Render the two labeled reliability sections: CESAR OWN-TURN RELIABILITY and the per-engine DELEGATED DISPATCH LEDGER." + handler lang="kern" + let name=engineId value="typeof input.engineId === 'string' && input.engineId.trim() ? input.engineId.trim() : undefined" + let name=ownLines value="buildOwnReliabilityLines(engineId)" + comment raw="// Delegated dispatch ledger (2b) — grouped engineId × backend. An engineId narrows the ledger to that engine; without one, EVERY engine is rendered on its own line(s) so two engines' api-loop stats are never merged into a single number. An engine with no ledger records (or an empty ledger) renders \"no ledger records yet\", never an error." + let name=ledgerLines value="formatAllDelegateReliability(summarizeDelegateReliabilityByEngine(engineId))" + let name=content value="['CESAR OWN-TURN RELIABILITY', ownLines.join('\\n'), '', 'DELEGATED DISPATCH LEDGER', ledgerLines.join('\\n')].join('\\n')" + return value="{ ok: true, content }" diff --git a/packages/cli/src/kern/cesar/tool-render-probe.kern b/packages/cli/src/kern/cesar/tool-render-probe.kern new file mode 100644 index 000000000..46fac6949 --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-render-probe.kern @@ -0,0 +1,106 @@ +// ── RenderProbe tool ───────────────────────────────────────────────── +// Read-only tool that renders a generated Ink surface in-process at a given +// terminal size and returns the ANSI-stripped text frame, so Cesar can verify +// its own UI layout instead of editing it blind. Backed by captureSurfaceFrame +// (blocks/frame-capture), which returns the FINAL settled frame (not the +// transcript). A fixture registry maps a small set of surface ids to their +// component + default props; arbitrary surfaces need prop fixtures, so an +// unknown id returns the list of known ids rather than guessing. + +import from="@kernlang/agon-core" names="ToolDefinition,ToolHandler,ToolContext,ToolResult,PermissionDecision" types=true +import from="../blocks/frame-capture.js" names="captureSurfaceFrame" +import from="../surfaces/status.js" names="StatusBar" +import from="../blocks/todo-list.js" names="TodoList" +import from="../surfaces/app-views.js" names="ChromeBar" + +interface name=RenderFixture + field name=component type=any + field name=defaults type="Record" + +fn name=renderProbeFixtures returns="Record" export=true + doc "Fixture registry mapping RenderProbe surface ids to their generated component and default props." + handler <<< + return { + StatusBar: { + component: StatusBar, + defaults: { + cesarId: 'cesar-engine', + chatMessageCount: 0, + totalTokens: 0, + totalCostUsd: 0, + meteredCostUsd: 0, + hasPlanApiUsage: false, + hasCliUsage: false, + cwd: '~/workspace', + branch: 'main', + explorationMode: false, + autoModeQueued: false, + telemetryVitals: new Map(), + context: { pct: 0, used: 0, limit: 100000, compacted: 0, cached: 0, source: 'estimate' }, + termWidth: 100, + }, + }, + TodoList: { + component: TodoList, + defaults: { + todos: [], + planActive: false, + }, + }, + ChromeBar: { + component: ChromeBar, + defaults: { + mode: 'chat', + cwdLabel: 'workspace', + engineCount: 0, + replState: 'idle', + runningJobs: [], + }, + }, + }; + >>> + +fn name=renderProbeSurface async=true params="surface:string, component:any, defaults:Record, overrides:Record, cols:number, rows:number" returns="Promise" export=true + doc "Render one fixture surface — caller props shallow-merged over the fixture defaults — and return its final text frame. A render error becomes an error ToolResult, never a throw." + handler lang="kern" + let name=props value="Object.assign({}, defaults, overrides)" + try + let name=frame value="await captureSurfaceFrame(component, props, cols, rows)" + return value="{ ok: true, content: frame }" + catch name=err + return value="{ ok: false, content: '', error: `RenderProbe failed to render '${surface}': ${err instanceof Error ? err.message : String(err)}` }" + +fn name=renderProbeDefinition params="validIds:string[]" returns=ToolDefinition export=true + doc "ToolDefinition for the RenderProbe tool, parameterized by the valid surface ids so the description and schema advertise the live fixture set." + handler lang="kern" + return value="{ name: 'RenderProbe', description: `Render a known Ink surface in-process and return its ANSI-stripped text frame so you can verify layout. Valid surface ids: ${validIds.join(', ')}. Input: { surface, cols?=100, rows?=30, props? } — props are shallow-merged over the fixture defaults. Read-only.`, inputSchema: { type: 'object', properties: { surface: { type: 'string', description: `Surface id to render. One of: ${validIds.join(', ')}.` }, cols: { type: 'number', description: 'Terminal columns. Optional, defaults to 100.' }, rows: { type: 'number', description: 'Terminal rows. Optional, defaults to 30.' }, props: { type: 'object', description: 'Optional props shallow-merged over the fixture defaults.' } }, required: ['surface'] }, maxResultSizeChars: 40000, isReadOnly: true, isConcurrencySafe: true }" + +fn name=createRenderProbeTool returns=ToolHandler export=true + doc "Factory for the RenderProbe tool — renders a known Ink surface fixture and returns its final text frame." + handler lang="kern" + return value="{ definition: renderProbeDefinition(Object.keys(renderProbeFixtures())), validate: renderProbeValidate, checkPermission: renderProbeCheckPermission, execute: renderProbeExecute }" + +fn name=renderProbeValidate params="input:Record, _ctx:ToolContext" returns="string|null" export=true + doc "Require a non-empty `surface` id." + handler lang="kern" + return value="typeof input.surface === 'string' && input.surface.trim() ? null : 'Missing required parameter: surface'" + +fn name=renderProbeCheckPermission params="_input:Record, _ctx:ToolContext" returns=PermissionDecision export=true + doc "RenderProbe is read-only — always allowed." + handler lang="kern" + return value="{ behavior: 'allow' }" + +fn name=renderProbeExecute async=true params="input:Record, _ctx:ToolContext" returns="Promise" export=true + doc "Resolve the requested fixture, clamp dimensions, and render its final text frame. An unknown surface returns the list of known ids rather than guessing." + handler lang="kern" + let name=fixtures value="renderProbeFixtures()" + let name=validIds value="Object.keys(fixtures)" + let name=surface value="String(input.surface ?? '').trim()" + let name=fixture value="fixtures[surface]" + if cond="!fixture" + return value="{ ok: false, content: '', error: `Unknown surface '${surface}'. Valid surface ids: ${validIds.join(', ')}.` }" + comment raw="// Clamp dimensions: an in-process Ink render allocates per-cell state, so model-controlled unbounded cols/rows is a memory-exhaustion vector (agon-review finding). 400x200 covers any real terminal." + let name=cols value="typeof input.cols === 'number' && input.cols > 0 ? Math.min(Math.max(1, Math.floor(input.cols)), 400) : 100" + let name=rows value="typeof input.rows === 'number' && input.rows > 0 ? Math.min(Math.max(1, Math.floor(input.rows)), 200) : 30" + let name=overrides value="(input.props && typeof input.props === 'object' && !Array.isArray(input.props)) ? input.props as Record : {}" + return value="renderProbeSurface(surface, fixture.component, fixture.defaults, overrides, cols, rows)" diff --git a/packages/cli/src/kern/cesar/tool-tui-probe.kern b/packages/cli/src/kern/cesar/tool-tui-probe.kern new file mode 100644 index 000000000..d44cceb30 --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-tui-probe.kern @@ -0,0 +1,115 @@ +// ── TuiProbe tool ──────────────────────────────────────────────────────── +// End-to-end self-render verification: PTY-launches a THROWAWAY agon (isolated +// AGON_HOME + cwd tempdirs, empty forge roster, safelisted non-dispatching +// input) via py/agon-tui-probe.py and returns the FINAL terminal grid rendered +// through a pyte screen emulator — the actual on-screen state, not an +// ANSI-stripped transcript (nero challenge 1). v1 is a chrome/layout probe: +// the input safelist below mirrors the script's and is the dispatch guard. +// Complements RenderProbe (in-process, single surface) with the full app frame. + +import from="node:url" names="fileURLToPath" +import from="node:path" names="join,dirname" +import from="node:fs" names="existsSync" +import from="@kernlang/agon-core" names="spawnWithTimeout" +import from="@kernlang/agon-core" names="ToolDefinition,ToolHandler,ToolContext,ToolResult,PermissionDecision" types=true + +const name=TUI_PROBE_INPUT_SAFELIST type="readonly string[]" value={{ ['/help', '/status', '/todos', '/plans', '/checkpoints'] as const }} export=true + +fn name=resolveTuiProbePaths returns="{ script: string|null, agonBin: string|null }" export=true + doc "Locate py/agon-tui-probe.py and dist/index.js relative to this compiled module. PACKAGED layout: tsup bundles this module into a flat chunk directly under /dist/, so the package root is ONE level up. Dev/vitest layout: /src/generated/cesar/ → three levels up. All candidates probed with existsSync, mirroring resolveModelProbeScript's walk in agon-core." + handler lang="kern" + let name=here value="dirname(fileURLToPath(import.meta.url))" + comment raw="// Candidate package roots, most-specific first: dist/.js → pkg root (PACKAGED layout — tsup emits flat chunks directly under dist/; agon-review blocking finding); src/generated/cesar → pkg root (vitest/dev layout); then two more fallback depths." + let name=roots value="[join(here, '..'), join(here, '..', '..', '..'), join(here, '..', '..'), join(here, '..', '..', '..', '..')]" + let name=script kind=let value="null as string | null" + let name=agonBin kind=let value="null as string | null" + each name=root in="roots" + let name=s value="join(root, 'py', 'agon-tui-probe.py')" + if cond="!script && existsSync(s)" + assign target="script" value="s" + let name=b value="join(root, 'dist', 'index.js')" + if cond="!agonBin && existsSync(b)" + assign target="agonBin" value="b" + return value="{ script, agonBin }" + +fn name=createTuiProbeTool returns=ToolHandler export=true + doc "Factory for the TuiProbe tool — spawns a throwaway isolated agon under a PTY, drives one safelisted input, and returns the final pyte-emulated screen grid." + handler <<< + const definition: ToolDefinition = { + name: 'TuiProbe', + description: `Launch a throwaway isolated agon instance under a PTY, drive one scripted input, and return the FINAL rendered terminal frame (pyte screen state) so you can verify the real UI layout end-to-end. Input: { input?='/help', cols?=120, rows?=40, timeoutSec?=45 }. input must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')} (layout probe — never dispatches engines). Read-only from the real install's perspective (isolated AGON_HOME + cwd). Requires a built agon (dist/) and python3 with pyte.`, + inputSchema: { + type: 'object', + properties: { + input: { type: 'string', description: `Scripted input to drive. Must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')}. Defaults to /help.` }, + cols: { type: 'number', description: 'Terminal columns. Optional, defaults to 120.' }, + rows: { type: 'number', description: 'Terminal rows. Optional, defaults to 40.' }, + timeoutSec: { type: 'number', description: 'Probe timeout in seconds. Optional, defaults to 45.' }, + }, + }, + maxResultSizeChars: 60000, + isReadOnly: true, + isConcurrencySafe: false, + }; + + const validate = (input: Record, _ctx: ToolContext): string | null => { + const scripted = typeof input.input === 'string' ? input.input.trim() : '/help'; + // Control characters (incl. \n/\r) are rejected outright: the PTY treats a + // newline as "submit", so a safelisted first line could smuggle a second, + // engine-dispatching command past a prefix check (agon-review blocking + // finding). The python probe enforces the same rule — defense in depth. + if (/[\u0000-\u001f\u007f]/.test(scripted)) { + return 'TuiProbe input must be a single line without control characters'; + } + if (!TUI_PROBE_INPUT_SAFELIST.some((allowed) => scripted === allowed || scripted.startsWith(`${allowed} `))) { + return `TuiProbe input must start with one of: ${TUI_PROBE_INPUT_SAFELIST.join(', ')} (v1 is a layout probe and never dispatches engines)`; + } + return null; + }; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + const { script, agonBin } = resolveTuiProbePaths(); + if (!script) { + return { ok: false, content: '', error: 'TuiProbe: py/agon-tui-probe.py not found relative to the agon package.' }; + } + if (!agonBin) { + return { ok: false, content: '', error: 'TuiProbe: agon dist/index.js not found — build the package first (npm run build).' }; + } + const scripted = typeof input.input === 'string' && input.input.trim() ? input.input.trim() : '/help'; + const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.min(Math.max(1, Math.floor(input.cols)), 400) : 120; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(Math.max(1, Math.floor(input.rows)), 200) : 40; + const timeoutSec = typeof input.timeoutSec === 'number' && input.timeoutSec > 0 ? Math.floor(input.timeoutSec) : 45; + try { + const result = await spawnWithTimeout({ + command: 'python3', + args: [script, '--input', scripted, '--cols', String(cols), '--rows', String(rows), '--timeout', String(timeoutSec), '--agon-bin', agonBin], + // The python wrapper's own cwd is irrelevant — the script mkdtemps an + // isolated cwd for the child agon; SpawnOptions just requires one. + cwd: dirname(script), + timeout: (timeoutSec + 15) * 1000, + }); + if (result.timedOut) { + return { ok: false, content: '', error: `TuiProbe timed out after ${timeoutSec + 15}s (outer guard).` }; + } + let parsed: { frame?: string; durationMs?: number; state?: string; error?: string }; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch { + return { ok: false, content: '', error: `TuiProbe: probe emitted non-JSON output: ${result.stdout.slice(0, 400)}${result.stderr ? ` | stderr: ${result.stderr.slice(0, 400)}` : ''}` }; + } + if (parsed.error || typeof parsed.frame !== 'string') { + return { ok: false, content: '', error: `TuiProbe: ${parsed.error ?? 'probe returned no frame'}` }; + } + return { + ok: true, + content: `[TuiProbe · input=${scripted} · ${cols}x${rows} · ${parsed.durationMs ?? '?'}ms · final pyte screen state]\n\n${parsed.frame}`, + }; + } catch (err) { + return { ok: false, content: '', error: `TuiProbe failed: ${err instanceof Error ? err.message : String(err)}` }; + } + }; + + return { definition, validate, checkPermission, execute }; + >>> diff --git a/packages/cli/src/kern/cesar/tools.kern b/packages/cli/src/kern/cesar/tools.kern index 19ba599ed..1fd50c507 100644 --- a/packages/cli/src/kern/cesar/tools.kern +++ b/packages/cli/src/kern/cesar/tools.kern @@ -2,6 +2,9 @@ import from="@kernlang/agon-core" names="ToolRegistry,getProjectFileStateCache,c import from="@kernlang/agon-core" names="ToolContext,ToolCallResult" types=true import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="./council-tool.js" names="createCouncilTool" +import from="./tool-engine-reliability.js" names="createEngineReliabilityTool" +import from="./tool-render-probe.js" names="createRenderProbeTool" +import from="./tool-tui-probe.js" names="createTuiProbeTool" import from="./task-execution-lease.js" names="isTaskFileMutationAction,taskActionApprovalMessage,isApprovedPermissionResponse" import from="./permission-resolver.js" names="authorizeResolvedTaskAction" import from="../signals/output.js" names="getSessionAllowList" @@ -38,6 +41,9 @@ fn name=createCesarToolRegistry params="engineId?:string" returns="ToolRegistry" do value="toolRegistry.register(createExitPlanModeTool())" do value="toolRegistry.register(createListPlansTool())" do value="toolRegistry.register(createRetrieveResultTool(engineId))" + do value="toolRegistry.register(createEngineReliabilityTool())" + do value="toolRegistry.register(createRenderProbeTool())" + do value="toolRegistry.register(createTuiProbeTool())" return value="toolRegistry" fn name=createEagerToolContext params="ctx:HandlerContext, config:any, signal:AbortSignal, dispatch:Dispatch" returns="ToolContext" diff --git a/packages/cli/src/kern/commands/call.kern b/packages/cli/src/kern/commands/call.kern index a172d7544..0b865c172 100644 --- a/packages/cli/src/kern/commands/call.kern +++ b/packages/cli/src/kern/commands/call.kern @@ -243,6 +243,9 @@ fn name=buildCallCommands params="opts:CallCommandOptions" returns="BuiltCallCom 'review', opts.input?.trim() || 'uncommitted', ...textFlag('--engine', opts.engine), + // Role-lens review: 'auto' deals the fixed roster (security, correctness, + // dryness, performance, overall backstop); a comma list zips per engine. + ...textFlag('--roles', opts.roles), ...timeout, ...engines, ]); @@ -466,7 +469,7 @@ const name=callCommand type="any" }, roles: { type: 'string', - description: 'For council: override advisor roles (comma-separated, priority order)', + description: "For council: override advisor roles (comma-separated, priority order). For review: role-lens review — 'auto' or a comma-separated role list (security, correctness, dryness, performance, overall)", }, chairman: { type: 'string', diff --git a/packages/cli/src/kern/handlers/review.kern b/packages/cli/src/kern/handlers/review.kern index 25713627c..951c80b53 100644 --- a/packages/cli/src/kern/handlers/review.kern +++ b/packages/cli/src/kern/handlers/review.kern @@ -15,6 +15,39 @@ import from="./engine-filter.js" names="filterDefaultOrchestrationEngines" import from="../blocks/engine-helpers.js" names="stripReasoning,stripTuiChrome" import from="../lib/kern-host.js" names="hostNowMs" +fn name=refResolvesToCommit params="ref:string, cwd:string" returns=boolean export=false + doc "True when `ref^{commit}` resolves to a real commit in the given repo (best-effort: a git failure means it does not)." + handler lang="kern" + try + do value="execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd, encoding: 'utf-8' })" + return value="true" + catch name=e + return value="false" + +fn name=resolveAutoReviewBase params="cwd:string, branch:string" returns="string|null" export=true + doc "Resolve the base ref for reviewing the currently checked-out branch: the repo's default branch via origin/HEAD, falling back to origin/main, origin/master, main, master. Returns null when the only candidates ARE the branch being reviewed (i.e. you are on the default branch) or nothing resolves — callers keep the loud no-base error for that case." + handler lang="kern" + let name=stripOrigin value="(ref: string) => ref.replace(/^origin\\//, '')" + try + let name=sym value="execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf-8' }).trim()" + if cond="sym" + comment raw="// origin/main" + let name=cand value="sym.replace(/^refs\\/remotes\\//, '')" + comment raw="// The reviewed branch IS the repo default branch → there is no base; return null (loud error) instead of falling through to a possibly unrelated legacy main/master (agon-review blocking finding)." + if cond="stripOrigin(cand) === branch || cand === branch" + return value="null" + comment raw="// A stale origin/HEAD pointing at a pruned ref must not short-circuit the fallback chain — verify it resolves before trusting it." + if cond="refResolvesToCommit(cand, cwd)" + return value="cand" + catch name=e + comment raw="// no origin/HEAD (local-only repo) — fall through to candidates" + each name=fallbackCand in="['origin/main', 'origin/master', 'main', 'master']" + if cond="stripOrigin(fallbackCand) === branch || fallbackCand === branch" + continue + if cond="refResolvesToCommit(fallbackCand, cwd)" + return value="fallbackCand" + return value="null" + fn name=resolveReviewTarget params="target:string|undefined, cwd:string, base:string|undefined" returns="{diff:string, label:string}" handler <<< const t = (target ?? 'uncommitted').trim(); @@ -147,12 +180,29 @@ fn name=resolveReviewTarget params="target:string|undefined, cwd:string, base:st throw new Error(`Failed to resolve branch "${branch}": ${err instanceof Error ? err.message : String(err)}`); } if (branchSha && branchSha === headSha) { - throw new Error(`branch:${branch} points at the commit you are currently on, so diffing it against HEAD yields nothing to review. Use "branch:main" (or your base branch) to review this branch's commits, or "uncommitted" to review working-tree changes.`); - } - try { - diff = execFileSync('git', ['diff', `${branch}...HEAD`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); - } catch (err) { - throw new Error(`Failed to get branch diff for ${branch}: ${err instanceof Error ? err.message : String(err)}`); + // Targeting the branch you are currently on. The previous behavior + // was a loud error (itself a fix for the silent empty self-diff that + // read as "clean review"), but the caller's question is unambiguous — + // "this branch's commits vs its base" — so ANSWER it: auto-resolve + // the base to the repo default branch and diff merge-base...branch. + // The loud error remains only when no base can be resolved (you are + // on the default branch itself, or there is no main/master anchor). + const autoBase = resolveAutoReviewBase(cwd, branch); + if (!autoBase) { + throw new Error(`branch:${branch} points at the commit you are currently on and no base branch could be auto-resolved (are you on the default branch?). Use "range:BASE...${branch}" for an explicit two-ref diff, or "uncommitted" to review working-tree changes.`); + } + label = `branch ${branch} vs ${autoBase} (auto-base)`; + try { + diff = execFileSync('git', ['diff', `${autoBase}...${branch}`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); + } catch (err) { + throw new Error(`Failed to diff ${autoBase}...${branch}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + try { + diff = execFileSync('git', ['diff', `${branch}...HEAD`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); + } catch (err) { + throw new Error(`Failed to get branch diff for ${branch}: ${err instanceof Error ? err.message : String(err)}`); + } } } } else if (t.startsWith('commit:')) { @@ -507,8 +557,69 @@ module name=ReviewEngineSelection return sections.length ? sections.join('\n\n') : ''; >>> - fn name=runReviewCore params="diff:string, label:string, engineId:string, ctx:HandlerContext, signal?:AbortSignal, onProgress?:(chunk:string)=>void, cwdOverride?:string" returns="Promise" async=true export=true - doc "Core review flow with no ctx side effects. Used by both handleReview (with streaming dispatch) and the plan executor's review step (silent). Does NOT touch ctx.setActiveAbort, ctx.lastReviewResult, ctx.chatSession, or tracker. signal is optional: callers that don't have an abort controller can pass undefined. cwdOverride pins the working directory the review engine runs in AND the repo file-context is gathered from — goal passes the per-task worktree so review engines never operate in (and write to) the parent repo; defaults to resolveWorkingDir() for the interactive/CLI review paths." + // ── Review roles — a focused lens per engine, over the same diff + machine + // contract. A role only narrows the engine's ATTENTION (an extra ## ROLE + // block + a role-scoped INSTRUCTIONS lead); the SECURITY NOTICE, grounding, + // sentinel JSON machine block, and fail-closed parser stay byte-identical, so + // consensus merging and the results pager work untouched. ──────────────────── + + interface name=ReviewRole export=true + field name=id type=string + field name=title type=string + field name=focus type=string + + const name=REVIEW_ROLES type="readonly ReviewRole[]" value={{ [ + { id: 'security', title: 'Security', focus: 'injection, authN/authZ, secret or credential exposure, unsafe deserialization, path traversal, SSRF, XSS, insecure crypto, data exfiltration, and trusting attacker-controlled input. Trace untrusted data from entry to sink.' }, + { id: 'correctness', title: 'Correctness', focus: 'logic errors, broken conditionals, off-by-one and boundary mistakes, null/undefined handling, error and exception paths, async/race conditions, and edge cases the change does not cover. This is the deepest lens — verify each suspected bug against the real code before flagging.' }, + { id: 'dryness', title: 'Dryness & Modularity', focus: 'duplication that should be shared, leaked abstractions, misplaced responsibilities, tight coupling between modules, and functions or files doing too much. Judge whether the change fits the surrounding architecture.' }, + { id: 'performance', title: 'Performance', focus: 'unnecessary allocation, O(n²) or worse hot paths, repeated work in loops, blocking the event loop, unbounded growth (memory, listeners, caches), and N+1-style patterns. Only flag a cost you can justify from the code, not a theoretical one.' }, + { id: 'overall', title: 'Overall (generalist backstop)', focus: 'the whole change with no narrowed lens — bugs, security, performance, quality, and missing edge cases. You are the safety net: catch whatever the focused roles miss.' }, + ] as const }} export=true + + const name=REVIEW_ROLE_OUTSIDE_TAIL type=string value="Even though that is your focus, if you notice a BLOCKING issue OUTSIDE your role, flag it too — never let a real blocker fall through the cracks." export=true + + fn name=resolveReviewRole params="roleId:string|undefined" returns="ReviewRole|undefined" export=true + doc "Look up a role by id (case-insensitive). Returns undefined for none/unknown so callers can fall back to the generic prompt." + handler lang="kern" + if cond="!roleId" + return value="undefined" + let name=needle value="roleId.trim().toLowerCase()" + each name=r in="REVIEW_ROLES" + if cond="r.id === needle" + return value="r" + return value="undefined" + + fn name=assignReviewRoles params="engineIds:string[], roleIds:string[]|undefined" returns="Map" export=true + doc "Map each engine to a role. With an explicit roleIds list, zip engine i → roleIds[i] (extra engines cycle from the start; unknown ids → overall). Without one, seat the 'overall' generalist backstop FIRST whenever there are 2+ engines (a small panel must never lose the catch-all), then deal the specialist lenses (security, correctness, dryness, performance) in order; any engine past the roster also lands on 'overall'. A single engine gets the deepest lens (security) — it IS the whole panel." + handler lang="kern" + let name=out type="Map" value="new Map()" + let name=fallback value="resolveReviewRole('overall') ?? REVIEW_ROLES[REVIEW_ROLES.length - 1]" + if cond="roleIds && roleIds.length > 0" + let name=idx type=number value="0" kind=let + each name=engineId in="engineIds" + let name=picked value="resolveReviewRole(roleIds[idx % roleIds.length]) ?? fallback" + do value="out.set(engineId, picked)" + assign target="idx" op="+=" value="1" + return value="out" + // Specialist lenses in deal order (overall is pulled out and seated first). + let name=specialists value="REVIEW_ROLES.filter((r) => r.id !== 'overall')" + let name=multi value="engineIds.length >= 2" + let name=i2 type=number value="0" kind=let + each name=engineId2 in="engineIds" + let name=isBackstopSeat value="multi && i2 === 0" + let name=specIdx value="multi ? i2 - 1 : i2" + let name=role value="isBackstopSeat ? fallback : (specIdx < specialists.length ? specialists[specIdx] : fallback)" + do value="out.set(engineId2, role)" + assign target="i2" op="+=" value="1" + return value="out" + + fn name=buildRoleInstructions params="role:ReviewRole" returns=string export=true + doc "Role-scoped replacement for the INSTRUCTIONS lead. Keeps the same word/severity/confidence discipline and points at the SAME mandatory machine block the generic prompt uses (the caller appends the shared block verbatim after this)." + handler lang="kern" + return value="`You are the ${role.title} reviewer on a multi-role review panel. Focus your review on: ${role.focus}\n\n${REVIEW_ROLE_OUTSIDE_TAIL}\n\nReport every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep the prose under 1200 words. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\n\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding 'blocking' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.`" + + fn name=runReviewCore params="diff:string, label:string, engineId:string, ctx:HandlerContext, signal?:AbortSignal, onProgress?:(chunk:string)=>void, cwdOverride?:string, roleId?:string" returns="Promise" async=true export=true + doc "Core review flow with no ctx side effects. Used by both handleReview (with streaming dispatch) and the plan executor's review step (silent). Does NOT touch ctx.setActiveAbort, ctx.lastReviewResult, ctx.chatSession, or tracker. signal is optional: callers that don't have an abort controller can pass undefined. cwdOverride pins the working directory the review engine runs in AND the repo file-context is gathered from — goal passes the per-task worktree so review engines never operate in (and write to) the parent repo; defaults to resolveWorkingDir() for the interactive/CLI review paths. roleId is optional: when it resolves to a known role the engine reviews through that focused lens (a ## ROLE block + role-scoped INSTRUCTIONS lead) over the SAME diff, grounding, and machine-block contract; when undefined/unknown the generic prompt is used unchanged." handler lang="kern" let name=cwd value="cwdOverride ?? resolveWorkingDir()" let name=config value="ctx.config" @@ -523,7 +634,10 @@ module name=ReviewEngineSelection if cond="fileContext" do value="parts.push(`## CURRENT FILE CONTENTS\\nFull current content of the changed source files, for grounding. Verify each finding against this real code — e.g. check whether an error is actually handled, a symbol actually unused, or an import actually missing — before flagging it. The DIFF below shows only what changed.\\n\\n${fileContext}`)" do value="parts.push(`## DIFF\\n\\`\\`\\`diff\\n${diff}\\n\\`\\`\\``)" - do value="parts.push(`## INSTRUCTIONS\\nProvide a thorough but concise code review: bugs and logic errors, security vulnerabilities, performance issues, code quality, and missing edge cases. Keep the prose under 1200 words. Report every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep each problem and fix concise. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\\n\\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding 'blocking' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.\\n\\n## REQUIRED MACHINE BLOCK\\nAfter your prose review you MUST append a machine-readable findings block. This is mandatory — a review without it is discarded. The block is the sentinel line, then a fenced JSON code block, as the very last thing in your response. Do NOT stop at the sentinel line: the JSON array after it is required.\\n\\n\\n\\`\\`\\`json\\n[{\"file\":\"src/auth.ts\",\"lines\":\"42\",\"severity\":\"important\",\"blocking\":false,\"confidence\":0.7,\"problem\":\"missing null check\",\"minimalFix\":\"guard before deref\"}]\\n\\`\\`\\`\\n\\nReplace the example with your real findings. If you found no issues, the array MUST be []. Emit the sentinel + JSON block exactly once, at the end.`)" + let name=role value="resolveReviewRole(roleId)" + if cond="role" + do value="parts.push(`## ROLE\\n${role.title}`)" + do value="parts.push(`## INSTRUCTIONS\\n${role ? buildRoleInstructions(role) : 'Provide a thorough but concise code review: bugs and logic errors, security vulnerabilities, performance issues, code quality, and missing edge cases. Keep the prose under 1200 words. Report every verified blocking finding, then at most the 8 highest-priority, best-verified non-blocking findings so the mandatory machine block cannot be crowded out. Keep each problem and fix concise. For each issue give file, line range, severity (blocking|important|nit), a 0.00-1.00 confidence, and a suggested fix.\\n\\nVERIFY before you flag: confirm each issue against the CURRENT FILE CONTENTS above — is the error really unhandled, the symbol really unused, the import really missing? Only mark a finding \\'blocking\\' if you confirmed it in the code. Set confidence honestly: 1.0 means you verified it in the code; lower it the more you are inferring or guessing. If you could not verify it from the provided context, lower the confidence and downgrade the severity rather than guessing — unverified high-confidence blocking findings are the #1 source of review noise.'}\\n\\n## REQUIRED MACHINE BLOCK\\nAfter your prose review you MUST append a machine-readable findings block. This is mandatory — a review without it is discarded. The block is the sentinel line, then a fenced JSON code block, as the very last thing in your response. Do NOT stop at the sentinel line: the JSON array after it is required.\\n\\n\\n\\`\\`\\`json\\n[{\"file\":\"src/auth.ts\",\"lines\":\"42\",\"severity\":\"important\",\"blocking\":false,\"confidence\":0.7,\"problem\":\"missing null check\",\"minimalFix\":\"guard before deref\"}]\\n\\`\\`\\`\\n\\nReplace the example with your real findings. If you found no issues, the array MUST be []. Emit the sentinel + JSON block exactly once, at the end.`)" let name=prompt value="parts.join('\\n\\n')" let name=engine value="ctx.registry.get(engineId)" let name=outputDir value="join(RUNS_DIR, `review-${hostNowMs()}`)" @@ -939,3 +1053,138 @@ module name=ReviewEngineSelection cleanup <<< ctx.setActiveAbort(null); >>> + + fn name=handleReviewRoles params="dispatch:Dispatch, ctx:HandlerContext, target?:string, requestedEngines?:string[], roleIds?:string[]" returns="Promise" async=true export=true + doc "Run /review role — the same parallel multi-engine review as handleReviewMany, but each engine reviews through a focused ROLE lens (security / correctness / dryness / performance) plus an 'overall' generalist backstop, so coverage is never partitioned away. Roles come from assignReviewRoles: an explicit roleIds list zips engine i → roleIds[i]; otherwise the fixed roster is assigned in order and extra engines fall back to 'overall'. The diff, grounding, sentinel JSON machine block, consensus merge, and results pager are identical to a normal review — roles only narrow each engine's ATTENTION via an extra ## ROLE block + role-scoped INSTRUCTIONS lead." + signal name=abort + handler <<< + ensureAgonHome(); + const cwd = resolveWorkingDir(); + let engineIds: string[]; + try { + engineIds = selectReviewEngines(requestedEngines, ctx); + } catch (err) { + dispatch({ type: 'error', message: err instanceof Error ? err.message : String(err) }); + return; + } + + // Resolve the diff once — every role reviews the same target. + let diff: string; + let label: string; + try { + ({ diff, label } = resolveReviewTarget(target, cwd, undefined)); + } catch (err) { + dispatch({ type: 'error', message: err instanceof Error ? err.message : String(err) }); + return; + } + announceReviewTarget(dispatch, cwd, label); + if (!diff.trim()) { + dispatch({ type: 'info', message: `No changes to review (${label}).` }); + return; + } + + const roleByEngine = assignReviewRoles(engineIds, roleIds); + dispatch({ type: 'info', message: `Roles: ${engineIds.map((id) => `${id}=${roleByEngine.get(id)?.id ?? 'overall'}`).join(' · ')}` }); + + const config = ctx.config as any; + const timeoutSec = config.reviewTimeout ?? config.agentTimeout ?? 420; + interface Collected { engineId: string; reviewOutput: string; unstructured: boolean; status: string; note?: string } + const controllers: AbortController[] = []; + const onMasterAbort = () => { for (const c of controllers) c.abort(); }; + ctx.setActiveAbort(abort); + if (abort.signal.aborted) onMasterAbort(); + else abort.signal.addEventListener('abort', onMasterAbort, { once: true }); + + const reviewOne = async (engineId: string): Promise => { + const controller = new AbortController(); + controllers.push(controller); + let timedOut = false; + let timer: ReturnType | undefined; + const role = roleByEngine.get(engineId); + try { + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + resolve(null); + }, timeoutSec * 1000); + }); + const corePromise = runReviewCore(diff, label, engineId, ctx, controller.signal, undefined, undefined, role?.id); + corePromise.catch(() => undefined); + const result = await Promise.race([corePromise, timeoutPromise]); + if (result === null || timedOut) { + dispatch({ type: 'warning', message: `${engineId}: timed out after ${timeoutSec}s — skipped.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'timeout' }; + } + const response = (result.response ?? '').trim(); + if (!response) { + dispatch({ type: 'warning', message: `${engineId} returned no review output.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'error', note: 'no output' }; + } + const status = result.unstructured ? 'unstructured' : 'ok'; + const roleTag = role ? ` [${role.id}]` : ''; + dispatch({ type: 'info', message: result.unstructured + ? `${icons().success} ${engineId}${roleTag}: unstructured (no machine verdict)` + : `${icons().success} ${engineId}${roleTag}: ${formatReviewCounts(result.severityCounts)}` }); + appendMessage(ctx.chatSession, { role: 'engine', engineId, content: response, timestamp: new Date().toISOString() }); + tracker.record(engineId, { prompt: `[review${roleTag} ${label}]`, response }); + return { engineId, reviewOutput: response, unstructured: result.unstructured, status }; + } catch (err) { + if (timedOut) { + dispatch({ type: 'warning', message: `${engineId}: timed out after ${timeoutSec}s — skipped.` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'timeout' }; + } + const msg = err instanceof Error ? err.message : String(err); + dispatch({ type: 'error', message: `${engineId}: ${msg}` }); + return { engineId, reviewOutput: '', unstructured: false, status: 'error', note: msg }; + } finally { + if (timer) clearTimeout(timer); + } + }; + + appendMessage(ctx.chatSession, { role: 'user', content: `[review role ${label}]`, timestamp: new Date().toISOString() }); + const all = await Promise.all(engineIds.map((id) => reviewOne(id))); + const collected = all.filter((c) => c.reviewOutput); + + if (collected.length === 0) { + dispatch({ type: 'warning', message: `No review output returned from ${engineIds.join(', ')}.` }); + ctx.setActiveAbort(null); + return; + } + + const outcomes = all.map((c) => reviewOutcome(c.engineId, c.reviewOutput, c.status, c.note)); + const consensus = buildConsensus(outcomes as any); + const consensusSummary = buildReviewConsensusLines(consensus).join('\n'); + if (consensus.degraded) dispatch({ type: 'warning', message: consensus.degraded.warning }); + dispatch({ type: consensus.autoBlock ? 'warning' : 'info', message: consensusSummary }); + + const anyUnstructured = collected.some((c) => c.unstructured); + ctx.lastReviewResult = { + engineId: collected.map((r) => r.engineId).join(', '), + target: target ?? 'uncommitted', + label: `${label} (role review)`, + diff, + reviewOutput: collected.map((r) => `## ${r.engineId} [${roleByEngine.get(r.engineId)?.id ?? 'overall'}]\n\n${r.reviewOutput}`).join('\n\n---\n\n'), + timestamp: Date.now(), + }; + + sessionResultStore.add({ + type: 'review', + timestamp: new Date().toISOString(), + question: `${label} (role review)`, + engines: collected.map((r) => r.engineId), + winner: null, + data: { + label: `${label} (role review)`, + consensusSummary, + blocking: consensus.autoBlock, + reviews: collected.map((r) => ({ engineId: `${r.engineId} [${roleByEngine.get(r.engineId)?.id ?? 'overall'}]`, status: r.status, reviewOutput: stripMachineBlock(r.reviewOutput) })), + }, + }); + + dispatch({ type: 'info', message: `Role review complete (${collected.map((r) => `${r.engineId}=${roleByEngine.get(r.engineId)?.id ?? 'overall'}`).join(', ')}).${anyUnstructured ? ' Some reviews were unstructured (no machine verdict) but valid.' : ''} Ctrl+R for the full reviews · say "fix it" or "fix it with " to address the findings.` }); + >>> + cleanup <<< + ctx.setActiveAbort(null); + >>> + diff --git a/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern b/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern index 3ac7ebd09..16d742a18 100644 --- a/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern +++ b/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern @@ -3,7 +3,7 @@ import from="node:path" names="join" import from="@kernlang/agon-core" names="resolveWorkingDir,spawnWithTimeout" import from="../../../handlers/types.js" names="Dispatch" types=true import from="../../blocks/output-format.js" names="ENGINE_COLORS" -import from="../../../handlers/index.js" names="handleForge,handleBrainstorm,handleCampfire,handleTribunal,handleThink,handleCouncil,handleSynthesis,handleNeroChallenge,handleResearch,handleChrome,handleConquer,handleBuild,handleReviewMany,runAgentMode,runAgentTeam" +import from="../../../handlers/index.js" names="handleForge,handleBrainstorm,handleCampfire,handleTribunal,handleThink,handleCouncil,handleSynthesis,handleNeroChallenge,handleResearch,handleChrome,handleConquer,handleBuild,handleReviewMany,handleReviewRoles,runAgentMode,runAgentTeam" import from="../../handlers/team-tribunal.js" names="handleTeamTribunal" import from="../../handlers/team-forge.js" names="handleTeamForge" import from="../../handlers/team-brainstorm.js" names="handleTeamBrainstorm" diff --git a/packages/cli/src/kern/signals/intent.kern b/packages/cli/src/kern/signals/intent.kern index 6e59f3918..6afddd63a 100644 --- a/packages/cli/src/kern/signals/intent.kern +++ b/packages/cli/src/kern/signals/intent.kern @@ -48,8 +48,9 @@ module name=IntentParsing field name=reasoning type="string|undefined" field name=count type="number|undefined" field name=last type="boolean|undefined" + field name=roles type="string[]|undefined" - const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGENTS.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} + const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/review role', desc: '[security|correctness|dryness|performance] [] — multi-role review: each engine a focused lens + overall backstop' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGENTS.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} const name=FITNESS_PATTERN type=RegExp value={{ /\b(?:test with|test:|--test|fitness:)\s+(.+)/i }} @@ -170,7 +171,25 @@ module name=IntentParsing let target: string | undefined; let collectingEngines = false; - for (let i = 0; i < reviewParts.length; i += 1) { + // `/review role …` — a leading `role`/`roles` keyword switches to the focused + // multi-role review. Any following bare words that match a known role id are + // collected as the explicit role roster (engine i → role i); the rest parse + // exactly like a normal /review (target + engines). With no role names, the + // handler assigns the fixed roster automatically. + let roleMode = false; + const roleIds: string[] = []; + const KNOWN_ROLES = new Set(['security', 'correctness', 'dryness', 'performance', 'overall']); + let startIdx = 0; + if (reviewParts.length > 0 && /^(role|roles)$/i.test(reviewParts[0])) { + roleMode = true; + startIdx = 1; + while (startIdx < reviewParts.length && KNOWN_ROLES.has(reviewParts[startIdx].toLowerCase())) { + roleIds.push(reviewParts[startIdx].toLowerCase()); + startIdx += 1; + } + } + + for (let i = startIdx; i < reviewParts.length; i += 1) { const part = reviewParts[i]; const lower = part.toLowerCase(); if (lower === 'and' || lower === 'or' || lower === 'plus') { @@ -195,6 +214,9 @@ module name=IntentParsing } const engineId = engineIds[0]; + if (roleMode) { + return { type: 'review-role', engineId, engineIds: engineIds.length > 0 ? engineIds : undefined, target, roles: roleIds.length > 0 ? roleIds : undefined } as Intent; + } return { type: 'review', engineId, engineIds: engineIds.length > 0 ? engineIds : undefined, target } as Intent; >>> diff --git a/packages/cli/src/kern/surfaces/app.kern b/packages/cli/src/kern/surfaces/app.kern index 85cb2af01..68e79d51b 100644 --- a/packages/cli/src/kern/surfaces/app.kern +++ b/packages/cli/src/kern/surfaces/app.kern @@ -1594,16 +1594,9 @@ screen name=App target=ink () => nativeLiveRows.map((row: any) => ), [nativeLiveRows], ); - // Whenever the bottom-chrome PlanChip is showing it already carries the - // plan glance (Step N/M · bar · % · current step), so the inline TodoList - // suppresses the duplicate plan-step rows (they live in the Ctrl+G rail). - // Keyed on planChipVisible — the SAME predicate that drives the chip — so - // the two surfaces stay in lockstep across every plan state (incl. the - // post-done retain window). Live (non-plan) todos always render. const lowerPanel = ( - {startupUseDashboardView && (displayRows.length === 0 || terminalMode === 'native') && ( @@ -1838,6 +1831,15 @@ screen name=App target=ink executionRailOpen={executionRailOpen} /> )} {liveSpinner && mode !== 'chat' && } + {/* Pinned todo list — docked at the END of the dynamic region so it sits + directly above the bottom chrome (plan chip + composer) instead of + floating above the live stream. When the bottom-chrome PlanChip is + showing it already carries the plan glance (Step N/M · bar · % · + current step), so TodoList suppresses duplicate plan-step rows. + Keyed on planChipVisible — the SAME predicate that drives the chip — + so the two surfaces stay in lockstep across every plan state (incl. + the post-done retain window). Live (non-plan) todos always render. */} + {!enginePickerOpen && !modelPickerOpen && !cesarPickerOpen && !railTakeover && ( |null { let cleaned = raw.trim(); @@ -121,7 +124,7 @@ export function repairToolArgs(raw: string): Record|null { /** * Auto-correct tool name case mismatches. Maps 'read' → 'Read', 'GREP' → 'Grep', etc. */ -// @kern-source: agent-loop:96 +// @kern-source: agent-loop:99 export function repairToolName(name: string, registry?: any): string { // Prefer the registry's canonical spelling when one is available. ToolRegistry.get // already resolves case-insensitively, so custom registered tools stay authoritative. @@ -144,7 +147,7 @@ export function repairToolName(name: string, registry?: any): string { /** * True when an API dispatch failure looks transient (worth a backoff+retry) rather than permanent. Transient: request timeout (exitCode 124), rate limit (429), upstream 5xx, stream errors, connection resets / DNS hiccups, overloaded. Permanent (never retried): missing/invalid API key, 401/403 auth, 400 bad request. Aborts (exitCode 130 / signal) are handled by the caller, not here. */ -// @kern-source: agent-loop:117 +// @kern-source: agent-loop:120 export function isTransientDispatchFailure(stderr: string, exitCode?: number): boolean { const s = String(stderr ?? '').toLowerCase(); if (exitCode === 124) return true; // request timed out @@ -156,7 +159,7 @@ export function isTransientDispatchFailure(stderr: string, exitCode?: number): b /** * Run an API engine with full tool loop. Returns final response after all tool calls resolve. */ -// @kern-source: agent-loop:127 +// @kern-source: agent-loop:130 export async function runApiAgentLoop(opts: ApiAgentOptions): Promise { // Run-scoped cache ID: prevents concurrent forge runs from colliding const runCacheId = `${opts.api.model || 'api-agent'}-${randomUUID().slice(0, 8)}`; @@ -245,6 +248,15 @@ export async function runApiAgentLoop(opts: ApiAgentOptions): Promise { + toolOutcomes.push({ tool, status, durationMs, provenance }); + }; let finalResponse = ''; // Last visible assistant narration seen across steps. finalResponse is only // set on the terminal no-tool-call answer, so on the silent return paths @@ -278,7 +290,7 @@ export async function runApiAgentLoop(opts: ApiAgentOptions): Promise= totalDeadline, errorReason: reason }; + ? { response: `Error: ${reason}`, toolCalls: totalToolCalls, steps: step, toolOutcomes, cancelled: true, errorReason: reason } + : { response: `Error: ${reason}`, toolCalls: totalToolCalls, steps: step, toolOutcomes, failed: true, engineFault: true, timedOut: lastTransientTimedOut || Date.now() >= totalDeadline, errorReason: reason }; } console.warn(`[agon] api-agent-loop: transient failure (${transientReason}); reconnecting attempt ${dispatchAttempt}/${maxDispatchRetries} in ${backoffMs}ms`); await new Promise((r) => setTimeout(r, backoffMs)); if (opts.signal?.aborted) { - return { response: 'Error: aborted during reconnect', toolCalls: totalToolCalls, steps: step, cancelled: true, errorReason: 'aborted during reconnect' }; + return { response: 'Error: aborted during reconnect', toolCalls: totalToolCalls, steps: step, toolOutcomes, cancelled: true, errorReason: 'aborted during reconnect' }; } } @@ -385,6 +397,7 @@ export async function runApiAgentLoop(opts: ApiAgentOptions): Promise