diff --git a/packages/cli/src/generated/blocks/rendering.entry.tsx b/packages/cli/src/generated/blocks/rendering.entry.tsx index 9f5d58894..087f99e4c 100644 --- a/packages/cli/src/generated/blocks/rendering.entry.tsx +++ b/packages/cli/src/generated/blocks/rendering.entry.tsx @@ -1,7 +1,7 @@ #!/usr/bin/env node -// @generated by kern v3.5.7 — DO NOT EDIT. Source: src/kern/blocks/rendering.kern +// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/blocks/rendering.kern -// @kern-source: rendering:428 +// @kern-source: rendering:438 import React from 'react'; import { render } from 'ink'; diff --git a/packages/cli/src/generated/blocks/rendering.tsx b/packages/cli/src/generated/blocks/rendering.tsx index 1c176d6f8..cf026dd9f 100644 --- a/packages/cli/src/generated/blocks/rendering.tsx +++ b/packages/cli/src/generated/blocks/rendering.tsx @@ -71,11 +71,20 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment const bc = borderColor || '#585858'; const maxLineLen = capped.reduce((m: number, l: string) => Math.max(m, l.length), 0); const headerLen = (segment.language || 'code').length + (segment.index !== undefined ? ` [${segment.index}]`.length : 0); - const innerWidth = Math.max(maxLineLen, headerLen); - const boxWidth = innerWidth + 4; - const rule = '\u2500'.repeat(boxWidth); + // Single coherent inner-content width for every row. Clamp to the terminal + // budget so a long line truncates instead of forcing the box wider than the + // screen. Every row is built as: `\u2502 \u258c \u2502` + // left frame `\u2502 \u258c ` = 5 cols, right frame ` \u2502` = 3 cols \u2192 rowWidth = body + 8. + // border row `\u2502 ` + rule + ` \u2502` = rule.length + 4, so rule = body + 4. + // Previously the box was sized to body+2 while rows rendered at body+4..body+11, + // so every row overflowed and Ink wrapped it \u2014 dropping the trailing `\u2502` onto a + // blank row (the stray pipes + huge gaps between lines). + const body = Math.min(Math.max(maxLineLen, headerLen), codeWidth); + const rowWidth = body + 8; + const rule = '\u2500'.repeat(body + 4); + const overflowLabel = `\u2026 ${overflow} more lines`; return ( - + {'\u2502 '}{rule}{' \u2502'} {'\u2502 '} @@ -83,7 +92,7 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment {segment.language || 'code'} {segment.index !== undefined && {` [${segment.index}]`}} - {' '.repeat(Math.max(0, boxWidth - headerLen - 1))} + {' '.repeat(Math.max(0, body - headerLen))} {' \u2502'} {capped.map((line: string, i: number) => ( @@ -91,8 +100,8 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment {'\u2502 '} {CODE_RAIL} - {isDiff ? : } - {' '.repeat(Math.max(0, codeWidth - line.length - 4))} + {isDiff ? : } + {' '.repeat(Math.max(0, body - line.length))} {' \u2502'} ))} @@ -101,7 +110,8 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment {'\u2502 '} {CODE_RAIL} - {'\u2026 '}{overflow}{' more lines'} + {overflowLabel} + {' '.repeat(Math.max(0, body - overflowLabel.length))} {' \u2502'} )} @@ -110,7 +120,7 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment ); } -// @kern-source: rendering:254 +// @kern-source: rendering:264 export function RichSpanView({ span }: { span:InlineSpan }) { if (span.style.code) { return {span.text}; @@ -127,7 +137,7 @@ export function RichSpanView({ span }: { span:InlineSpan }) { return el; } -// @kern-source: rendering:275 +// @kern-source: rendering:285 export function RichLineView({ line, borderColor }: { line:RichLine; borderColor?:string }) { const border = borderColor ? {'\u2502 '} : null; const indent = line.indent > 0 ? ' '.repeat(line.indent) : ''; @@ -149,7 +159,7 @@ export function RichLineView({ line, borderColor }: { line:RichLine; borderColor return {border}{indent}{listIndent}{marker}{line.spans.map((s: InlineSpan, i: number) => )}; } -// @kern-source: rendering:302 +// @kern-source: rendering:312 export function MarkdownTableView({ headers, rows, alignments, borderColor }: { headers:string[]; rows:string[][]; alignments:('left' | 'center' | 'right')[]; borderColor:string }) { const colWidths = headers.map((h: string, i: number) => { let max = h.length; @@ -185,7 +195,7 @@ export function MarkdownTableView({ headers, rows, alignments, borderColor }: { ); } -// @kern-source: rendering:345 +// @kern-source: rendering:355 export function RenderedSegments({ segments, borderColor, wrapWidth }: { segments:ContentSegment[]; borderColor:string; wrapWidth:number }) { return ( <> @@ -246,7 +256,7 @@ export function RenderedSegments({ segments, borderColor, wrapWidth }: { segment ); } -// @kern-source: rendering:412 +// @kern-source: rendering:422 export function GradientLine({ text, colors }: { text:string; colors:readonly string[] }) { const step = Math.max(1, Math.ceil(text.length / colors.length)); return ( @@ -259,7 +269,7 @@ export function GradientLine({ text, colors }: { text:string; colors:readonly st ); } -// @kern-source: rendering:428 +// @kern-source: rendering:438 export function AnsiLine({ text, maxWidth, fallbackDim }: { text:string; maxWidth:number; fallbackDim?:boolean }) { if (!hasAnsiCodes(text)) { const display = text.length > maxWidth ? text.slice(0, maxWidth - 4) + '\u2026' : text; diff --git a/packages/cli/src/generated/cesar/brain-helpers.ts b/packages/cli/src/generated/cesar/brain-helpers.ts index 4c9a44eab..79fd486d9 100644 --- a/packages/cli/src/generated/cesar/brain-helpers.ts +++ b/packages/cli/src/generated/cesar/brain-helpers.ts @@ -121,9 +121,24 @@ export function detectMutationIntentStall(text: string): boolean { } /** - * Return unique tool names from failed eager tool results. Used to restrict one-shot repair retries to the tool that just failed. + * Detect a response that CLAIMS an async review/forge/tribunal/brainstorm/agent or background job was dispatched or is now running — e.g. 'review delegated to codex, claude, agy', 'three reviewers are reading the diff in parallel', 'I kicked off the review', "I'll get back when they report". The caller pairs this with 'no delegation was actually emitted this turn' (ctx.cesar.pendingDelegation is null) to catch the confabulation where a weak engine narrates a dispatch it never made. Requires BOTH a delegable target AND a dispatch/running claim, so a plain answer that merely mentions the word 'review' does not trip it. */ // @kern-source: brain-helpers:113 +export function detectFabricatedDelegation(text: string): boolean { + const body = String(text ?? '').trim(); + if (!body) return false; + // A delegable target: review / forge / tribunal / brainstorm / campfire / agents / engines / a background job. + const TARGET_RE = /\b(?:review(?:er)?s?|forg(?:e|ing)|tribunal|brainstorm|campfire|agents?|engines?|jobs?)\b/i; + if (!TARGET_RE.test(body)) return false; + // A claim that the target was dispatched or is now running / will report back. + const DISPATCH_RE = /\b(?:kick(?:ed|ing)?\s*(?:it|them|that|the\s+\w+)?\s*off|fired?\s*(?:it|them|off)|dispatch(?:ed|ing)|delegat(?:ed|ing)|(?:is|are|now)\s+running|running\s+(?:in|now)|in\s+parallel|reading\s+the\s+(?:diff|changes|code)|working\s+(?:on\s+it|in\s+parallel)|in\s+progress|under\s*way|i'?ll\s+(?:get\s+back|report|let\s+you\s+know|surface|update)|report(?:s|ing)?\s+back|when\s+they\s+(?:report|land|return|finish|come\s+back)|still\s+(?:running|going|working|in\s+progress)|spun?\s+up|started\s+(?:the|a)\s+(?:review|forge|job|tribunal|brainstorm))\b/i; + return DISPATCH_RE.test(body); +} + +/** + * Return unique tool names from failed eager tool results. Used to restrict one-shot repair retries to the tool that just failed. + */ +// @kern-source: brain-helpers:126 export function eagerFailedToolNames(results: ToolCallResult[]): string[] { const names: string[] = []; for (const result of results ?? []) { @@ -141,7 +156,7 @@ export function eagerFailedToolNames(results: ToolCallResult[]): string[] { /** * Gate eager tool repair retries. A corrected tool call may run once only if the same tool failed in the immediately previous eager batch. */ -// @kern-source: brain-helpers:125 +// @kern-source: brain-helpers:138 export function shouldRunEagerRepairTool(toolName: string, meta: any, failedToolNames: string[], usedToolNames: string[]): boolean { const name = String(toolName ?? '').trim(); if (!name) return false; @@ -156,7 +171,7 @@ export function shouldRunEagerRepairTool(toolName: string, meta: any, failedTool /** * Return true for XML tools that hand control back to the Agon dispatcher. These tools do not produce inline results; continuing the XML tool loop after them can make Cesar claim a delegation happened while the actual forge/brainstorm/etc. job has not started yet. */ -// @kern-source: brain-helpers:138 +// @kern-source: brain-helpers:151 export function shouldStopAfterXmlToolCall(toolName: string): boolean { const HANDOFF_TOOLS = new Set(['Forge', 'Brainstorm', 'Tribunal', 'Campfire', 'Pipeline', 'Review', 'Agent', 'Goal', 'ProposePlan', 'ExitPlanMode']); return HANDOFF_TOOLS.has(String(toolName ?? '')); @@ -165,7 +180,7 @@ export function shouldStopAfterXmlToolCall(toolName: string): boolean { /** * Expand a bare 'fix it' follow-up into an explicit prompt grounded in the most recent stored review result. This avoids making Cesar guess which reviewer findings the user means, especially because /review runs outside Cesar's live session history. */ -// @kern-source: brain-helpers:144 +// @kern-source: brain-helpers:157 export function buildReviewFollowupPrompt(input: string, ctx: HandlerContext): { matched: boolean; prompt: string } { const trimmed = input.trim(); const match = trimmed.match(/^fix it(?:\s+with\s+([a-z0-9._-]+))?[\s?!.,;:]*$/i); @@ -186,7 +201,7 @@ export function buildReviewFollowupPrompt(input: string, ctx: HandlerContext): { return { matched: true, prompt: prompt }; } -// @kern-source: brain-helpers:163 +// @kern-source: brain-helpers:176 export function extractDelegation(toolName: string, args: Record): PendingDelegation { const argsRecord = args as Record; const taskKindRaw = argsRecord.taskKind; diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 85de85f6d..8b5f047df 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -32,7 +32,7 @@ import { applyCesarSelfTurnApproval } from './self-turn-approval.js'; import { createCesarTurnId, recordCesarApprovalDecision, recordCesarToolTimeline, recordCesarConfidence } from './tool-observability.js'; -import { yieldToInk, splitBeforeToolMarkup, XML_TOOL_MARKUP_HOLD_CHARS, findTrailingUserQuestion, detectAwaitingUserInput, detectNarratedToolStall, detectMutationIntentStall, eagerFailedToolNames, shouldRunEagerRepairTool, shouldStopAfterXmlToolCall, buildReviewFollowupPrompt, extractDelegation } from './brain-helpers.js'; +import { yieldToInk, splitBeforeToolMarkup, XML_TOOL_MARKUP_HOLD_CHARS, findTrailingUserQuestion, detectAwaitingUserInput, detectNarratedToolStall, detectMutationIntentStall, detectFabricatedDelegation, eagerFailedToolNames, shouldRunEagerRepairTool, shouldStopAfterXmlToolCall, buildReviewFollowupPrompt, extractDelegation } from './brain-helpers.js'; // @kern-source: brain:19 export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input: string, response: string, cesarEngineId: string, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record): Promise { @@ -319,8 +319,14 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H const outputDir = join(RUNS_DIR, `cesar-fallback-${Date.now()}`); mkdirSync(outputDir, { recursive: true }); const primedPrompt = buildHistoryPrimedPrompt(ctx.chatSession, input); + // Cesar is an agentic leading role, so dispatch in 'agent' mode when the + // engine supports it. 'exec' triggers agy's OUTPUT-RULES gag (adapter-helpers: + // engine.id === 'agy' && mode !== 'agent') which forbids file edits / tool use + // and forces a single-pass text answer — exactly why agy could not run tools + // as Cesar. Fall back to 'exec' for engines without an agent mode (no regression). + const fallbackMode = ((engine as any)?.agent ? 'agent' : 'exec') as any; const freshResult = await ctx.adapter.dispatch({ - engine, prompt: primedPrompt, cwd: resolveWorkingDir(), mode: 'exec' as any, + engine, prompt: primedPrompt, cwd: resolveWorkingDir(), mode: fallbackMode, timeout: config.timeout ?? 120, outputDir, signal: abort.signal, systemPrompt: buildCesarSystemPrompt(ctx), }); dispatch({ type: 'spinner-stop' }); @@ -1625,6 +1631,43 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H } } + // ── Fabricated-delegation guard: ground a confabulated dispatch ── + // Catches the failure where a weak engine narrates that it dispatched or is + // running an async review/forge/agent job ("three reviewers are reading the + // diff in parallel", "I kicked off the review", "I'll report when they land") + // WITHOUT having emitted any delegation this turn — pendingDelegation is null, + // so nothing is actually queued or running. Re-prompt once to ground it: call + // the real tool now, or tell the user plainly that nothing is running. If the + // re-prompt dispatches for real, pendingDelegation gets set and the existing + // downstream delegation path takes over. + if ( + !ctx.cesar!.pendingDelegation + && session.alive + && !abort.signal.aborted + && detectFabricatedDelegation(response.trim()) + ) { + dispatch({ type: 'warning', message: 'Cesar claimed a job was running but never dispatched one — grounding...' }); + dispatch({ type: 'spinner-start', message: 'Cesar grounding…', color }); + try { + let groundResponse = ''; + const groundGen = session.send({ + message: '[SYSTEM] GROUNDING CHECK: You did NOT dispatch any review/forge/tribunal/brainstorm/agent/job this turn, and none is pending or running. Do NOT claim background work is "running", "in parallel", "kicked off", or that anyone "will report back" — that is false and misleads the user. If the user wants that work done, call the actual tool now (Review/Forge/Tribunal/Brainstorm/Agent). Otherwise tell the user plainly that nothing is currently running and ask whether to start it.', + signal: abort.signal, + }); + for await (const chunk of groundGen) { + if (chunk.type === 'text') groundResponse += chunk.content; + if (chunk.type === 'done' || chunk.type === 'error') break; + } + dispatch({ type: 'spinner-stop' }); + if (groundResponse.trim()) { + dispatch({ type: 'engine-block', engineId: cesarEngineId, color, content: groundResponse.trim() }); + response = groundResponse.trim(); + } + } catch { + dispatch({ type: 'spinner-stop' }); + } + } + // ── Protocol enforcement: DISABLED ── // Cesar decides all delegations. The system never forces brainstorm/tribunal on the user. // If Cesar wants to delegate, he calls the tool. If he doesn't, that's his call. diff --git a/packages/cli/src/generated/handlers/review.ts b/packages/cli/src/generated/handlers/review.ts index 77e17dee7..209c40217 100644 --- a/packages/cli/src/generated/handlers/review.ts +++ b/packages/cli/src/generated/handlers/review.ts @@ -82,6 +82,23 @@ export function resolveReviewTarget(target: string|undefined, cwd: string): {dif } else if (t.startsWith('branch:')) { const branch = t.slice(7); label = `branch ${branch}`; + // `git diff BRANCH...HEAD` reviews HEAD's changes relative to BRANCH as the + // base — correct when BRANCH is the base (e.g. branch:main). The footgun: + // when BRANCH resolves to the same commit as HEAD (targeting the branch you + // are currently on), it's an empty self-diff that surfaces as a silent + // "No changes to review" — which a caller (or Cesar) can mistake for a clean + // review. Detect that and fail LOUDLY with the right targets instead. + let branchSha = ''; + let headSha = ''; + try { + branchSha = execFileSync('git', ['rev-parse', branch], { cwd, encoding: 'utf-8' }).trim(); + headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).trim(); + } catch (err) { + throw new Error(`Failed to resolve branch "${branch}": ${err instanceof Error ? err.message : String(err)}`); + } + if (branchSha && branchSha === headSha) { + throw new Error(`branch:${branch} points at the commit you are currently on, so diffing it against HEAD yields nothing to review. Use "branch:main" (or your base branch) to review this branch's commits, or "uncommitted" to review working-tree changes.`); + } try { diff = execFileSync('git', ['diff', `${branch}...HEAD`], { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim(); } catch (err) { @@ -107,7 +124,7 @@ export function resolveReviewTarget(target: string|undefined, cwd: string): {dif return { diff, label }; } -// @kern-source: review:100 +// @kern-source: review:117 export function selectReviewEngine(requestedEngine: string|undefined, ctx: HandlerContext): string { const allActive = ctx.activeEngines(); const active = requestedEngine ? allActive : filterDefaultOrchestrationEngines(allActive); @@ -155,7 +172,7 @@ export function selectReviewEngine(requestedEngine: string|undefined, ctx: Handl throw new Error('No engines available for review. Try /engines to check availability.'); } -// @kern-source: review:148 +// @kern-source: review:165 export interface ReviewCoreResult { response: string; blocking: boolean; @@ -165,10 +182,10 @@ export interface ReviewCoreResult { usage?: {promptTokens:number,completionTokens:number,totalTokens:number,source:'sdk'|'cli-reported'|'estimated'}; } -// @kern-source: review:159 +// @kern-source: review:176 export const REVIEW_SENTINEL: string = ''; -// @kern-source: review:161 +// @kern-source: review:178 export interface ReviewSeverityCounts { blocking: number; important: number; @@ -179,7 +196,7 @@ export interface ReviewSeverityCounts { /** * Sentinel-anchored, fail-closed extraction of the findings array — the single chokepoint shared by parseReviewBlocking (the blocking gate) and summarizeReviewFindings (severity counts). Returns the parsed array (possibly empty []) or null when no parseable block follows the LAST sentinel. Anti-injection: only text after the LAST sentinel is considered, so attacker brackets quoted earlier in the diff are ignored. Tolerant of almost-JSON (trailing commas, line and block JS-style comments) and fenced json code blocks. */ -// @kern-source: review:167 +// @kern-source: review:184 export function extractReviewFindings(response: string): Array<{severity?:string, blocking?:boolean}> | null { if (!response || response.trim().length === 0) return null; @@ -279,7 +296,7 @@ export function extractReviewFindings(response: string): Array<{severity?:string /** * Sentinel-anchored, fail-closed parser. The engine MUST end its response with a unique sentinel followed by a JSON array of findings. Without a parseable block the response is treated as blocking + parseFailed, so the user must explicitly approve. This blocks the prompt-injection attack where an attacker echoes `[{"blocking":false}]` inside diff content — only the engine's real structured output after the LAST sentinel is considered. Thin wrapper over extractReviewFindings. */ -// @kern-source: review:265 +// @kern-source: review:282 export function parseReviewBlocking(response: string): {blocking:boolean, parseFailed:boolean} { const findings = extractReviewFindings(response); if (findings === null) return { blocking: true, parseFailed: true }; @@ -290,7 +307,7 @@ export function parseReviewBlocking(response: string): {blocking:boolean, parseF /** * Count findings by severity from the structured block, for human summaries like 'claude: ok, 1 important, 3 nits'. Returns all-zero when there is no parseable findings block (the caller renders that as unstructured/empty). A finding counts as blocking if blocking===true or severity==='blocking'; otherwise by its severity, with anything not 'important' falling to nit. */ -// @kern-source: review:274 +// @kern-source: review:291 export function summarizeReviewFindings(response: string): ReviewSeverityCounts { const findings = extractReviewFindings(response); if (!findings) return { blocking: 0, important: 0, nit: 0, total: 0 }; @@ -309,7 +326,7 @@ export function summarizeReviewFindings(response: string): ReviewSeverityCounts /** * Repair pass (B): re-ask the engine for ONLY a bare JSON array of the findings it already wrote in prose. Asking for a bare array (no sentinel, no prose, no fence) is the format LLMs comply with most reliably — far better than 'an HTML-comment marker followed by JSON', which engines routinely truncate to just the marker. The caller (runReviewCore) prepends the sentinel itself before parsing, so the anti-injection anchor is preserved. Best-effort: if this still doesn't parse, the fail-closed/unstructured result stands. */ -// @kern-source: review:291 +// @kern-source: review:308 export async function runReviewRepair(priorReview: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal): Promise { const config = ctx.config; const cwd = resolveWorkingDir(); @@ -359,7 +376,7 @@ export async function runReviewRepair(priorReview: string, engineId: string, ctx /** * Repo grounding: read the CURRENT full content of each source file the diff touches and format it as a context block. A diff shows only the changed hunks, so reviewers raise false alarms that reading the whole file would kill instantly ('X is unhandled' when the wrapper handles it three lines down; 'unimported' when it's imported at the top). Bounded hard (per-file + total caps) to protect prompt size / TTFT, and skips generated/dist/min files (derived noise that would blow the budget). Best-effort: deleted/binary/unreadable files are skipped — the diff still covers them. */ -// @kern-source: review:329 +// @kern-source: review:346 export function gatherReviewFileContext(diff: string, cwd: string): string { const PER_FILE_MAX = 20_000; const TOTAL_MAX = 60_000; @@ -406,7 +423,7 @@ export function gatherReviewFileContext(diff: string, cwd: string): string { /** * 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. */ -// @kern-source: review:374 +// @kern-source: review:391 export async function runReviewCore(diff: string, label: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal, onProgress?: (chunk:string)=>void, cwdOverride?: string): Promise { const cwd = cwdOverride ?? resolveWorkingDir(); const config = ctx.config; @@ -502,7 +519,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:450 +// @kern-source: review:467 export function stripMachineBlock(response: string): string { const idx = response.lastIndexOf(REVIEW_SENTINEL); if (idx < 0) return response; @@ -512,7 +529,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:458 +// @kern-source: review:475 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 @@ -531,7 +548,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. */ -// @kern-source: review:475 +// @kern-source: review:492 export function buildReviewConsensusLines(consensus: any): string[] { const fmt = (f: any): string => ` • [${f.severity} ${f.maxConfidence.toFixed(2)} ×${f.engines.length}${f.pairVotes >= 2 ? ' pair' : ''}] ${f.problem}${f.file ? ` (${f.file}${f.lines ? ':' + f.lines : ''})` : ''}`; const lines: string[] = [`Consensus — ${consensus.summary}`]; @@ -546,7 +563,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:488 +// @kern-source: review:505 export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { if (!c || c.total === 0) return 'no findings'; const parts: string[] = []; @@ -556,7 +573,7 @@ export function formatReviewCounts(c: ReviewSeverityCounts|undefined): string { return parts.join(', '); } -// @kern-source: review:499 +// @kern-source: review:516 export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngine?: string): Promise { const abort = new AbortController(); try { @@ -682,7 +699,7 @@ export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, targ /** * Run review for one or more explicitly requested engines. 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 single engine delegates to the streaming handleReview path. */ -// @kern-source: review:621 +// @kern-source: review:638 export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngines?: string[]): Promise { const abort = new AbortController(); try { diff --git a/packages/cli/src/generated/lib/terminal-notify.ts b/packages/cli/src/generated/lib/terminal-notify.ts new file mode 100644 index 000000000..7feda78fe --- /dev/null +++ b/packages/cli/src/generated/lib/terminal-notify.ts @@ -0,0 +1,31 @@ +// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/lib/terminal-notify.kern + +import { stdout } from 'node:process'; + +/** + * Ring the terminal bell once on stdout. No-op when stdio is not a TTY (piped runs, CI) or when AGON_NO_BELL is set in the environment, so we never break automation or annoy users who opted out. + */ +// @kern-source: terminal-notify:3 +export function bell(): void { + if (!stdout.isTTY) { + return; + } + if (process.env.AGON_NO_BELL) { + return; + } + stdout.write('\x07'); +} + +/** + * Set the terminal window/tab title via the OSC 0 ;