@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 +4 -3
- package/dist/app-services.js +4 -0
- package/dist/cli.js +1 -1
- package/dist/execution/agent-events.js +45 -4
- package/dist/execution/agent-progress.js +3 -0
- package/dist/print-outcome.js +6 -20
- package/dist/providers/claude-adapter.js +5 -1
- package/dist/review/open-report.js +17 -0
- package/dist/review/report-preview.js +26 -0
- package/dist/review/run-review.js +1 -1
- package/dist/ui/app.js +12 -4
- package/dist/ui/components/report-result.js +50 -0
- package/dist/ui/run-progress.js +17 -4
- package/dist/ui/steps/reviewers-step.js +8 -2
- package/dist/ui/steps/run-screen.js +16 -4
- package/dist/ui/steps/summary-step.js +1 -1
- package/docs/council/council.md +13 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
package/dist/app-services.js
CHANGED
|
@@ -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
|
@@ -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 [
|
|
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({
|
|
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 [
|
|
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':
|
package/dist/print-outcome.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
//
|
|
6
|
-
const
|
|
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 {
|
|
7
|
+
const { stderr, exitCode } = describeReviewResult(outcome.result);
|
|
20
8
|
if (stderr !== null) {
|
|
21
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
|
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
|
+
};
|
package/dist/ui/run-progress.js
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
54
|
+
return joinStatus(['done', note, tokens === null ? null : formatTokens(tokens)]);
|
|
42
55
|
case 'failed':
|
|
43
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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
|
|
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') }) })] }));
|
package/docs/council/council.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
+
```
|