@twistezo/ai-review-council 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ai-review-council
2
2
 
3
- Runs independent Codex and Claude Code reviewers on a code change. A council agent verifies their findings and prints one Markdown report, also copied to the clipboard.
3
+ Runs independent Codex and Claude Code reviewers on a code change. A council agent verifies their findings and writes one Markdown report, previewed in the terminal, ready to copy or open.
4
4
 
5
5
  Changes: [CHANGELOG.md](CHANGELOG.md). Internals: [ARCHITECTURE.md](ARCHITECTURE.md).
6
6
 
@@ -39,13 +39,14 @@ The review covers the branch checked out; switch branches before starting.
39
39
  | `Ctrl+D` | confirm the prompt |
40
40
  | `Ctrl+V` | paste into the prompt |
41
41
  | `Esc` | back |
42
+ | `c` / `o` / `q` | after the review: copy the report / open it in the default `.md` app / quit |
42
43
 
43
44
  | Exit code | Meaning |
44
45
  |---|---|
45
- | `0` | Report printed; failed reviewers named on stderr |
46
+ | `0` | Report shown, then `q`, `Esc` or `Ctrl+C`; failed reviewers named on stderr |
46
47
  | `1` | Requirements not met, or no verified report |
47
48
  | `2` | Invalid options; usage printed on stderr |
48
- | `130` / `129` / `143` | `Ctrl+C` / terminal closed / `kill`; agents stop and temp files go, confirmed by a message where the terminal remains |
49
+ | `130` / `129` / `143` | `Ctrl+C` before the report / terminal closed / `kill`; agents stop and temp files go, confirmed by a message where the terminal remains |
49
50
 
50
51
  ## Agents
51
52
 
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { createClipboardReader } from './clipboard/read-clipboard.js';
3
+ import { writeClipboard } from './clipboard/write-clipboard.js';
3
4
  import { runAgent } from './execution/run-agent.js';
4
5
  import { createGitRepository } from './git/git-repository.js';
5
6
  import { findMissingInstructionFiles, instructionsDirectory, readInstructions } from './instructions/instruction-files.js';
@@ -7,6 +8,7 @@ import { startJsonLineProcess } from './process/json-line-process.js';
7
8
  import { runCommand } from './process/run-command.js';
8
9
  import { createClaudeAdapter } from './providers/claude-adapter.js';
9
10
  import { createCodexAdapter } from './providers/codex-adapter.js';
11
+ import { createReportOpener } from './review/open-report.js';
10
12
  import { createWorkspacePreparer } from './review/review-workspace.js';
11
13
  // The real services: local CLIs, Git, the shipped instructions and the macOS clipboard.
12
14
  export const createAppServices = (cwd, appInfo) => {
@@ -19,6 +21,8 @@ export const createAppServices = (cwd, appInfo) => {
19
21
  ],
20
22
  findMissingInstructionFiles: () => findMissingInstructionFiles(instructionsDirectory),
21
23
  readClipboard: createClipboardReader(runCommand),
24
+ writeClipboard,
25
+ openReport: createReportOpener(runCommand),
22
26
  review: {
23
27
  runAgent,
24
28
  prepareWorkspace: createWorkspacePreparer(git),
package/dist/cli.js CHANGED
@@ -36,6 +36,6 @@ const main = async () => {
36
36
  console.log(`\n${message}\n`);
37
37
  }
38
38
  }
39
- process.exitCode = await printOutcome(result.outcome);
39
+ process.exitCode = printOutcome(result.outcome);
40
40
  };
41
41
  await main();
@@ -30,8 +30,23 @@ const claudeEventSchema = z.discriminatedUnion('type', [
30
30
  subtype: z.string(),
31
31
  is_error: z.boolean().optional(),
32
32
  result: z.string().optional(),
33
+ usage: z
34
+ .object({
35
+ input_tokens: z.number().optional(),
36
+ cache_creation_input_tokens: z.number().optional(),
37
+ cache_read_input_tokens: z.number().optional(),
38
+ output_tokens: z.number().optional(),
39
+ })
40
+ .optional(),
33
41
  }),
34
42
  ]);
43
+ const sumTokens = (counts) => {
44
+ let total = 0;
45
+ for (const count of counts) {
46
+ total += count ?? 0;
47
+ }
48
+ return total;
49
+ };
35
50
  // Claude Code `--output-format stream-json`.
36
51
  export const parseClaudeLine = (line) => {
37
52
  const parsed = claudeEventSchema.safeParse(parseJson(line));
@@ -54,10 +69,27 @@ export const parseClaudeLine = (line) => {
54
69
  : [];
55
70
  });
56
71
  }
72
+ // Claude Code counts cache writes and reads apart from fresh input.
73
+ const usage = event.usage === undefined
74
+ ? []
75
+ : [
76
+ {
77
+ kind: 'usage',
78
+ tokens: sumTokens([
79
+ event.usage.input_tokens,
80
+ event.usage.cache_creation_input_tokens,
81
+ event.usage.cache_read_input_tokens,
82
+ event.usage.output_tokens,
83
+ ]),
84
+ },
85
+ ];
57
86
  if (event.subtype === 'success' && event.is_error !== true) {
58
- return [{ kind: 'completed', text: event.result ?? null }];
87
+ return [...usage, { kind: 'completed', text: event.result ?? null }];
59
88
  }
60
- return [{ kind: 'error', message: `Claude Code ended with ${event.subtype}${event.result ? `: ${event.result}` : ''}` }];
89
+ return [
90
+ ...usage,
91
+ { kind: 'error', message: `Claude Code ended with ${event.subtype}${event.result ? `: ${event.result}` : ''}` },
92
+ ];
61
93
  };
62
94
  const codexEventSchema = z.discriminatedUnion('type', [
63
95
  z.object({ type: z.literal('thread.started'), thread_id: z.string() }),
@@ -69,7 +101,10 @@ const codexEventSchema = z.discriminatedUnion('type', [
69
101
  type: z.literal('item.completed'),
70
102
  item: z.object({ type: z.string(), text: z.string().optional() }),
71
103
  }),
72
- z.object({ type: z.literal('turn.completed') }),
104
+ z.object({
105
+ type: z.literal('turn.completed'),
106
+ usage: z.object({ input_tokens: z.number().optional(), output_tokens: z.number().optional() }).optional(),
107
+ }),
73
108
  z.object({ type: z.literal('turn.failed'), error: z.object({ message: z.string() }) }),
74
109
  z.object({ type: z.literal('error'), message: z.string() }),
75
110
  ]);
@@ -89,8 +124,14 @@ export const parseCodexLine = (line) => {
89
124
  return event.item.type === 'agent_message' && event.item.text !== undefined && event.item.text.trim() !== ''
90
125
  ? [{ kind: 'message', text: event.item.text }]
91
126
  : [];
127
+ // Codex counts cached input inside `input_tokens`.
92
128
  case 'turn.completed':
93
- return [{ kind: 'completed', text: null }];
129
+ return [
130
+ ...(event.usage === undefined
131
+ ? []
132
+ : [{ kind: 'usage', tokens: sumTokens([event.usage.input_tokens, event.usage.output_tokens]) }]),
133
+ { kind: 'completed', text: null },
134
+ ];
94
135
  case 'turn.failed':
95
136
  return [{ kind: 'error', message: `Codex turn failed: ${event.error.message}` }];
96
137
  case 'error':
@@ -2,6 +2,7 @@ export const waitingProgress = {
2
2
  phase: 'waiting',
3
3
  reads: 0,
4
4
  sessionId: null,
5
+ tokens: null,
5
6
  startedAt: null,
6
7
  finishedAt: null,
7
8
  note: null,
@@ -11,6 +12,8 @@ export const applyAgentEvent = (progress, event) => {
11
12
  switch (event.kind) {
12
13
  case 'session':
13
14
  return { ...progress, sessionId: event.sessionId };
15
+ case 'usage':
16
+ return { ...progress, tokens: (progress.tokens ?? 0) + event.tokens };
14
17
  case 'read':
15
18
  return { ...progress, phase: 'reading', reads: progress.reads + 1 };
16
19
  case 'message':
@@ -1,27 +1,13 @@
1
- import { writeClipboard } from './clipboard/write-clipboard.js';
2
- import { formatReport } from './review/format-report.js';
3
1
  import { describeReviewResult } from './review/review-output.js';
4
- const defaultColumns = 80;
5
- // A full-width line between the progress table and the report.
6
- const separator = () => '─'.repeat(Math.max(1, (process.stdout.columns ?? defaultColumns) - 1));
7
- // The same formatted text is shown and copied.
8
- const printReport = async (rawReport) => {
9
- const report = await formatReport(rawReport);
10
- console.log(`\n${separator()}\n\n${report}\n\n${separator()}`);
11
- const isCopied = await writeClipboard(report);
12
- // the trailing empty line keeps the shell prompt apart from the message
13
- console.log(`${isCopied ? '✔ Report copied to the clipboard.' : '✘ Could not copy the report to the clipboard.'}\n`);
14
- };
15
- // Prints what the run produced and returns the exit code.
16
- export const printOutcome = async (outcome) => {
2
+ // Prints what the run left to say after the app closed and returns the exit code.
3
+ // The report itself stays in the app's last frame as a preview, copied with `c`.
4
+ export const printOutcome = (outcome) => {
17
5
  switch (outcome.kind) {
18
6
  case 'finished': {
19
- const { stdout, stderr, exitCode } = describeReviewResult(outcome.result);
7
+ const { stderr, exitCode } = describeReviewResult(outcome.result);
20
8
  if (stderr !== null) {
21
- console.error(`\n${stderr}`);
22
- }
23
- if (stdout !== null) {
24
- await printReport(stdout);
9
+ // the trailing empty line keeps the shell prompt apart from the message
10
+ console.error(`\n${stderr}\n`);
25
11
  }
26
12
  return exitCode;
27
13
  }
@@ -18,12 +18,16 @@ const initializeResponseSchema = z.object({
18
18
  ]),
19
19
  });
20
20
  const initializeRequestId = 'ai-review-council-catalog';
21
+ const defaultModelAlias = 'default';
21
22
  export const parseClaudeInitializeResponse = (message) => {
22
23
  const { response } = initializeResponseSchema.parse(message);
23
24
  if (response.subtype === 'error') {
24
25
  throw new Error(`Claude Code initialize failed: ${response.error}`);
25
26
  }
26
- return response.response.models.map((model) => ({
27
+ // `default` only points at a model already listed under its own name, e.g. Opus 5.5; kept when it is all there is.
28
+ const { models } = response.response;
29
+ const namedModels = models.filter((model) => model.value !== defaultModelAlias);
30
+ return (namedModels.length === 0 ? models : namedModels).map((model) => ({
27
31
  id: model.value,
28
32
  label: model.displayName || model.value,
29
33
  efforts: model.supportedEffortLevels ?? [],
@@ -0,0 +1,17 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { isSuccessful } from '../process/run-command.js';
5
+ // Writes the report to a temporary Markdown file and opens it in the macOS default app for `.md`.
6
+ // The file outlives the app, so the viewer keeps it after the review ends.
7
+ export const createReportOpener = (run) => async (report) => {
8
+ try {
9
+ const directory = await mkdtemp(path.join(tmpdir(), 'ai-review-council-report-'));
10
+ const filePath = path.join(directory, 'review.md');
11
+ await writeFile(filePath, `${report}\n`);
12
+ return isSuccessful(await run('open', [filePath])) ? filePath : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ };
@@ -0,0 +1,26 @@
1
+ import { proseLines } from './run-review.js';
2
+ const headingText = (line, marker) => line.slice(marker.length).trim();
3
+ // Built only from what every report keeps: the `# ` title, its first paragraph and the `### ` finding headings.
4
+ // Section names and severity fields follow the report language and wording, so they are left out.
5
+ export const previewReport = (report) => {
6
+ const lines = proseLines(report);
7
+ const titleIndex = lines.findIndex(({ line }) => line.startsWith('# '));
8
+ const title = titleIndex === -1 ? null : headingText(lines[titleIndex]?.line ?? '', '# ');
9
+ const afterTitle = lines.slice(titleIndex + 1);
10
+ const firstFinding = afterTitle.findIndex(({ line }) => line.startsWith('### '));
11
+ const beforeFindings = firstFinding === -1 ? afterTitle : afterTitle.slice(0, firstFinding);
12
+ const paragraphStart = beforeFindings.findIndex(({ line }) => line.trim() !== '' && !line.startsWith('#'));
13
+ const paragraph = [];
14
+ for (const { line } of paragraphStart === -1 ? [] : beforeFindings.slice(paragraphStart)) {
15
+ if (line.trim() === '' || line.startsWith('#')) {
16
+ break;
17
+ }
18
+ paragraph.push(line.trim());
19
+ }
20
+ return {
21
+ title,
22
+ summary: paragraph.length === 0 ? null : paragraph.join(' '),
23
+ findings: lines.filter(({ line }) => line.startsWith('### ')).map(({ line }) => headingText(line, '### ')),
24
+ lineCount: report.split('\n').length,
25
+ };
26
+ };
@@ -13,7 +13,7 @@ const isFence = (line) => {
13
13
  return trimmed.startsWith('```') || trimmed.startsWith('~~~');
14
14
  };
15
15
  // Lines outside fenced code blocks; a `#` in a code sample is not a heading.
16
- const proseLines = (text) => {
16
+ export const proseLines = (text) => {
17
17
  let isInCodeBlock = false;
18
18
  return text
19
19
  .split('\n')
package/dist/ui/app.js CHANGED
@@ -68,6 +68,7 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
68
68
  const { exit } = useApp();
69
69
  const [state, setState] = useState({
70
70
  step: 'preflight',
71
+ isGoingBack: false,
71
72
  skipped: presetSkips(presets),
72
73
  checks: [],
73
74
  models: [],
@@ -85,10 +86,17 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
85
86
  exit();
86
87
  };
87
88
  const complete = (patch) => {
88
- setState((previous) => ({ ...previous, ...patch, step: nextStep(previous.step, previous.skipped) }));
89
+ setState((previous) => ({
90
+ ...previous,
91
+ ...patch,
92
+ step: nextStep(previous.step, previous.skipped),
93
+ isGoingBack: false,
94
+ }));
89
95
  };
90
96
  const previous = previousStep(state.step, state.skipped);
91
- const back = previous === null ? undefined : () => setState((current) => ({ ...current, step: previous }));
97
+ const back = previous === null
98
+ ? undefined
99
+ : () => setState((current) => ({ ...current, step: previous, isGoingBack: true }));
92
100
  // A preset step that was skipped is shown when its value turned out invalid.
93
101
  const isAutoSubmit = state.step === 'project' && state.skipped.includes('project');
94
102
  const renderStep = () => {
@@ -104,7 +112,7 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
104
112
  case 'council':
105
113
  return (_jsx(CouncilStep, { models: state.models, value: state.council, onSubmit: (council) => complete({ council }), onBack: back }));
106
114
  case 'reviewers':
107
- return (_jsx(ReviewersStep, { models: state.models, value: state.reviewers, presetCount: presets.reviewerCount, onSubmit: (reviewers) => complete({ reviewers }), onBack: back }));
115
+ return (_jsx(ReviewersStep, { models: state.models, value: state.reviewers, resumeAtLast: state.isGoingBack, presetCount: presets.reviewerCount, onSubmit: (reviewers) => complete({ reviewers }), onBack: back }));
108
116
  case 'prompt':
109
117
  return (_jsx(PromptStep, { value: state.prompt, readClipboard: services.readClipboard, onSubmit: (prompt) => complete({ prompt }), onBack: back }));
110
118
  case 'summary': {
@@ -126,5 +134,5 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
126
134
  const { columns } = useWindowSize();
127
135
  const steps = numberedSteps(state.skipped);
128
136
  const stepNumber = state.running === null ? steps.indexOf(state.step) + 1 : 0;
129
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.title, children: title }), state.checks.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(RequirementsLine, { checks: state.checks }) })), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(1, Math.min(separatorWidth, columns - screenPadding))) }), _jsx(Box, { marginTop: 1, children: state.running === null ? (_jsx(StepCounterContext.Provider, { value: stepNumber > 0 ? `step ${stepNumber} of ${steps.length}` : null, children: _jsx(StepAnswersContext.Provider, { value: stepFields[state.step] === undefined ? null : (_jsx(AnswersPanel, { answers: answersBefore(state), selecting: stepFields[state.step] ?? null })), children: renderStep() }) })) : (_jsx(RunScreen, { session: state.running, dependencies: services.review, onFinished: finish })) })] }));
137
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.title, children: title }), state.checks.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(RequirementsLine, { checks: state.checks }) })), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(1, Math.min(separatorWidth, columns - screenPadding))) }), _jsx(Box, { marginTop: 1, children: state.running === null ? (_jsx(StepCounterContext.Provider, { value: stepNumber > 0 ? `step ${stepNumber} of ${steps.length}` : null, children: _jsx(StepAnswersContext.Provider, { value: stepFields[state.step] === undefined ? null : (_jsx(AnswersPanel, { answers: answersBefore(state), selecting: stepFields[state.step] ?? null })), children: renderStep() }) })) : (_jsx(RunScreen, { session: state.running, dependencies: services.review, writeClipboard: services.writeClipboard, openReport: services.openReport, onFinished: finish, onReported: onFinish, onQuit: exit })) })] }));
130
138
  };
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useEffect, useState } from 'react';
4
+ import { formatReport } from '../../review/format-report.js';
5
+ import { previewReport } from '../../review/report-preview.js';
6
+ import { theme } from '../theme.js';
7
+ const working = (text) => ({ text, color: theme.muted });
8
+ const succeeded = (text) => ({ text: `✔ ${text}`, color: theme.success });
9
+ const failed = (text) => ({ text: `✘ ${text}`, color: theme.failure });
10
+ // A short preview of the report; the full text is copied or opened on request.
11
+ export const ReportResult = ({ report, writeClipboard, openReport, onQuit }) => {
12
+ const [status, setStatus] = useState(null);
13
+ const [isBusy, setIsBusy] = useState(false);
14
+ const [isQuitting, setIsQuitting] = useState(false);
15
+ const { title, summary, findings, lineCount } = previewReport(report);
16
+ // Quits once the hints are gone, so the frame left behind shows no keys that no longer work.
17
+ useEffect(() => {
18
+ if (isQuitting) {
19
+ onQuit();
20
+ }
21
+ }, [isQuitting]);
22
+ // Copy and open both use the Prettier formatting, so the Markdown stays tidy.
23
+ const runAction = (pending, action) => {
24
+ setIsBusy(true);
25
+ setStatus(working(pending));
26
+ void formatReport(report)
27
+ .then(action)
28
+ .then((result) => {
29
+ setStatus(result);
30
+ setIsBusy(false);
31
+ });
32
+ };
33
+ useInput((input, key) => {
34
+ if (input === 'c' && !isBusy) {
35
+ runAction('Copying…', async (formatted) => (await writeClipboard(formatted))
36
+ ? succeeded('Report copied to the clipboard.')
37
+ : failed('Could not copy the report to the clipboard.'));
38
+ }
39
+ else if (input === 'o' && !isBusy) {
40
+ runAction('Opening…', async (formatted) => {
41
+ const filePath = await openReport(formatted);
42
+ return filePath === null ? failed('Could not open the report.') : succeeded(`Report opened: ${filePath}`);
43
+ });
44
+ }
45
+ else if (input === 'q' || key.escape) {
46
+ setIsQuitting(true);
47
+ }
48
+ }, { isActive: !isQuitting });
49
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.title, children: "Report" }), title !== null && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: title }) })), summary !== null && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: summary }) })), findings.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: findings.map((finding, index) => (_jsxs(Text, { children: ["\u2022 ", finding] }, index))) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2026 ", lineCount, " lines in the full report"] }) })] }), status !== null && _jsx(Text, { color: status.color, children: status.text }), !isQuitting && _jsx(Text, { dimColor: true, children: "c copy \u00B7 o open \u00B7 q quit" })] }));
50
+ };
@@ -18,10 +18,21 @@ export const formatElapsed = ({ startedAt, finishedAt }, now) => {
18
18
  const seconds = totalSeconds % 60;
19
19
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
20
20
  };
21
+ // Every Read, Grep or Glob call, or Codex shell command, so reading and searching alike.
22
+ const countLookups = (count) => (count === 1 ? '1 lookup' : `${count} lookups`);
23
+ // Rounded for a glance, e.g. `850 tokens`, `312k tokens`, `1.2M tokens`.
24
+ const formatTokens = (count) => {
25
+ if (count < 1_000) {
26
+ return `${count} tokens`;
27
+ }
28
+ // 999,600 rounds up to a million, not to `1000k`.
29
+ return count < 999_500 ? `${Math.round(count / 1_000)}k tokens` : `${(count / 1_000_000).toFixed(1)}M tokens`;
30
+ };
31
+ const joinStatus = (parts) => parts.filter((part) => part !== null).join(' · ');
21
32
  const reviewerRows = (rows) => rows.filter((row) => !row.isCouncil);
22
33
  // One short phrase per row: the general step, never individual tool calls.
23
34
  export const describeStatus = (row, rows) => {
24
- const { phase, reads, note } = row.progress;
35
+ const { phase, reads, tokens, note } = row.progress;
25
36
  switch (phase) {
26
37
  case 'waiting': {
27
38
  if (!row.isCouncil) {
@@ -34,13 +45,15 @@ export const describeStatus = (row, rows) => {
34
45
  case 'starting':
35
46
  return 'starting';
36
47
  case 'reading':
37
- return row.isCouncil ? `verifying (${reads} reads)` : `reading code (${reads})`;
48
+ return `${row.isCouncil ? 'verifying' : 'reading code'} · ${countLookups(reads)}`;
38
49
  case 'writing':
39
50
  return 'writing report';
51
+ // A failed run can still cost tokens, so both show what the provider reported. A failure reason is long and
52
+ // the cell cuts its end, so the tokens go before it.
40
53
  case 'done':
41
- return note === null ? 'done' : `done · ${note}`;
54
+ return joinStatus(['done', note, tokens === null ? null : formatTokens(tokens)]);
42
55
  case 'failed':
43
- return `failed · ${note ?? 'unknown error'}`;
56
+ return joinStatus(['failed', tokens === null ? null : formatTokens(tokens), note ?? 'unknown error']);
44
57
  }
45
58
  };
46
59
  const compareText = (left, right) => left.localeCompare(right, 'en', { sensitivity: 'base' });
@@ -23,10 +23,16 @@ const resize = (models, reviewers, count) => {
23
23
  const fresh = choiceToConfiguration(models, choiceFromConfiguration(models, null));
24
24
  return fresh === null ? [...reviewers] : resizeReviewers(reviewers, count, fresh);
25
25
  };
26
- export const ReviewersStep = ({ models, value, presetCount, onSubmit, onBack }) => {
26
+ export const ReviewersStep = ({ models, value, resumeAtLast, presetCount, onSubmit, onBack, }) => {
27
27
  const [reviewers, setReviewers] = useState(() => presetCount === null ? [...value] : resize(models, value, presetCount));
28
28
  // null shows the count question; a number edits that reviewer. `--reviewers` skips the question.
29
- const [editing, setEditing] = useState(presetCount === null ? null : 0);
29
+ // Esc from the prompt opens the last reviewer, with every choice kept.
30
+ const [editing, setEditing] = useState(() => {
31
+ if (resumeAtLast && value.length > 0) {
32
+ return reviewers.length - 1;
33
+ }
34
+ return presetCount === null ? null : 0;
35
+ });
30
36
  if (editing === null) {
31
37
  return (_jsxs(StepFrame, { title: "Reviewers", hints: "\u2191\u2193 row \u00B7 Enter confirm", onBack: onBack, children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "How many reviewers? Each works independently." }), _jsx(Text, { dimColor: true, children: "Mix providers and models to catch more." })] }), _jsx(SelectList, { items: Array.from({ length: maxReviewers }, (_, index) => ({
32
38
  key: String(index + 1),
@@ -5,6 +5,8 @@ import { waitingProgress } from '../../execution/agent-progress.js';
5
5
  import { formatEffort } from '../../providers/model-catalog.js';
6
6
  import { providerDisplayNames } from '../../providers/provider-adapter.js';
7
7
  import { councilAgentId, numberReviewers, reviewerAgentId, runReview } from '../../review/run-review.js';
8
+ import { describeReviewResult } from '../../review/review-output.js';
9
+ import { ReportResult } from '../components/report-result.js';
8
10
  import { RunProgress } from '../components/run-progress.js';
9
11
  const agentColumns = (configuration) => ({
10
12
  provider: providerDisplayNames[configuration.provider],
@@ -28,19 +30,29 @@ const initialRunRows = (reviewers, council) => [
28
30
  },
29
31
  ];
30
32
  // Starts the review once; leaving the screen (Ctrl+C) cancels every running agent.
31
- export const RunScreen = ({ session, dependencies, onFinished }) => {
33
+ export const RunScreen = ({ session, dependencies, writeClipboard, openReport, onFinished, onReported, onQuit, }) => {
32
34
  const [rows, setRows] = useState(() => initialRunRows(numberReviewers(session.reviewers), session.council));
33
35
  const [outcome, setOutcome] = useState(null);
34
- // Reported after the final statuses are on screen, so the frame left behind shows them.
36
+ const report = outcome?.kind === 'finished' ? describeReviewResult(outcome.result).stdout : null;
37
+ // Reported after the final statuses are on screen, so the frame left behind shows them. With a report the app
38
+ // waits for a key, but the outcome is already known: Ctrl+C there quits like `q`, not as a cancelled review.
35
39
  useEffect(() => {
36
- if (outcome !== null) {
40
+ if (outcome === null) {
41
+ return;
42
+ }
43
+ if (report === null) {
37
44
  onFinished(outcome);
38
45
  }
46
+ else {
47
+ onReported(outcome);
48
+ }
39
49
  }, [outcome]);
40
50
  useEffect(() => {
41
51
  const controller = new AbortController();
42
52
  runReview(session, dependencies, (agentId, change) => setRows((previous) => previous.map((row) => (row.id === agentId ? { ...row, progress: change(row.progress) } : row))), controller.signal).then((result) => setOutcome({ kind: 'finished', result }), (error) => setOutcome({ kind: 'failed', error: error instanceof Error ? error.message : String(error) }));
43
53
  return () => controller.abort();
44
54
  }, []);
45
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Reviewing ", session.scope.changedFiles.length, " file(s) \u00B7 Ctrl+C cancels all agents"] }), _jsx(Box, { marginTop: 1, children: _jsx(RunProgress, { rows: rows }) })] }));
55
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: outcome === null
56
+ ? `Reviewing ${session.scope.changedFiles.length} file(s) · Ctrl+C cancels all agents`
57
+ : `Reviewed ${session.scope.changedFiles.length} file(s)` }), _jsx(Box, { marginTop: 1, children: _jsx(RunProgress, { rows: rows }) }), outcome !== null && report !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ReportResult, { report: report, writeClipboard: writeClipboard, openReport: openReport, onQuit: onQuit }) }))] }));
46
58
  };
@@ -23,7 +23,7 @@ export const SummaryStep = ({ session, onAction }) => {
23
23
  const { repository, reviewers } = session;
24
24
  const [firstPromptLine = '', ...otherPromptLines] = summarizePrompt(session.prompt);
25
25
  return (_jsxs(StepFrame, { title: "Summary", hints: "\u2191\u2193 row \u00B7 Enter confirm", children: [_jsx(Field, { label: "Language", children: reportLanguageNames[session.language] }), _jsx(Field, { label: "Project", children: repository.root }), _jsx(Field, { label: "Branch", children: repository.branch ?? 'detached HEAD' }), _jsx(Field, { label: "Working tree", children: repository.hasChanges ? (_jsx(Text, { color: theme.warning, children: "currently has uncommitted changes" })) : (_jsx(Text, { color: theme.success, children: "currently clean" })) }), _jsx(Field, { label: "Scope", children: describeScope(session.scope) }), _jsx(Field, { label: "Council", children: formatConfiguration(session.council) }), _jsx(Field, { label: "Reviewers", children: String(reviewers.length) }), _jsx(Box, { paddingLeft: labelWidth, children: _jsx(ReviewerList, { reviewers: reviewers }) }), _jsx(Field, { label: "Prompt", children: session.prompt === '' ? _jsx(Text, { dimColor: true, children: "none, review of the code alone" }) : firstPromptLine }), otherPromptLines.map((line, index) => (_jsx(Box, { paddingLeft: labelWidth, children: _jsx(Text, { children: line }) }, index))), _jsxs(Field, { label: "Timeout", children: [session.timeoutMinutes, " min per agent"] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectList, { items: [
26
- { key: 'start', label: 'Start review' },
26
+ { key: 'start', label: 'Start' },
27
27
  { key: 'back', label: 'Back' },
28
28
  { key: 'cancel', label: 'Cancel' },
29
29
  ], onSelect: (key) => onAction(key === 'start' || key === 'back' ? key : 'cancel') }) })] }));
@@ -5,4 +5,16 @@ You verify independent reviews against the supplied change and produce one trust
5
5
  - Verify every claim, merge duplicates and resolve contradictions. Separate confirmed findings, rejected claims with reasons and unresolved questions with missing evidence.
6
6
  - Preserve evidence, repository-relative locations, attribution, smallest fixes and coverage gaps. Include severity and confidence; prioritize by impact. Mark incidental new findings as council.
7
7
  - Return only a self-contained Markdown report in the session language. State when no issues were confirmed.
8
- - Start with a level-1 title, without an enclosing code fence. Use level-3 headings only for confirmed findings; the application counts them.
8
+ - Use level-3 headings only for confirmed findings, most severe first; the application counts them and lists them in its preview.
9
+
10
+ ## Report opening
11
+
12
+ The application previews the title, the first paragraph and the finding headings. Open the report like this, translated, without an enclosing code fence:
13
+
14
+ ```markdown
15
+ # <one-line verdict>
16
+
17
+ <n> confirmed: <n> critical, <n> high, <n> medium, <n> low. <What to fix first, in one sentence.>
18
+
19
+ ### <severity> · <finding title>
20
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twistezo/ai-review-council",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Terminal app that runs independent Codex and Claude Code reviewers and merges their results",
5
5
  "license": "MIT",
6
6
  "keywords": [