@twistezo/ai-review-council 2.1.0 → 3.0.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
@@ -11,7 +11,7 @@ Docs:
11
11
  - [CHANGELOG.md](CHANGELOG.md)
12
12
  - [ARCHITECTURE.md](ARCHITECTURE.md)
13
13
 
14
- ## Supported agents
14
+ ## Supported Agents
15
15
 
16
16
  | Agent | CLI | Reviewer | Council | Models | Effort | Plan |
17
17
  | ------- | ------------------------ | :------: | :-----: | ------ | ------- | ----------------------------------- |
@@ -38,17 +38,16 @@ CLIs must be available on `PATH` and logged in. Usage and availability depend on
38
38
  npm install -g @twistezo/ai-review-council
39
39
 
40
40
  # run with optional args
41
- ai-review-council --dir ~/projects/my-app --lang pl --scope codebase --reviewers 2
41
+ ai-review-council --dir ~/projects/my-app --lang pl --scope codebase
42
42
 
43
43
  ```
44
44
 
45
- | Option | Value | Default |
46
- | ----------------- | -------------------------------------- | ------- |
47
- | `-d, --dir` | folder inside a Git repository | - |
48
- | `-l, --lang` | `en`, `pl` | `en` |
49
- | `-s, --scope` | `uncommitted`, `codebase`, commit hash | - |
50
- | `-r, --reviewers` | 1 to 8 | - |
51
- | `-t, --timeout` | minutes per agent, 1 to 30 | `10` |
45
+ | Option | Value | Default |
46
+ | --------------- | -------------------------------------- | ------- |
47
+ | `-d, --dir` | folder inside a Git repository | - |
48
+ | `-l, --lang` | `en`, `pl` | `en` |
49
+ | `-s, --scope` | `uncommitted`, `codebase`, commit hash | - |
50
+ | `-t, --timeout` | minutes per agent, 1 to 30 | `10` |
52
51
 
53
52
  ## Develop
54
53
 
@@ -59,3 +58,9 @@ npm test
59
58
  npm run typecheck
60
59
  npm run build # dist/
61
60
  ```
61
+
62
+ ## Dev-Docs
63
+
64
+ [`dev-docs/`](dev-docs) is the domain memory for agents and people. Written in the [Matt Pocock skills](https://github.com/mattpocock/skills) style:
65
+
66
+ - [`dev-docs/GLOSSARY.md`](dev-docs/GLOSSARY.md): Generated and kept up to date with the `domain-modeling` skill.
@@ -1,7 +1,6 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { z } from 'zod';
3
2
  import { isCommitHashFormat } from './git/git-repository.js';
4
- import { defaultTimeoutMinutes, maxReviewers, maxTimeoutMinutes, reportLanguageSchema, reportLanguages, timeoutMinutesSchema, } from './domain/review-session.js';
3
+ import { defaultTimeoutMinutes, maxTimeoutMinutes, reportLanguageSchema, reportLanguages, timeoutMinutesSchema, } from './domain/review-session.js';
5
4
  export const usage = `Usage: ai-review-council [options]
6
5
 
7
6
  Options:
@@ -9,7 +8,6 @@ Options:
9
8
  -l, --lang <code> Report language: ${reportLanguages.join(', ')}; skips the language step
10
9
  -s, --scope <value> uncommitted, codebase for the entire codebase, or a commit hash to review the changes after it;
11
10
  skips the scope step
12
- -r, --reviewers <n> Number of reviewers, 1 to ${maxReviewers}; skips the count question
13
11
  -t, --timeout <min> Minutes each agent may run before it fails (default ${defaultTimeoutMinutes}, max ${maxTimeoutMinutes})
14
12
  -h, --help Show this help`;
15
13
  const readRawOptions = (args) => parseArgs({
@@ -18,7 +16,6 @@ const readRawOptions = (args) => parseArgs({
18
16
  dir: { type: 'string', short: 'd' },
19
17
  lang: { type: 'string', short: 'l' },
20
18
  scope: { type: 'string', short: 's' },
21
- reviewers: { type: 'string', short: 'r' },
22
19
  timeout: { type: 'string', short: 't' },
23
20
  help: { type: 'boolean', short: 'h' },
24
21
  },
@@ -27,7 +24,6 @@ const readRawOptions = (args) => parseArgs({
27
24
  }).values;
28
25
  const uncommittedScope = 'uncommitted';
29
26
  const codebaseScope = 'codebase';
30
- const reviewerCountSchema = z.number().int().min(1).max(maxReviewers);
31
27
  const parseScope = (value) => {
32
28
  if (value === undefined) {
33
29
  return null;
@@ -60,10 +56,6 @@ export const parseCliOptions = (args) => {
60
56
  if (scope === 'invalid') {
61
57
  return { ok: false, error: 'The --scope option needs `uncommitted`, `codebase` or a commit hash (4 to 40 hexadecimal characters).' };
62
58
  }
63
- const reviewerCount = values.reviewers === undefined ? null : reviewerCountSchema.safeParse(Number(values.reviewers));
64
- if (reviewerCount !== null && !reviewerCount.success) {
65
- return { ok: false, error: `The --reviewers option needs a whole number from 1 to ${maxReviewers}.` };
66
- }
67
59
  const timeout = timeoutMinutesSchema.safeParse(values.timeout === undefined ? defaultTimeoutMinutes : Number(values.timeout));
68
60
  if (!timeout.success) {
69
61
  return { ok: false, error: `The --timeout option needs whole minutes from 1 to ${maxTimeoutMinutes}.` };
@@ -74,7 +66,6 @@ export const parseCliOptions = (args) => {
74
66
  directory: values.dir ?? null,
75
67
  language: language === null ? null : language.data,
76
68
  scope,
77
- reviewerCount: reviewerCount === null ? null : reviewerCount.data,
78
69
  timeoutMinutes: timeout.data,
79
70
  showHelp: values.help ?? false,
80
71
  },
package/dist/cli.js CHANGED
@@ -25,7 +25,6 @@ const main = async () => {
25
25
  language: parsed.options.language,
26
26
  directory: parsed.options.directory,
27
27
  scope: parsed.options.scope,
28
- reviewerCount: parsed.options.reviewerCount,
29
28
  }, timeoutMinutes: parsed.options.timeoutMinutes, onFinish: (outcome) => {
30
29
  result.outcome = outcome;
31
30
  } }));
@@ -23,7 +23,7 @@ const agentConfigurationSchema = z
23
23
  })
24
24
  .refine((configuration) => configuration.provider !== 'copilot' ||
25
25
  (configuration.model === copilotModelId && configuration.effort === null), { message: 'Copilot supports only Auto without reasoning effort.' });
26
- export const maxReviewers = 8;
26
+ export const maxReviewers = 10;
27
27
  const reviewerAssignmentSchema = z.object({
28
28
  configuration: agentConfigurationSchema,
29
29
  reviewType: reviewTypeSchema,
@@ -173,7 +173,7 @@ export const parseCopilotLine = (line) => {
173
173
  ? { kind: 'completed', text: null }
174
174
  : {
175
175
  kind: 'error',
176
- message: `Copilot exited with code ${exitCode ?? 'unknown'}. Check Copilot login and usage limits.`,
176
+ message: `Copilot exited with code ${exitCode ?? 'unknown'}. Check Copilot usage limits.`,
177
177
  },
178
178
  ];
179
179
  default:
@@ -8,7 +8,7 @@ const checkGit = async (git) => {
8
8
  label: 'Git',
9
9
  state: 'failed',
10
10
  detail: '`git` was not found in PATH. Git is required.',
11
- fix: 'Install Git (for example `xcode-select --install`), then run this app again.',
11
+ fix: null,
12
12
  }
13
13
  : { label: 'Git', state: 'ok', detail: version, fix: null };
14
14
  };
@@ -38,12 +38,11 @@ const checkProvider = async (adapter) => {
38
38
  if (models.length === 0) {
39
39
  return unavailable('The CLI returned no models.', 'Check your plan and CLI configuration.');
40
40
  }
41
- const loginNote = status.loginChecked ? '' : '; login checked when the review starts';
42
41
  return {
43
42
  check: {
44
43
  label: adapter.displayName,
45
44
  state: 'ok',
46
- detail: `${status.version ?? ''}, ${models.length} models${loginNote}`,
45
+ detail: `${status.version ?? ''}, ${models.length} models`,
47
46
  fix: null,
48
47
  },
49
48
  models,
@@ -65,7 +64,7 @@ export const runPreflight = async ({ git, adapters, findMissingInstructionFiles,
65
64
  instructionsCheck.state === 'failed' ? 'Instruction files are missing.' : null,
66
65
  models.some((model) => isCouncilProvider(model.provider))
67
66
  ? null
68
- : 'At least one of Codex or Claude must be installed and logged in.',
67
+ : 'At least one of Codex or Claude must be available.',
69
68
  ].filter((blocker) => blocker !== null);
70
69
  return {
71
70
  checks: [gitCheck, ...providerChecks.map((result) => result.check), instructionsCheck],
@@ -9,7 +9,6 @@ export const checkCliStatus = async (run, check) => {
9
9
  version: null,
10
10
  problem: `\`${check.command}\` was not found in PATH.`,
11
11
  fix: check.installFix,
12
- loginChecked: false,
13
12
  };
14
13
  }
15
14
  if (!isSuccessful(versionResult)) {
@@ -18,16 +17,15 @@ export const checkCliStatus = async (run, check) => {
18
17
  version: null,
19
18
  problem: `\`${check.command} ${versionArgs.join(' ')}\` failed: ${versionResult.stderr.trim()}`,
20
19
  fix: check.installFix,
21
- loginChecked: false,
22
20
  };
23
21
  }
24
22
  const version = versionResult.stdout.trim();
25
23
  if (check.loginStatusArgs === null) {
26
- return { provider: check.provider, version, problem: null, fix: null, loginChecked: false };
24
+ return { provider: check.provider, version, problem: null, fix: null };
27
25
  }
28
26
  const loginResult = await run(check.command, check.loginStatusArgs);
29
27
  if (!isSuccessful(loginResult)) {
30
- return { provider: check.provider, version, problem: 'Not logged in.', fix: check.loginFix, loginChecked: true };
28
+ return { provider: check.provider, version, problem: 'Provider is unavailable.', fix: null };
31
29
  }
32
- return { provider: check.provider, version, problem: null, fix: null, loginChecked: true };
30
+ return { provider: check.provider, version, problem: null, fix: null };
33
31
  };
@@ -51,7 +51,6 @@ export const createClaudeAdapter = (run, startProcess) => ({
51
51
  command: 'claude',
52
52
  loginStatusArgs: ['auth', 'status'],
53
53
  installFix: 'Install Claude Code and make sure `claude` is on PATH, then run this app again.',
54
- loginFix: 'Run `claude` in a terminal and sign in with `/login`, then run this app again.',
55
54
  }),
56
55
  // Catalog from the initialize control response; no user message is sent, so no model is called.
57
56
  // Runs in the temp directory so project settings of the current folder are not loaded.
@@ -55,7 +55,6 @@ export const createCodexAdapter = (run, startProcess, client) => ({
55
55
  command: 'codex',
56
56
  loginStatusArgs: ['login', 'status'],
57
57
  installFix: 'Install the Codex CLI and make sure `codex` is on PATH, then run this app again.',
58
- loginFix: 'Run `codex login` in a terminal, then run this app again.',
59
58
  }),
60
59
  // Catalog over the local App Server protocol; no prompt is sent to a model.
61
60
  listModels: async () => {
@@ -19,7 +19,6 @@ export const createCopilotAdapter = (run) => {
19
19
  versionArgs,
20
20
  loginStatusArgs: null,
21
21
  installFix: 'Install Copilot CLI or GitHub CLI with `gh copilot` support, then run this app again.',
22
- loginFix: command === 'gh' ? 'Run `gh copilot -- login`.' : 'Run `copilot login`.',
23
22
  });
24
23
  },
25
24
  // Free can use Auto even when the named-model catalog is empty.
@@ -33,5 +33,8 @@ export const choiceToConfiguration = (models, choice) => {
33
33
  const model = models[choice.modelIndex];
34
34
  return model === undefined ? null : toConfiguration(model, choice.effort);
35
35
  };
36
- // Keeps existing reviewers; new ones start from the fallback and cycle through the review types.
37
- export const resizeReviewers = (reviewers, count, fallback) => Array.from({ length: count }, (_, index) => reviewers[index] ?? { configuration: fallback, reviewType: reviewTypes[index % reviewTypes.length] ?? 'code' });
36
+ // The reviewer at a zero-based position cycles through the review types.
37
+ export const newReviewer = (configuration, position) => ({
38
+ configuration,
39
+ reviewType: reviewTypes[position % reviewTypes.length] ?? 'code',
40
+ });
package/dist/ui/app.js CHANGED
@@ -28,7 +28,7 @@ const buildSessionDraft = (state, timeoutMinutes) => {
28
28
  },
29
29
  scope: state.scope,
30
30
  council: state.council,
31
- reviewers: state.reviewers,
31
+ reviewers: state.reviewers.accepted,
32
32
  prompt: state.prompt,
33
33
  timeoutMinutes,
34
34
  });
@@ -51,7 +51,7 @@ const answersBefore = (state) => {
51
51
  repository: isBefore('project') ? state.repository : null,
52
52
  scope: isBefore('scope') ? state.scope : null,
53
53
  council: isBefore('council') ? state.council : null,
54
- reviewers: isBefore('reviewers') ? state.reviewers : null,
54
+ reviewers: isBefore('reviewers') ? state.reviewers.accepted : null,
55
55
  prompt: isBefore('prompt') ? state.prompt : null,
56
56
  };
57
57
  };
@@ -64,7 +64,6 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
64
64
  const { exit } = useApp();
65
65
  const [state, setState] = useState({
66
66
  step: 'preflight',
67
- isGoingBack: false,
68
67
  skipped: presetSkips(presets),
69
68
  checks: [],
70
69
  models: [],
@@ -73,7 +72,7 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
73
72
  repository: null,
74
73
  scope: null,
75
74
  council: null,
76
- reviewers: [],
75
+ reviewers: { accepted: [], draft: null },
77
76
  prompt: '',
78
77
  running: null,
79
78
  });
@@ -86,13 +85,12 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
86
85
  ...previous,
87
86
  ...patch,
88
87
  step: nextStep(previous.step, previous.skipped),
89
- isGoingBack: false,
90
88
  }));
91
89
  };
92
90
  const previous = previousStep(state.step, state.skipped);
93
91
  const back = previous === null
94
92
  ? undefined
95
- : () => setState((current) => ({ ...current, step: previous, isGoingBack: true }));
93
+ : () => setState((current) => ({ ...current, step: previous }));
96
94
  // A preset step that was skipped is shown when its value turned out invalid.
97
95
  const isAutoSubmit = state.step === 'project' && state.skipped.includes('project');
98
96
  const renderStep = () => {
@@ -108,7 +106,7 @@ export const App = ({ title, services, presets, timeoutMinutes, onFinish }) => {
108
106
  case 'council':
109
107
  return (_jsx(CouncilStep, { models: state.models.filter((model) => isCouncilProvider(model.provider)), value: state.council, onSubmit: (council) => complete({ council }), onBack: back }));
110
108
  case 'reviewers':
111
- return (_jsx(ReviewersStep, { models: state.models, value: state.reviewers, resumeAtLast: state.isGoingBack, presetCount: presets.reviewerCount, onSubmit: (reviewers) => complete({ reviewers }), onBack: back }));
109
+ return (_jsx(ReviewersStep, { models: state.models, value: state.reviewers, onChange: (reviewers) => setState((current) => ({ ...current, reviewers })), onSubmit: (accepted) => complete({ reviewers: { accepted, draft: null } }), onBack: back }));
112
110
  case 'prompt':
113
111
  return (_jsx(PromptStep, { value: state.prompt, readClipboard: services.readClipboard, onSubmit: (prompt) => complete({ prompt }), onBack: back }));
114
112
  case 'summary': {
@@ -1,4 +1,5 @@
1
- const describeRange = (scope) => {
1
+ export const fileNoun = (count) => (count === 1 ? 'file' : 'files');
2
+ export const describeScopeRange = (scope) => {
2
3
  const base = scope.base.hash.slice(0, 7);
3
4
  switch (scope.kind) {
4
5
  case 'uncommitted':
@@ -10,6 +11,8 @@ const describeRange = (scope) => {
10
11
  }
11
12
  };
12
13
  export const describeScope = (scope) => {
13
- const excluded = scope.excludedFiles.length === 0 ? '' : `, ${scope.excludedFiles.length} likely secret file(s) excluded`;
14
- return `${describeRange(scope)} (${scope.changedFiles.length} files${excluded})`;
14
+ const changedCount = scope.changedFiles.length;
15
+ const excludedCount = scope.excludedFiles.length;
16
+ const excluded = excludedCount === 0 ? '' : `, ${excludedCount} likely secret ${fileNoun(excludedCount)} excluded`;
17
+ return `${describeScopeRange(scope)} (${changedCount} ${fileNoun(changedCount)}${excluded})`;
15
18
  };
@@ -5,5 +5,5 @@ import { MultilineInput } from '../components/multiline-input.js';
5
5
  import { StepFrame } from '../components/step-frame.js';
6
6
  export const PromptStep = ({ value, readClipboard, onSubmit, onBack }) => {
7
7
  const [prompt, setPrompt] = useState(value);
8
- return (_jsxs(StepFrame, { title: "Review prompt", hints: "Enter new line \u00B7 Ctrl+D confirm \u00B7 Cmd+V paste", onBack: onBack, children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Review context, goals and skills. Shared by all agents." }), _jsx(Text, { dimColor: true, children: "Read-only local review. No live web access." })] }), _jsx(MultilineInput, { value: prompt, onChange: setPrompt, onSubmit: () => onSubmit(prompt.trim()), readClipboard: readClipboard, placeholder: "Type or paste (Ctrl+V) the review prompt" })] }));
8
+ return (_jsxs(StepFrame, { title: "Review prompt", hints: "Enter newline \u00B7 Ctrl+D confirm \u00B7 Cmd+V paste", onBack: onBack, children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Review context, goals and skills. Shared by all agents." }), _jsx(Text, { dimColor: true, children: "Read-only local review. No live web access." })] }), _jsx(MultilineInput, { value: prompt, onChange: setPrompt, onSubmit: () => onSubmit(prompt.trim()), readClipboard: readClipboard, placeholder: "Type or paste (Ctrl+V) the review prompt" })] }));
9
9
  };
@@ -1,11 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
2
+ import { Box, Text, useInput } from 'ink';
3
3
  import { useState } from 'react';
4
4
  import { maxReviewers } from '../../domain/review-session.js';
5
- import { choiceFromConfiguration, choiceToConfiguration, resizeReviewers } from '../agent-choice.js';
5
+ import { choiceFromConfiguration, choiceToConfiguration, newReviewer } from '../agent-choice.js';
6
6
  import { AgentPicker } from '../components/agent-picker.js';
7
7
  import { ReviewerList } from '../components/reviewer-list.js';
8
- import { SelectList } from '../components/select-list.js';
9
8
  import { StepFrame } from '../components/step-frame.js';
10
9
  import { theme } from '../theme.js';
11
10
  // Keyed by reviewer index so the column focus resets for each reviewer.
@@ -16,52 +15,45 @@ const ReviewerEditor = ({ models, reviewer, onChange, onSubmit }) => {
16
15
  if (configuration !== null) {
17
16
  onChange({ ...reviewer, configuration });
18
17
  }
19
- }, reviewType: { value: reviewer.reviewType, onChange: (reviewType) => onChange({ ...reviewer, reviewType }) }, onSubmit: onSubmit }));
18
+ }, reviewType: {
19
+ value: reviewer.reviewType,
20
+ onChange: (reviewType) => onChange({ ...reviewer, reviewType }),
21
+ }, onSubmit: onSubmit }));
20
22
  };
21
23
  // A new reviewer starts from a clean choice: the first model with its default effort.
22
- const resize = (models, reviewers, count) => {
23
- const fresh = choiceToConfiguration(models, choiceFromConfiguration(models, null));
24
- return fresh === null ? [...reviewers] : resizeReviewers(reviewers, count, fresh);
24
+ const freshReviewer = (models, position) => {
25
+ const configuration = choiceToConfiguration(models, choiceFromConfiguration(models, null));
26
+ return configuration === null ? null : newReviewer(configuration, position);
25
27
  };
26
- export const ReviewersStep = ({ models, value, resumeAtLast, presetCount, onSubmit, onBack, }) => {
27
- const [reviewers, setReviewers] = useState(() => presetCount === null ? [...value] : resize(models, value, presetCount));
28
- // null shows the count question; a number edits that reviewer. `--reviewers` skips the question.
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;
28
+ // The next draft, or null when the accepted reviewers reach the limit.
29
+ const nextDraft = (models, accepted) => accepted.length < maxReviewers ? freshReviewer(models, accepted.length) : null;
30
+ export const ReviewersStep = ({ models, value, onChange, onSubmit, onBack }) => {
31
+ // coming back from the prompt, every accepted reviewer stays and a new one opens
32
+ const [selection, setSelection] = useState(() => ({
33
+ accepted: value.accepted,
34
+ draft: value.draft ?? nextDraft(models, value.accepted),
35
+ }));
36
+ const { accepted, draft } = selection;
37
+ const isAtLimit = accepted.length === maxReviewers;
38
+ const updateSelection = (next) => {
39
+ setSelection(next);
40
+ onChange(next);
41
+ };
42
+ useInput((input, key) => {
43
+ if (key.ctrl && input === 'd' && accepted.length > 0) {
44
+ onSubmit(accepted);
33
45
  }
34
- return presetCount === null ? null : 0;
35
46
  });
36
- if (editing === null) {
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) => ({
38
- key: String(index + 1),
39
- label: String(index + 1),
40
- })), initialKey: String(Math.max(1, value.length)), onSelect: (key) => {
41
- setReviewers((previous) => resize(models, previous, Number(key)));
42
- setEditing(0);
43
- } })] }));
44
- }
45
- const reviewer = reviewers[editing];
46
- if (reviewer === undefined) {
47
- return _jsx(Text, { color: theme.failure, children: "Reviewer not found." });
48
- }
49
- return (_jsxs(StepFrame, { title: `Reviewer ${editing + 1} of ${reviewers.length}`, hints: "\u2191\u2193 row \u00B7 \u2190\u2192 column \u00B7 Enter confirm", onBack: () => {
50
- if (editing > 0) {
51
- setEditing(editing - 1);
52
- }
53
- else if (presetCount === null) {
54
- setEditing(null);
47
+ return (_jsxs(StepFrame, { title: isAtLimit ? 'Reviewers' : `Reviewer ${accepted.length + 1}`, hints: "\u2191\u2193 row \u00B7 \u2190\u2192 column \u00B7 Enter next \u00B7 Ctrl+D confirm", onBack: () => {
48
+ const last = accepted.at(-1);
49
+ if (last === undefined) {
50
+ onBack?.();
55
51
  }
56
52
  else {
57
- onBack?.();
53
+ updateSelection({ accepted: accepted.slice(0, -1), draft: last });
58
54
  }
59
- }, children: [editing > 0 && (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(ReviewerList, { reviewers: reviewers.slice(0, editing), dimmed: true }) })), _jsx(ReviewerEditor, { models: models, reviewer: reviewer, onChange: (next) => setReviewers((previous) => previous.map((item, index) => (index === editing ? next : item))), onSubmit: () => {
60
- if (editing + 1 < reviewers.length) {
61
- setEditing(editing + 1);
62
- }
63
- else {
64
- onSubmit(reviewers);
65
- }
66
- } }, editing)] }));
55
+ }, children: [accepted.length > 0 && (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(ReviewerList, { reviewers: accepted, dimmed: true }) })), isAtLimit && (_jsxs(Text, { dimColor: true, children: ["Maximum ", maxReviewers, " reviewers. Press Ctrl+D to confirm them or Esc to edit the last one."] })), !isAtLimit && draft === null && _jsx(Text, { color: theme.failure, children: "No model is available for reviewers." }), draft !== null && (_jsx(ReviewerEditor, { models: models, reviewer: draft, onChange: (next) => updateSelection({ accepted, draft: next }), onSubmit: () => {
56
+ const nextAccepted = [...accepted, draft];
57
+ updateSelection({ accepted: nextAccepted, draft: nextDraft(models, nextAccepted) });
58
+ } }, accepted.length))] }));
67
59
  };
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { basename } from 'node:path';
2
3
  import { Box, Text } from 'ink';
3
4
  import { useEffect, useState } from 'react';
4
5
  import { waitingProgress } from '../../execution/agent-progress.js';
@@ -8,6 +9,7 @@ import { councilAgentId, numberReviewers, reviewerAgentId, runReview } from '../
8
9
  import { describeReviewResult } from '../../review/review-output.js';
9
10
  import { ReportResult } from '../components/report-result.js';
10
11
  import { RunProgress } from '../components/run-progress.js';
12
+ import { describeScopeRange, fileNoun } from '../describe-scope.js';
11
13
  const agentColumns = (configuration) => ({
12
14
  provider: providerDisplayNames[configuration.provider],
13
15
  model: configuration.modelLabel,
@@ -52,7 +54,6 @@ export const RunScreen = ({ session, dependencies, writeClipboard, openReport, o
52
54
  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) }));
53
55
  return () => controller.abort();
54
56
  }, []);
55
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: outcome === null
56
- ? `Reviewing ${session.scope.changedFiles.length} file(s)`
57
- : `Reviewed ${session.scope.changedFiles.length} file(s)` }), _jsx(Box, { marginTop: 1, children: _jsx(RunProgress, { rows: rows }) }), outcome === null && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Ctrl+C cancels all agents" }) })), outcome !== null && report !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ReportResult, { report: report, writeClipboard: writeClipboard, openReport: openReport, onQuit: onQuit }) }))] }));
57
+ const changedCount = session.scope.changedFiles.length;
58
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [outcome === null ? 'Reviewing' : 'Reviewed', " ", changedCount, " ", fileNoun(changedCount), " \u00B7", ' ', basename(session.repository.root), " \u00B7 ", describeScopeRange(session.scope)] }), _jsx(Box, { marginTop: 1, children: _jsx(RunProgress, { rows: rows }) }), outcome === null && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Ctrl+C cancels all agents" }) })), outcome !== null && report !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ReportResult, { report: report, writeClipboard: writeClipboard, openReport: openReport, onQuit: onQuit }) }))] }));
58
59
  };
@@ -2,14 +2,14 @@
2
2
 
3
3
  You verify independent reviews against the supplied change and produce one trustworthy, actionable report. Judge evidence, not reviewer agreement.
4
4
 
5
- - Verify every claim, merge duplicates and resolve contradictions. Separate confirmed findings, rejected claims with reasons and unresolved questions with missing evidence.
5
+ - Verify every claim, merge duplicates and resolve contradictions. Separate confirmed findings, rejected claims with counterevidence 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
9
 
10
10
  ## Report opening
11
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:
12
+ The application previews the title, the first paragraph and the finding headings. Include any failed reviews and material coverage gaps in the first paragraph. Open the report like this, translated, without an enclosing code fence:
13
13
 
14
14
  ```markdown
15
15
  # <one-line verdict>
@@ -1,3 +1,4 @@
1
1
  # Code review
2
2
 
3
3
  - Focus on behaviour, contracts and compatibility. Tie test gaps and structural concerns to concrete consequences.
4
+ - When requirements are supplied, check for missing, partial or conflicting behaviour; cite the requirement behind each mismatch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twistezo/ai-review-council",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Terminal app that runs independent Codex, Claude Code and GitHub Copilot reviewers and merges their results",
5
5
  "license": "MIT",
6
6
  "keywords": [