From e8e26bc30c6c385f3e518e56056b0fb7207d9331 Mon Sep 17 00:00:00 2001 From: cukas Date: Sat, 18 Jul 2026 15:57:07 +0200 Subject: [PATCH 01/15] =?UTF-8?q?feat(review):=20add=20/review=20role=20?= =?UTF-8?q?=E2=80=94=20per-engine=20focused=20lenses=20over=20the=20shared?= =?UTF-8?q?=20review=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each engine reviews the same diff through a role lens (security/correctness/ dryness/performance) with an always-seated overall generalist backstop, over the identical grounding + sentinel-JSON machine block, so consensus and the results pager work unchanged. Master-abort fan-out wired so Esc cancels the whole panel. Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/handlers/review.ts | 236 +++++++++++++++++- .../signals/dispatch/intent-orchestration.ts | 2 +- packages/cli/src/generated/signals/intent.ts | 86 ++++--- packages/cli/src/handlers/index.ts | 2 +- packages/cli/src/handlers/review.ts | 2 +- packages/cli/src/kern/handlers/review.kern | 205 ++++++++++++++- .../dispatch/intent-orchestration.kern | 2 +- packages/cli/src/kern/signals/intent.kern | 26 +- tests/unit/review-roles.test.ts | 101 ++++++++ 9 files changed, 610 insertions(+), 52 deletions(-) create mode 100644 tests/unit/review-roles.test.ts diff --git a/packages/cli/src/generated/handlers/review.ts b/packages/cli/src/generated/handlers/review.ts index 48e979ed4..40aca22d1 100644 --- a/packages/cli/src/generated/handlers/review.ts +++ b/packages/cli/src/generated/handlers/review.ts @@ -549,11 +549,84 @@ export function gatherReviewFileContext(diff: string, cwd: string): string { return sections.length ? sections.join('\n\n') : ''; } +// @kern-source: review:516 +export interface ReviewRole { + id: string; + title: string; + focus: string; +} + +// @kern-source: review:521 +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:529 +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."; + +/** + * 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:531 +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; +} + /** - * 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. + * 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: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:542 +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:566 +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:571 +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 +642,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 +741,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:664 export function stripMachineBlock(response: string): string { const idx = response.lastIndexOf(REVIEW_SENTINEL); if (idx < 0) return response; @@ -674,7 +751,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:672 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 +770,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:689 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 +784,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:701 export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { if (!c || c.total === 0) return 'no findings'; const parts: string[] = []; @@ -717,7 +794,7 @@ export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { return parts.join(', '); } -// @kern-source: review:648 +// @kern-source: review:712 export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngine?: string): Promise { const abort = new AbortController(); try { @@ -846,7 +923,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:837 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 +942,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:854 export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngines?: string[]): Promise { const abort = new AbortController(); try { @@ -1017,3 +1094,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:1007 +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/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/handlers/review.kern b/packages/cli/src/kern/handlers/review.kern index 25713627c..81511061a 100644 --- a/packages/cli/src/kern/handlers/review.kern +++ b/packages/cli/src/kern/handlers/review.kern @@ -507,8 +507,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 +584,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 +1003,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/tests/unit/review-roles.test.ts b/tests/unit/review-roles.test.ts new file mode 100644 index 000000000..50b7cfdf3 --- /dev/null +++ b/tests/unit/review-roles.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + REVIEW_ROLES, + assignReviewRoles, + buildRoleInstructions, + resolveReviewRole, +} from '../../packages/cli/src/generated/handlers/review.js'; + +describe('resolveReviewRole', () => { + it('resolves a known role case-insensitively', () => { + expect(resolveReviewRole('security')?.id).toBe('security'); + expect(resolveReviewRole('SECURITY')?.id).toBe('security'); + expect(resolveReviewRole(' Dryness ')?.id).toBe('dryness'); + }); + + it('returns undefined for none or unknown ids', () => { + expect(resolveReviewRole(undefined)).toBeUndefined(); + expect(resolveReviewRole('')).toBeUndefined(); + expect(resolveReviewRole('not-a-role')).toBeUndefined(); + }); + + it('covers the fixed roster ids', () => { + expect(REVIEW_ROLES.map((r) => r.id)).toEqual([ + 'security', + 'correctness', + 'dryness', + 'performance', + 'overall', + ]); + }); +}); + +describe('assignReviewRoles', () => { + it('seats the overall backstop first on a full panel, then deals specialists', () => { + const map = assignReviewRoles(['a', 'b', 'c', 'd', 'e', 'f', 'g'], undefined); + expect(map.get('a')?.id).toBe('overall'); // backstop first + expect(map.get('b')?.id).toBe('security'); + expect(map.get('c')?.id).toBe('correctness'); + expect(map.get('d')?.id).toBe('dryness'); + expect(map.get('e')?.id).toBe('performance'); + // Coverage is never partitioned away: engines past the roster are generalists. + expect(map.get('f')?.id).toBe('overall'); + expect(map.get('g')?.id).toBe('overall'); + }); + + it('always guarantees an overall backstop on small multi-engine panels', () => { + // 2 engines: backstop + one specialist — nobody is left without a catch-all. + const two = assignReviewRoles(['a', 'b'], undefined); + expect(two.get('a')?.id).toBe('overall'); + expect(two.get('b')?.id).toBe('security'); + + const three = assignReviewRoles(['a', 'b', 'c'], undefined); + expect(three.get('a')?.id).toBe('overall'); + expect(three.get('b')?.id).toBe('security'); + expect(three.get('c')?.id).toBe('correctness'); + + const four = assignReviewRoles(['a', 'b', 'c', 'd'], undefined); + expect([...four.values()].some((r) => r.id === 'overall')).toBe(true); + }); + + it('gives a single engine the deepest lens (it IS the whole panel)', () => { + const map = assignReviewRoles(['solo'], undefined); + expect(map.get('solo')?.id).toBe('security'); + }); + + it('zips an explicit role list engine i → role i', () => { + const map = assignReviewRoles(['a', 'b'], ['correctness', 'security']); + expect(map.get('a')?.id).toBe('correctness'); + expect(map.get('b')?.id).toBe('security'); + }); + + it('cycles an explicit role list shorter than the engine list', () => { + const map = assignReviewRoles(['a', 'b', 'c'], ['security']); + expect(map.get('a')?.id).toBe('security'); + expect(map.get('b')?.id).toBe('security'); + expect(map.get('c')?.id).toBe('security'); + }); + + it('falls back to overall for unknown explicit role ids', () => { + const map = assignReviewRoles(['a'], ['bogus']); + expect(map.get('a')?.id).toBe('overall'); + }); +}); + +describe('buildRoleInstructions', () => { + it('names the role and keeps the outside-role safety tail', () => { + const role = resolveReviewRole('security'); + expect(role).toBeDefined(); + const text = buildRoleInstructions(role!); + expect(text).toContain('Security reviewer'); + expect(text.toLowerCase()).toContain('outside your role'); + }); + + it('preserves the shared verify/confidence discipline', () => { + const role = resolveReviewRole('correctness'); + const text = buildRoleInstructions(role!); + expect(text).toContain('VERIFY before you flag'); + expect(text).toContain('severity (blocking|important|nit)'); + }); +}); From 14194157a68f5d3eed83838963216143157c395c Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:48:25 +0200 Subject: [PATCH 02/15] feat(review): expose role-lens review on the CLI + call bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review role was REPL-only — external agents (Claude Code, Codex, CI) had no path to it (mode-checklist surface gap). Adds: - agon review --roles auto|: roles assigned AFTER risk routing / explicit selection, zipped per engine, overall backstop for extras; role tag shown per reviewer, roleId passed through runReviewCore - agon call review --roles passthrough (shared roles option with council) - README: role-lens review section with REPL + CLI + bridge usage - tests: call bridge forwards --roles ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- README.md | 14 +++++++++++ packages/cli/src/commands/review.ts | 26 +++++++++++++++++++-- packages/cli/src/generated/commands/call.ts | 11 +++++---- packages/cli/src/kern/commands/call.kern | 5 +++- tests/unit/call-command.test.ts | 18 ++++++++++++++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e9d090869..e581b68ab 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,20 @@ Diff scope is deliberate, never implicit: `--base ` pins the base for `unco 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. 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/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/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/tests/unit/call-command.test.ts b/tests/unit/call-command.test.ts index 7df0d1dff..24dd4781b 100644 --- a/tests/unit/call-command.test.ts +++ b/tests/unit/call-command.test.ts @@ -322,6 +322,24 @@ describe('agon call command mapping', () => { ]); }); + it('forwards --roles to review (role-lens bridge for external CLIs)', () => { + expect(buildCallCommands({ + workflow: 'review', + input: 'uncommitted', + roles: 'auto', + }).commands).toEqual([ + ['review', 'uncommitted', '--roles', 'auto'], + ]); + expect(buildCallCommands({ + workflow: 'review', + input: 'branch:main', + roles: 'security,correctness', + engines: 'claude,codex', + }).commands).toEqual([ + ['review', 'branch:main', '--roles', 'security,correctness', '--engines', 'claude,codex'], + ]); + }); + it('forwards --engine to review as an explicit single-reviewer request', () => { expect(buildCallCommands({ workflow: 'review', From e858f78a73753a768ed31c6f0b3a55d0c405c2b2 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:21:13 +0200 Subject: [PATCH 03/15] fix(cli): dock TodoList at the end of the dynamic region, above the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned todo panel rendered directly under ChromeBar with the whole live stream below it, so it floated mid-transcript ('free flight'). Moved to just above BottomChromeSection so Todos N/M pins to the bottom chrome where the plan chip and composer live. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../src/generated/blocks/todo-list.entry.tsx | 4 ++-- .../cli/src/generated/blocks/todo-list.tsx | 6 +++--- packages/cli/src/generated/surfaces/app.tsx | 18 ++++++++++-------- packages/cli/src/kern/blocks/todo-list.kern | 6 ++++-- packages/cli/src/kern/surfaces/app.kern | 16 +++++++++------- 5 files changed, 28 insertions(+), 22 deletions(-) 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/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/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/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 && ( Date: Sat, 18 Jul 2026 17:21:14 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(cesar):=20self-verification=20tools?= =?UTF-8?q?=20=E2=80=94=20EngineReliability,=20RenderProbe,=20TuiProbe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Cesar's 'I edit my own UI blind' and 'I can't query engine reliability mid-turn' gaps (spec'd from Kimi-as-Cesar feedback, nero-challenged, 6-engine reviewed): - EngineReliability: read-only digest, two labeled sections (own-turn summarizers vs delegated dispatch ledger), never blended - RenderProbe: in-process Ink frame capture of fixture surfaces with true FINAL-frame semantics (last non-empty debug write, not the accumulated transcript) - TuiProbe: PTY-launches a throwaway agon (isolated AGON_HOME + cwd, empty roster, single-line safelisted input — control chars rejected, review finding) via py/agon-tui-probe.py; pyte screen-state grid, not ANSI-stripped bytes. Ships in the package (py/ in files; path resolution handles tsup's flat dist chunks — review finding) ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/package.json | 1 + packages/cli/py/agon-tui-probe.py | 553 ++++++++++++++++++ .../cli/src/generated/blocks/frame-capture.ts | 96 +++ .../cesar/tool-engine-reliability.ts | 72 +++ .../src/generated/cesar/tool-render-probe.ts | 127 ++++ .../cli/src/generated/cesar/tool-tui-probe.ts | 121 ++++ packages/cli/src/generated/cesar/tools.ts | 17 +- .../cli/src/kern/blocks/frame-capture.kern | 96 +++ .../kern/cesar/tool-engine-reliability.kern | 77 +++ .../cli/src/kern/cesar/tool-render-probe.kern | 124 ++++ .../cli/src/kern/cesar/tool-tui-probe.kern | 119 ++++ packages/cli/src/kern/cesar/tools.kern | 6 + tests/unit/engine-reliability-tool.test.ts | 115 ++++ tests/unit/render-probe.test.ts | 81 +++ tests/unit/terminal-frame.test.ts | 24 +- tests/unit/tui-probe-tool.test.ts | 61 ++ 16 files changed, 1663 insertions(+), 27 deletions(-) create mode 100644 packages/cli/py/agon-tui-probe.py create mode 100644 packages/cli/src/generated/blocks/frame-capture.ts create mode 100644 packages/cli/src/generated/cesar/tool-engine-reliability.ts create mode 100644 packages/cli/src/generated/cesar/tool-render-probe.ts create mode 100644 packages/cli/src/generated/cesar/tool-tui-probe.ts create mode 100644 packages/cli/src/kern/blocks/frame-capture.kern create mode 100644 packages/cli/src/kern/cesar/tool-engine-reliability.kern create mode 100644 packages/cli/src/kern/cesar/tool-render-probe.kern create mode 100644 packages/cli/src/kern/cesar/tool-tui-probe.kern create mode 100644 tests/unit/engine-reliability-tool.test.ts create mode 100644 tests/unit/render-probe.test.ts create mode 100644 tests/unit/tui-probe-tool.test.ts 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..46077edbe --- /dev/null +++ b/packages/cli/py/agon-tui-probe.py @@ -0,0 +1,553 @@ +#!/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: + pass + 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: + pass + # Best-effort cleanup of the throwaway dirs. + for d in (agon_home, child_cwd): + try: + shutil.rmtree(d, ignore_errors=True) + except Exception: + pass + + +# ── 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/generated/blocks/frame-capture.ts b/packages/cli/src/generated/blocks/frame-capture.ts new file mode 100644 index 000000000..d27107ba9 --- /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:34 +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:71 +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/cesar/tool-engine-reliability.ts b/packages/cli/src/generated/cesar/tool-engine-reliability.ts new file mode 100644 index 000000000..9657c6907 --- /dev/null +++ b/packages/cli/src/generated/cesar/tool-engine-reliability.ts @@ -0,0 +1,72 @@ +// @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'; + +/** + * 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:18 +export function createEngineReliabilityTool(): ToolHandler { + const definition: ToolDefinition = { + 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, + }; + + const validate = (_input: Record, _ctx: ToolContext): string | null => null; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + const engineId = typeof input.engineId === 'string' && input.engineId.trim() ? input.engineId.trim() : undefined; + + 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)); + } + } + + // 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 }; + }; + + return { definition, validate, checkPermission, execute }; +} 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..99e906e59 --- /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: [], + }, + }, + }; +} + +/** + * Factory for the RenderProbe tool — renders a known Ink surface fixture and returns its final text frame. + */ +// @kern-source: tool-render-probe:63 +export function createRenderProbeTool(): ToolHandler { + const fixtures = renderProbeFixtures(); + const validIds = Object.keys(fixtures); + + const definition: ToolDefinition = { + 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, + }; + + const validate = (input: Record, _ctx: ToolContext): string | null => + typeof input.surface === 'string' && input.surface.trim() ? null : 'Missing required parameter: surface'; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + 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.floor(input.cols), 400) : 100; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(Math.floor(input.rows), 200) : 30; + const overrides = (input.props && typeof input.props === 'object' && !Array.isArray(input.props)) + ? input.props as Record + : {}; + const props = { ...fixture.defaults, ...overrides }; + try { + const frame = await captureSurfaceFrame(fixture.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)}`, + }; + } + }; + + return { definition, validate, checkPermission, execute }; +} 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..f43f687d6 --- /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)); + const roots = [ + join(here, '..'), // dist/.js → pkg root (PACKAGED layout — tsup emits flat chunks directly under dist/; agon-review blocking finding) + join(here, '..', '..', '..'), // src/generated/cesar → pkg root (vitest/dev layout) + join(here, '..', '..'), + join(here, '..', '..', '..', '..'), + ]; + let script: string | null = null; + let agonBin: string | null = 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, 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:39 +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.floor(input.cols), 400) : 120; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(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/kern/blocks/frame-capture.kern b/packages/cli/src/kern/blocks/frame-capture.kern new file mode 100644 index 000000000..daa92e1d9 --- /dev/null +++ b/packages/cli/src/kern/blocks/frame-capture.kern @@ -0,0 +1,96 @@ +// ── 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, ''); + >>> + +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/cesar/tool-engine-reliability.kern b/packages/cli/src/kern/cesar/tool-engine-reliability.kern new file mode 100644 index 000000000..e62b6c43b --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-engine-reliability.kern @@ -0,0 +1,77 @@ +// ── 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=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 <<< + const definition: ToolDefinition = { + 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, + }; + + const validate = (_input: Record, _ctx: ToolContext): string | null => null; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + const engineId = typeof input.engineId === 'string' && input.engineId.trim() ? input.engineId.trim() : undefined; + + 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)); + } + } + + // 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 }; + }; + + return { definition, validate, checkPermission, execute }; + >>> 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..effc6f46c --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-render-probe.kern @@ -0,0 +1,124 @@ +// ── 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=createRenderProbeTool returns=ToolHandler export=true + doc "Factory for the RenderProbe tool — renders a known Ink surface fixture and returns its final text frame." + handler <<< + const fixtures = renderProbeFixtures(); + const validIds = Object.keys(fixtures); + + const definition: ToolDefinition = { + 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, + }; + + const validate = (input: Record, _ctx: ToolContext): string | null => + typeof input.surface === 'string' && input.surface.trim() ? null : 'Missing required parameter: surface'; + + const checkPermission = (_input: Record, _ctx: ToolContext): PermissionDecision => ({ behavior: 'allow' }); + + const execute = async (input: Record, _ctx: ToolContext): Promise => { + 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.floor(input.cols), 400) : 100; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(Math.floor(input.rows), 200) : 30; + const overrides = (input.props && typeof input.props === 'object' && !Array.isArray(input.props)) + ? input.props as Record + : {}; + const props = { ...fixture.defaults, ...overrides }; + try { + const frame = await captureSurfaceFrame(fixture.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)}`, + }; + } + }; + + return { definition, validate, checkPermission, execute }; + >>> 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..a5823cbdd --- /dev/null +++ b/packages/cli/src/kern/cesar/tool-tui-probe.kern @@ -0,0 +1,119 @@ +// ── 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 <<< + const here = dirname(fileURLToPath(import.meta.url)); + const roots = [ + join(here, '..'), // dist/.js → pkg root (PACKAGED layout — tsup emits flat chunks directly under dist/; agon-review blocking finding) + join(here, '..', '..', '..'), // src/generated/cesar → pkg root (vitest/dev layout) + join(here, '..', '..'), + join(here, '..', '..', '..', '..'), + ]; + let script: string | null = null; + let agonBin: string | null = 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, 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.floor(input.cols), 400) : 120; + const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.min(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/tests/unit/engine-reliability-tool.test.ts b/tests/unit/engine-reliability-tool.test.ts new file mode 100644 index 000000000..347b27f3a --- /dev/null +++ b/tests/unit/engine-reliability-tool.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createEngineReliabilityTool } from '../../packages/cli/src/generated/cesar/tool-engine-reliability.js'; +import { recordApiLoopDispatch, recordTextTransportDispatch } from '../../packages/core/src/generated/signals/delegate-ledger.js'; +import { setupTestAgonHome, cleanupTestAgonHome } from '../helpers/agon-home.js'; + +const ctx = { cwd: process.cwd(), readFileState: new Map() } as any; + +describe('EngineReliability tool', () => { + it('exposes a read-only, concurrency-safe definition', () => { + const tool = createEngineReliabilityTool(); + expect(tool.definition.name).toBe('EngineReliability'); + expect(tool.definition.isReadOnly).toBe(true); + expect(tool.definition.isConcurrencySafe).toBe(true); + expect(tool.checkPermission({}, ctx).behavior).toBe('allow'); + }); + + it('reports "calibrating" for an engine with zero records instead of erroring', async () => { + const tool = createEngineReliabilityTool(); + // A bogus engine id can never have logged turns, so this is deterministic + // regardless of whatever real telemetry exists in ~/.agon/runs. + const result = await tool.execute({ engineId: 'no-such-engine-zzz-9999' }, ctx); + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('calibrating'); + }); + + it('renders both explicitly labeled sections', async () => { + const tool = createEngineReliabilityTool(); + const result = await tool.execute({ engineId: 'no-such-engine-zzz-9999' }, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('CESAR OWN-TURN RELIABILITY'); + expect(result.content).toContain('DELEGATED DISPATCH LEDGER'); + // An engine with no ledger records renders the honest empty line, not an error. + expect(result.content).toContain('no ledger records yet'); + }); + + it('summary scope (no engineId) still returns both sections without throwing', async () => { + const tool = createEngineReliabilityTool(); + const result = await tool.execute({}, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('CESAR OWN-TURN RELIABILITY'); + expect(result.content).toContain('DELEGATED DISPATCH LEDGER'); + }); +}); + +describe('EngineReliability tool — real delegate ledger section (2b)', () => { + let home: string; + beforeEach(() => { home = setupTestAgonHome('engine-reliability-ledger'); }); + afterEach(() => { cleanupTestAgonHome(home); }); + + it('surfaces per-backend ledger stats, keeping api-loop and cli-print separate', async () => { + recordApiLoopDispatch('eng-led', 'agent', [ + { tool: 'Read', status: 'ok', durationMs: 1, provenance: 'native' }, + { tool: 'Bash', status: 'error', durationMs: 1, provenance: 'native' }, + ]); + recordTextTransportDispatch('eng-led', 'exec', 'cli-print'); + + const tool = createEngineReliabilityTool(); + const result = await tool.execute({ engineId: 'eng-led' }, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('DELEGATED DISPATCH LEDGER'); + // api-loop line carries native per-call reliability... + expect(result.content).toContain('[api-loop]'); + expect(result.content).toContain('1 ok, 1 failed'); + expect(result.content).toContain('(native)'); + // ...cli-print line is honest about having no per-call visibility. + expect(result.content).toContain('[cli-print]'); + expect(result.content).toContain('no per-call visibility on this transport'); + }); + + it('an engine with no ledger records shows the empty line, not an error', async () => { + const tool = createEngineReliabilityTool(); + const result = await tool.execute({ engineId: 'never-delegated-to' }, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('no ledger records yet'); + }); + + it('the all-engine view (no engineId) keeps two engines separate, never merged by backend', async () => { + // Finding 1: two engines on the SAME backend must render as two distinct + // lines, not one merged api-loop bucket. + recordApiLoopDispatch('eng-alpha', 'agent', [ + { tool: 'Read', status: 'ok', durationMs: 1, provenance: 'native' }, + { tool: 'Grep', status: 'ok', durationMs: 1, provenance: 'native' }, + ]); + recordApiLoopDispatch('eng-beta', 'agent', [ + { tool: 'Bash', status: 'error', durationMs: 1, provenance: 'native' }, + ]); + + const tool = createEngineReliabilityTool(); + const result = await tool.execute({}, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('eng-alpha [api-loop]'); + expect(result.content).toContain('eng-beta [api-loop]'); + // eng-alpha's 2 ok are not blended with eng-beta's failure. + expect(result.content).toContain('eng-alpha [api-loop]: 1 dispatch, 2 tool calls — 2 ok, 0 failed'); + expect(result.content).toContain('eng-beta [api-loop]: 1 dispatch, 1 tool calls — 0 ok, 1 failed'); + }); + + it('renders narrated stalls (heuristic) separately from native tool calls', async () => { + // Finding 2: a real call plus a narrated stall in one dispatch. + recordApiLoopDispatch('eng-stall', 'agent', [ + { tool: 'Read', status: 'ok', durationMs: 1, provenance: 'native' }, + { tool: 'narrated-stall', status: 'unknown', durationMs: 0, provenance: 'heuristic' }, + ]); + + const tool = createEngineReliabilityTool(); + const result = await tool.execute({ engineId: 'eng-stall' }, ctx); + expect(result.ok).toBe(true); + // The stall does NOT inflate the tool-call total (1, not 2)... + expect(result.content).toContain('1 tool calls — 1 ok, 0 failed'); + // ...it is surfaced as its own heuristic clause. + expect(result.content).toContain('1 narrated stall (heuristic)'); + }); +}); diff --git a/tests/unit/render-probe.test.ts b/tests/unit/render-probe.test.ts new file mode 100644 index 000000000..3cd9323a1 --- /dev/null +++ b/tests/unit/render-probe.test.ts @@ -0,0 +1,81 @@ +import React from 'react'; +import { render } from 'ink'; +import { describe, expect, it } from 'vitest'; + +import { createRenderProbeTool } from '../../packages/cli/src/generated/cesar/tool-render-probe.js'; +import { createPseudoTty } from '../../packages/cli/src/generated/blocks/frame-capture.js'; +import { TodoList } from '../../packages/cli/src/generated/blocks/todo-list.js'; + +const ctx = { cwd: process.cwd(), readFileState: new Map() } as any; + +describe('RenderProbe tool', () => { + it('renders the TodoList fixture with the given todos', async () => { + const tool = createRenderProbeTool(); + const result = await tool.execute( + { + surface: 'TodoList', + cols: 60, + rows: 20, + props: { + todos: [ + { id: '1', text: 'First todo item', state: 'pending' }, + { id: '2', text: 'Second todo item', state: 'pending' }, + ], + }, + }, + ctx, + ); + expect(result.ok).toBe(true); + expect(result.content).toContain('Todos 0/2'); + expect(result.content).toContain('First todo item'); + expect(result.content).toContain('Second todo item'); + }); + + it('rejects an unknown surface id and lists the valid ids', async () => { + const tool = createRenderProbeTool(); + const result = await tool.execute({ surface: 'bogus' }, ctx); + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error).toContain('StatusBar'); + expect(result.error).toContain('TodoList'); + expect(result.error).toContain('ChromeBar'); + }); + + it('returns only the final frame after a rerender (final-frame semantics)', async () => { + // Drive the pseudo-TTY directly to prove the nero-mandated contract: after a + // rerender, lastFrame() reflects only the settled state, while read() (the + // legacy transcript accumulator) still carries the stale intermediate render. + const tty = createPseudoTty(60, 20); + const app = render( + React.createElement(TodoList as any, { + todos: [{ id: '1', text: 'INITIAL-STATE-ROW', state: 'pending' }], + planActive: false, + }), + { + stdout: tty.stdout as any, + stderr: tty.stderr as any, + stdin: tty.stdin as any, + debug: true, + exitOnCtrlC: false, + patchConsole: false, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + app.rerender( + React.createElement(TodoList as any, { + todos: [{ id: '1', text: 'FINAL-STATE-ROW', state: 'pending' }], + planActive: false, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + app.unmount(); + await new Promise((resolve) => setTimeout(resolve, 5)); + + const finalFrame = tty.lastFrame(); + expect(finalFrame).toContain('FINAL-STATE-ROW'); + expect(finalFrame).not.toContain('INITIAL-STATE-ROW'); + // The transcript accumulator still holds the stale render — proving the + // final-frame semantics are a real distinction, not a coincidence. + expect(tty.read()).toContain('INITIAL-STATE-ROW'); + }); +}); diff --git a/tests/unit/terminal-frame.test.ts b/tests/unit/terminal-frame.test.ts index d47a577b6..91938b440 100644 --- a/tests/unit/terminal-frame.test.ts +++ b/tests/unit/terminal-frame.test.ts @@ -1,32 +1,10 @@ -import { PassThrough } from 'node:stream'; import React from 'react'; import { render } from 'ink'; import { describe, expect, it } from 'vitest'; import { StatusBar } from '../../packages/cli/src/generated/surfaces/status.js'; import { buildPriorityStatusLine } from '../../packages/cli/src/generated/surfaces/status-helpers.js'; - -function stripTerminalControl(value: string): string { - return value - .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') - .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\r/g, ''); -} - -function createPseudoTty(width: number, height: number) { - 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 = () => {}; - let output = ''; - stdout.on('data', (chunk) => { output += chunk.toString(); }); - return { stdout, stderr, stdin, read: () => stripTerminalControl(output) }; -} +import { createPseudoTty } from '../../packages/cli/src/generated/blocks/frame-capture.js'; function statusProps(termWidth: number) { return { diff --git a/tests/unit/tui-probe-tool.test.ts b/tests/unit/tui-probe-tool.test.ts new file mode 100644 index 000000000..090f82a53 --- /dev/null +++ b/tests/unit/tui-probe-tool.test.ts @@ -0,0 +1,61 @@ +import { existsSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + TUI_PROBE_INPUT_SAFELIST, + createTuiProbeTool, + resolveTuiProbePaths, +} from '../../packages/cli/src/generated/cesar/tool-tui-probe.js'; + +const ctx = {} as never; + +describe('TuiProbe tool', () => { + it('exposes a read-only, non-concurrency-safe definition', () => { + const tool = createTuiProbeTool(); + expect(tool.definition.name).toBe('TuiProbe'); + expect(tool.definition.isReadOnly).toBe(true); + expect(tool.definition.isConcurrencySafe).toBe(false); + }); + + it('validate rejects non-safelisted input (layout probe never dispatches engines)', () => { + const tool = createTuiProbeTool(); + expect(tool.validate({ input: 'review the auth code' }, ctx)).toMatch(/must start with one of/); + expect(tool.validate({ input: '/forge do things' }, ctx)).toMatch(/must start with one of/); + for (const allowed of TUI_PROBE_INPUT_SAFELIST) { + expect(tool.validate({ input: allowed }, ctx)).toBeNull(); + } + // default (no input) is /help — allowed + expect(tool.validate({}, ctx)).toBeNull(); + }); + + it('validate rejects control characters — a newline must not smuggle a second command past the prefix check', () => { + const tool = createTuiProbeTool(); + // agon-review blocking finding: the PTY submits on newline, so a safelisted + // first line followed by an engine-dispatching second line is an injection. + expect(tool.validate({ input: '/help x\n/forge rm -rf' }, ctx)).toMatch(/single line without control characters/); + expect(tool.validate({ input: '/help\r/council attack' }, ctx)).toMatch(/single line|must start with one of/); + expect(tool.validate({ input: '/help ' }, ctx)).toMatch(/single line without control characters/); + // prefix must be exact or followed by a space + expect(tool.validate({ input: '/helpanything' }, ctx)).toMatch(/must start with one of/); + }); + + it('resolves the probe script inside the package py/ dir', () => { + const { script } = resolveTuiProbePaths(); + expect(script).toBeTruthy(); + expect(script).toMatch(/packages\/cli\/py\/agon-tui-probe\.py$|@kernlang\/agon\/py\/agon-tui-probe\.py$/); + expect(existsSync(script as string)).toBe(true); + }); + + // Full end-to-end spawn of a throwaway agon (~3s, needs built dist + python3 + // + pyte). Gated so CI/unit runs stay fast and deterministic; run with + // AGON_TUI_E2E=1 locally or in the gate. + it.runIf(process.env.AGON_TUI_E2E === '1')( + 'end-to-end: returns the final pyte frame containing the ChromeBar', + async () => { + const tool = createTuiProbeTool(); + const result = await tool.execute({ input: '/help', timeoutSec: 40 }, ctx); + expect(result.ok).toBe(true); + expect(result.content).toContain('AGON'); + }, + 70000, + ); +}); From b69a4685b52e7007139ccfa5a9a9bf790e5c34a5 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:21:35 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(core):=20delegate=20tool=20ledger=20?= =?UTF-8?q?=E2=80=94=20per-dispatch=20tool=20outcomes=20with=20honest=20pr?= =?UTF-8?q?ovenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated engines previously returned only a bare toolCalls count; Cesar had no evidence of which tool calls a delegate dropped, faked, or stalled on. Now every dispatch writes one record to ~/.agon/runs/delegate-tool-ledger.jsonl: - api-loop runs carry per-call outcomes (native provenance, exactly one outcome per counted call incl. pre-execution failures — review finding) - narrated stalls are heuristic and NEVER inflate native counts (review finding); text transports record unknown / dispatchFailed, never ok/error fabricated from prose - summaries group per engine x backend, engines never merge (review finding); reader never throws on corrupt or schema-invalid lines (review finding) - Delegate results append a one-line ledger digest; failed dispatches are recorded too (zai+kimi convergent review finding) ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/cesar/session.ts | 82 ++-- packages/cli/src/kern/cesar/session.kern | 23 +- packages/core/src/generated/api/agent-loop.ts | 81 +++- .../core/src/generated/cesar/agent-session.ts | 18 +- .../src/generated/cesar/agent-synthesis.ts | 46 ++- .../core/src/generated/cesar/speculator.ts | 16 +- .../src/generated/signals/delegate-ledger.ts | 368 +++++++++++++++++ packages/core/src/index.ts | 12 + packages/core/src/kern/api/agent-loop.kern | 69 +++- .../core/src/kern/cesar/agent-session.kern | 5 + .../core/src/kern/cesar/agent-synthesis.kern | 7 + packages/core/src/kern/cesar/speculator.kern | 5 + .../src/kern/signals/delegate-ledger.kern | 371 ++++++++++++++++++ tests/unit/api-agent-loop.test.ts | 48 +++ tests/unit/delegate-ledger.test.ts | 257 ++++++++++++ 15 files changed, 1319 insertions(+), 89 deletions(-) create mode 100644 packages/core/src/generated/signals/delegate-ledger.ts create mode 100644 packages/core/src/kern/signals/delegate-ledger.kern create mode 100644 tests/unit/delegate-ledger.test.ts 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/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/core/src/generated/api/agent-loop.ts b/packages/core/src/generated/api/agent-loop.ts index 70a1559ef..8ce78b464 100644 --- a/packages/core/src/generated/api/agent-loop.ts +++ b/packages/core/src/generated/api/agent-loop.ts @@ -48,7 +48,9 @@ import type { ToolCacheEntry } from '../models/context-parts.js'; import { safeAgentVisibleText } from './agent-visible.js'; -// @kern-source: agent-loop:31 +import type { AgentToolOutcome } from '../signals/delegate-ledger.js'; + +// @kern-source: agent-loop:32 export interface ApiAgentOptions { api: ApiConfig; prompt: string; @@ -74,7 +76,7 @@ export interface ApiAgentOptions { retryBaseMs?: number; } -// @kern-source: agent-loop:56 +// @kern-source: agent-loop:57 export interface ApiAgentResult { response: string; toolCalls: number; @@ -85,12 +87,13 @@ export interface ApiAgentResult { cancelled?: boolean; timedOut?: boolean; harvestable?: boolean; + toolOutcomes?: AgentToolOutcome[]; } /** * Attempt to repair malformed JSON tool arguments. Handles common LLM mistakes: markdown fencing, trailing commas, single quotes, unquoted keys. */ -// @kern-source: agent-loop:67 +// @kern-source: agent-loop:70 export function repairToolArgs(raw: string): Record|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