Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/cli/src/commands/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
: 'single engine';
if (!quiet) {
header(`Review: ${target.label}`);
info(`Repo: ${cwd}`);
info(`Engines: ${requested.join(', ')} (${concurrencyNote})`);
info(`Per-engine timeout: ${timeoutSec}s (auto-cancel, others unaffected)`);
}
Expand Down Expand Up @@ -186,9 +187,15 @@
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')}`); };

Check warning on line 190 in packages/cli/src/commands/review.ts

View check run for this annotation

KERN guard / kern-guard

sensitive-console-log

Runtime console log includes request/auth/body/PII-looking data — logs can leak credentials or personal data
Raw output
Confidence: 82%

Suggestion:
Log only non-sensitive metadata, or pass values through a redaction helper before logging.
try {
const result = await runReviewCore(target.diff, target.label, engineId, ctx, controller.signal);
// Pin the engine dispatch to the SAME cwd the diff came from (process.cwd()).
// Without this cwdOverride, runReviewCore falls back to resolveWorkingDir()
// = the active workspace — so `agon review` run from repo X dispatched every
// engine into the active-workspace repo (e.g. agon's own source) to gather
// file context, while reviewing X's diff. The reviewers "never saw the code"
// the diff referenced. Passing cwd keeps diff + engine context in one repo.
const result = await runReviewCore(target.diff, target.label, engineId, ctx, controller.signal, undefined, cwd);
const rawResponse = result.response ?? '';
writeOutput(rawResponse);
if (timedOut) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/generated/blocks/plan-view.entry.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// @generated by kern v3.5.7 — DO NOT EDIT. Source: src/kern/blocks/plan-view.kern
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/blocks/plan-view.kern

// @kern-source: plan-view:220
// @kern-source: plan-view:222

import React from 'react';
import { render } from 'ink';
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/generated/blocks/plan-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ const PlanProposalView = React.memo(function PlanProposalView({ plan, markdown,
<Box flexDirection="column">
<Text color="#fbbf24" bold>{'Awaiting approval'}</Text>
<Text color={(selectedIndex ?? 0) === 0 ? '#4ade80' : '#6b7280'} bold={(selectedIndex ?? 0) === 0}>{(selectedIndex ?? 0) === 0 ? '❯ ' : ' '}{'1. Approve & run'}</Text>
<Text color={(selectedIndex ?? 0) === 1 ? '#ef4444' : '#6b7280'} bold={(selectedIndex ?? 0) === 1}>{(selectedIndex ?? 0) === 1 ? '❯ ' : ' '}{'2. Reject'}</Text>
<Text dimColor>{' ↑↓ move · Enter select · ✎ type to change · /approve · /cancel'}</Text>
<Text color={(selectedIndex ?? 0) === 1 ? '#60a5fa' : '#6b7280'} bold={(selectedIndex ?? 0) === 1}>{(selectedIndex ?? 0) === 1 ? '❯ ' : ' '}{'2. Other — revise the plan'}</Text>
<Text color={(selectedIndex ?? 0) === 2 ? '#ef4444' : '#6b7280'} bold={(selectedIndex ?? 0) === 2}>{(selectedIndex ?? 0) === 2 ? '❯ ' : ' '}{'3. Reject'}</Text>
<Text dimColor>{' ↑↓ move · Enter select · 1/2/3 jump · y/o/n · /approve · /cancel'}</Text>
</Box>
)}
</Box>
Expand Down Expand Up @@ -188,16 +189,17 @@ const PlanProposalView = React.memo(function PlanProposalView({ plan, markdown,
<Box flexDirection="column">
<Text color="#fbbf24" bold>{'Awaiting approval'}</Text>
<Text color={(selectedIndex ?? 0) === 0 ? '#4ade80' : '#6b7280'} bold={(selectedIndex ?? 0) === 0}>{(selectedIndex ?? 0) === 0 ? '❯ ' : ' '}{'1. Approve & run'}</Text>
<Text color={(selectedIndex ?? 0) === 1 ? '#ef4444' : '#6b7280'} bold={(selectedIndex ?? 0) === 1}>{(selectedIndex ?? 0) === 1 ? '❯ ' : ' '}{'2. Reject'}</Text>
<Text dimColor>{' ↑↓ move · Enter select · ✎ type to change · /approve · /cancel'}</Text>
<Text color={(selectedIndex ?? 0) === 1 ? '#60a5fa' : '#6b7280'} bold={(selectedIndex ?? 0) === 1}>{(selectedIndex ?? 0) === 1 ? '❯ ' : ' '}{'2. Other — revise the plan'}</Text>
<Text color={(selectedIndex ?? 0) === 2 ? '#ef4444' : '#6b7280'} bold={(selectedIndex ?? 0) === 2}>{(selectedIndex ?? 0) === 2 ? '❯ ' : ' '}{'3. Reject'}</Text>
<Text dimColor>{' ↑↓ move · Enter select · 1/2/3 jump · y/o/n · /approve · /cancel'}</Text>
</Box>
)}
</Box>
);
});
export { PlanProposalView };

// @kern-source: plan-view:220
// @kern-source: plan-view:222
export function PlanExecutionView({ plan }: { plan:any }) {
const steps: any[] = plan.steps ?? [];
const doneSteps = steps.filter((s: any) => s.state === 'done');
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/generated/blocks/rendering.entry.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/blocks/rendering.kern

// @kern-source: rendering:438
// @kern-source: rendering:447

import React from 'react';
import { render } from 'ink';
Expand Down
26 changes: 17 additions & 9 deletions packages/cli/src/generated/blocks/rendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,16 @@ export function SyntaxLine({ line, maxWidth }: { line:string; maxWidth:number })
}

// @kern-source: rendering:199
export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment & { type: 'code' }; borderColor:string }) {
const codeWidth = contentWidth(8);
export function CodeBlockView({ segment, borderColor, wrapWidth }: { segment:ContentSegment & { type: 'code' }; borderColor:string; wrapWidth?:number }) {
// The box is `body + 8` cols wide (frame). When the parent passes the
// actual available content width (wrapWidth), the box must fit inside it —
// otherwise nesting/padding pushes the block past the available Ink width
// and every row wraps again. Cap the body budget at wrapWidth-8 in that
// case; fall back to the raw terminal budget when no width is provided.
const termBudget = contentWidth(8);
const codeWidth = (typeof wrapWidth === 'number' && wrapWidth > 0)
? Math.max(8, Math.min(termBudget, wrapWidth - 8))
: termBudget;
const lines = (segment.code ?? '').split('\n');
const isDiff = segment.language === 'diff' || lines.some((l: string) => /^[+-@]/.test(l));
const capped = lines.slice(0, MAX_CODE_LINES);
Expand Down Expand Up @@ -120,7 +128,7 @@ export function CodeBlockView({ segment, borderColor }: { segment:ContentSegment
);
}

// @kern-source: rendering:264
// @kern-source: rendering:273
export function RichSpanView({ span }: { span:InlineSpan }) {
if (span.style.code) {
return <Text color="#a78bfa" backgroundColor="#1e1033">{span.text}</Text>;
Expand All @@ -137,7 +145,7 @@ export function RichSpanView({ span }: { span:InlineSpan }) {
return el;
}

// @kern-source: rendering:285
// @kern-source: rendering:294
export function RichLineView({ line, borderColor }: { line:RichLine; borderColor?:string }) {
const border = borderColor ? <Text color={borderColor}>{'\u2502 '}</Text> : null;
const indent = line.indent > 0 ? ' '.repeat(line.indent) : '';
Expand All @@ -159,7 +167,7 @@ export function RichLineView({ line, borderColor }: { line:RichLine; borderColor
return <Text>{border}{indent}{listIndent}{marker}{line.spans.map((s: InlineSpan, i: number) => <RichSpanView key={i} span={s} />)}</Text>;
}

// @kern-source: rendering:312
// @kern-source: rendering:321
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;
Expand Down Expand Up @@ -195,7 +203,7 @@ export function MarkdownTableView({ headers, rows, alignments, borderColor }: {
);
}

// @kern-source: rendering:355
// @kern-source: rendering:364
export function RenderedSegments({ segments, borderColor, wrapWidth }: { segments:ContentSegment[]; borderColor:string; wrapWidth:number }) {
return (
<>
Expand Down Expand Up @@ -248,15 +256,15 @@ export function RenderedSegments({ segments, borderColor, wrapWidth }: { segment
return (
<React.Fragment key={`seg-${i}`}>
{spacer}
<CodeBlockView segment={seg as ContentSegment & { type: 'code' }} borderColor={borderColor} />
<CodeBlockView segment={seg as ContentSegment & { type: 'code' }} borderColor={borderColor} wrapWidth={wrapWidth} />
</React.Fragment>
);
})}
</>
);
}

// @kern-source: rendering:422
// @kern-source: rendering:431
export function GradientLine({ text, colors }: { text:string; colors:readonly string[] }) {
const step = Math.max(1, Math.ceil(text.length / colors.length));
return (
Expand All @@ -269,7 +277,7 @@ export function GradientLine({ text, colors }: { text:string; colors:readonly st
);
}

// @kern-source: rendering:438
// @kern-source: rendering:447
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;
Expand Down
36 changes: 31 additions & 5 deletions packages/cli/src/generated/handlers/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,12 @@ 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.
* Repair pass (B): re-ask the engine for ONLY a bare JSON array of the findings it already wrote in prose. Asking for a bare array (no sentinel, no prose, no fence) is the format LLMs comply with most reliably — far better than 'an HTML-comment marker followed by JSON', which engines routinely truncate to just the marker. The caller (runReviewCore) prepends the sentinel itself before parsing, so the anti-injection anchor is preserved. Best-effort: if this still doesn't parse, the fail-closed/unstructured result stands. cwdOverride must match the main dispatch's cwd so the repair engine runs in the SAME repo (goal worktree / process.cwd()), never the active workspace.
*/
// @kern-source: review:308
export async function runReviewRepair(priorReview: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal): Promise<string> {
export async function runReviewRepair(priorReview: string, engineId: string, ctx: HandlerContext, signal?: AbortSignal, cwdOverride?: string): Promise<string> {
const config = ctx.config;
const cwd = resolveWorkingDir();
const cwd = cwdOverride ?? resolveWorkingDir();
const parts: string[] = [];
parts.push('You previously produced this code review:');
parts.push(priorReview);
Expand Down Expand Up @@ -497,7 +497,7 @@ export async function runReviewCore(diff: string, label: string, engineId: strin
let unstructured = false;
// Repair pass (B): the engine reviewed but didn't emit a parseable findings block (the common case: it ends with the sentinel and no JSON). Re-ask for ONLY a bare JSON array — the format LLMs comply with most reliably — then prepend the sentinel OURSELVES so parseReviewBlocking's anti-injection anchor still holds. Append the reconstructed block so the result is parseable downstream. Failure leaves the fail-closed/unstructured result intact.
if (parseFailed && response.length > 0 && !signal?.aborted) {
const repairResp = await runReviewRepair(response, engineId, ctx, signal);
const repairResp = await runReviewRepair(response, engineId, ctx, signal, cwd);
if (repairResp) {
const repairBlock = `<!--AGON_REVIEW_FINDINGS_v1-->\n${repairResp}`;
const parsed2 = parseReviewBlocking(repairBlock);
Expand Down Expand Up @@ -590,6 +590,9 @@ export async function handleReview(dispatch: Dispatch, ctx: HandlerContext, targ
return;
}

// Announce the real target (and warn on a cwd-vs-reviewed-repo mismatch)
// before anything else, so an empty or wrong-repo review is never silent.
announceReviewTarget(dispatch, cwd, label);
if (!diff.trim()) {
dispatch({ type: 'info', message: `No changes to review (${label}).` });
return;
Expand Down Expand Up @@ -696,10 +699,29 @@ 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. agon resolves the review dir from the active workspace (resolveWorkingDir), not process.cwd(), so running `agon review` from repo X while the active workspace is repo Y silently reviews Y. That footgun produced a 6-engine review of agon's own repo instead of the user's code; this turns it into a loud, actionable signal instead of a silent wrong-repo pass.
*/
// @kern-source: review:641
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 */ }
let branch = '';
try { branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8' }).trim(); } catch { /* detached / no repo */ }
const name = reviewRoot.split(sep).filter(Boolean).pop() ?? reviewRoot;
dispatch({ type: 'info', message: `Reviewing ${label} in ${name} (${reviewRoot})${branch ? ` on ${branch}` : ''}` } as any);
try {
const pwdRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf-8' }).trim();
if (pwdRoot && pwdRoot !== reviewRoot) {
dispatch({ type: 'warning', message: `Heads up: you are in ${pwdRoot}, but this review targets the active workspace ${name} (${reviewRoot}). To review where you are, switch the active workspace (e.g. /workspace ${name === 'agon' ? '<your-repo>' : name}) or pass an explicit target (branch:NAME / commit:SHA).` } as any);
}
} catch { /* process.cwd() isn't a git repo — nothing to compare against */ }
}

/**
* 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:638
// @kern-source: review:658
export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext, target?: string, requestedEngines?: string[]): Promise<void> {
const abort = new AbortController();
try {
Expand All @@ -724,6 +746,10 @@ export async function handleReviewMany(dispatch: Dispatch, ctx: HandlerContext,
dispatch({ type: 'error', message: err instanceof Error ? err.message : String(err) });
return;
}
// Announce the real target (and warn on a cwd-vs-reviewed-repo mismatch)
// BEFORE the empty-diff check, so even "No changes to review" makes clear
// which repo was actually inspected.
announceReviewTarget(dispatch, cwd, label);
if (!diff.trim()) {
dispatch({ type: 'info', message: `No changes to review (${label}).` });
return;
Expand Down
20 changes: 15 additions & 5 deletions packages/cli/src/generated/signals/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export type KeyboardAction =
| { type: 'togglePauseMenu' }
| { type: 'movePauseCursor'; direction: 'up'|'down' }
| { type: 'selectPauseAction' }
| { type: 'planControl'; action: 'approve'|'cancel' }
| { type: 'planControl'; action: 'approve'|'cancel'|'revise' }
| { type: 'movePlanApproval'; index: number }
| { type: 'updateBanner'; action: 'update'|'changelog'|'dismiss' };

Expand Down Expand Up @@ -255,15 +255,25 @@ export function resolveKeyboardInput(ctx: KeyboardCtx): KeyboardAction {
&& !ctx.reviewEventOpen && !ctx.toolDetailOpen
&& !ctx.questionState
) {
const pcur = (ctx.planApprovalIndex ?? 0) === 1 ? 1 : 0;
if (key.upArrow || key.downArrow) return { type: 'movePlanApproval', index: pcur === 0 ? 1 : 0 };
// 3-option plan approval indexed in VISUAL (top-to-bottom) order:
// 0=Approve, 1=Other (revise / type feedback), 2=Reject. Arrow nav follows
// that order so ↓ moves Approve→Other→Reject. (Cesar's original 0=Approve,
// 1=Reject, 2=Other made ↓ from Approve jump to Reject, skipping the middle
// Other slot — the bug the panel review flagged.)
const pcur = Math.max(0, Math.min(2, ctx.planApprovalIndex ?? 0));
const next = pcur === 2 ? 0 : pcur + 1; // 0→1→2→0
const prev = pcur === 0 ? 2 : pcur - 1; // 0→2→1→0
if (key.downArrow) return { type: 'movePlanApproval', index: next };
if (key.upArrow) return { type: 'movePlanApproval', index: prev };
if (key.return || input === '\r' || input === '\n') {
return { type: 'planControl', action: pcur === 1 ? 'cancel' : 'approve' };
const action = pcur === 1 ? 'revise' : pcur === 2 ? 'cancel' : 'approve';
return { type: 'planControl', action };
}
if (typeof input === 'string' && input.length === 1) {
const lowered = input.toLowerCase();
if (lowered === 'y' || lowered === '1') return { type: 'movePlanApproval', index: 0 };
if (lowered === 'n' || lowered === '2') return { type: 'movePlanApproval', index: 1 };
if (lowered === 'o' || lowered === '2') return { type: 'movePlanApproval', index: 1 };
if (lowered === 'n' || lowered === '3') return { type: 'movePlanApproval', index: 2 };
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/generated/surfaces/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1887,6 +1887,17 @@ export function App() {
// into the composer after we route the approval.
ctrlKeyHandledRef.current = true;
setPlanApprovalIndex(0);
if (action.action === 'revise') {
// Drop the proposal from the live pane but leave the plan in
// activePlanRef so /cancel on the next turn is a no-op. Pre-fill
// the composer with a revision prompt; pressing Enter routes the
// message to Cesar, who (per session.kern RULE 9) will see the
// pending plan and ProposePlan again with the user's changes.
setPendingPlanProposal(null);
setInputValue('Revise the plan: ');
dispatch({ type: 'info', message: 'Type your revision and press Enter (Esc clears).' } as any);
return;
}
handleSubmit(action.action === 'approve' ? '/approve' : '/cancel');
return;
case 'movePlanApproval':
Expand Down Expand Up @@ -2921,7 +2932,7 @@ export const _lastSigintAt: { value: number } = { value: 0 };
// @kern-source: app:93
export const _pauseState: { value: PauseState | null } = { value: null };

// @kern-source: app:2656
// @kern-source: app:2667
export async function startRepl(): Promise<void> {
ensureAgonHome();
ensureCurrentWorkspace(process.cwd());
Expand Down
Loading