@twistezo/ai-review-council 1.2.1 → 1.3.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
@@ -26,7 +26,7 @@ npx @twistezo/ai-review-council
26
26
  |---|---|---|
27
27
  | `-d, --dir` | folder inside a Git repository | asked |
28
28
  | `-l, --lang` | `en`, `pl` | asked, `en` |
29
- | `-s, --scope` | `uncommitted`, or a commit hash: changes after it, uncommitted included | asked |
29
+ | `-s, --scope` | `uncommitted`; `full`: the entire codebase, every file as new; or a commit hash: changes after it, uncommitted included | asked |
30
30
  | `-r, --reviewers` | 1 to 6 | asked |
31
31
  | `-t, --timeout` | minutes per agent, 1 to 120 | `10` |
32
32
 
@@ -7,7 +7,8 @@ export const usage = `Usage: ai-review-council [options]
7
7
  Options:
8
8
  -d, --dir <path> Project folder to review; skips the folder step
9
9
  -l, --lang <code> Report language: ${reportLanguages.join(', ')}; skips the language step
10
- -s, --scope <value> uncommitted, or a commit hash to review the changes after it; skips the scope step
10
+ -s, --scope <value> uncommitted, full for the entire codebase, or a commit hash to review the changes after it;
11
+ skips the scope step
11
12
  -r, --reviewers <n> Number of reviewers, 1 to ${maxReviewers}; skips the count question
12
13
  -t, --timeout <min> Minutes each agent may run before it fails (default ${defaultTimeoutMinutes}, max ${maxTimeoutMinutes})
13
14
  -h, --help Show this help`;
@@ -25,6 +26,7 @@ const readRawOptions = (args) => parseArgs({
25
26
  allowPositionals: false,
26
27
  }).values;
27
28
  const uncommittedScope = 'uncommitted';
29
+ const fullScope = 'full';
28
30
  const reviewerCountSchema = z.number().int().min(1).max(maxReviewers);
29
31
  const parseScope = (value) => {
30
32
  if (value === undefined) {
@@ -34,6 +36,9 @@ const parseScope = (value) => {
34
36
  if (trimmed.toLowerCase() === uncommittedScope) {
35
37
  return { kind: 'uncommitted' };
36
38
  }
39
+ if (trimmed.toLowerCase() === fullScope) {
40
+ return { kind: 'full' };
41
+ }
37
42
  return isCommitHashFormat(trimmed) ? { kind: 'since-commit', commitRef: trimmed } : 'invalid';
38
43
  };
39
44
  export const parseCliOptions = (args) => {
@@ -53,7 +58,7 @@ export const parseCliOptions = (args) => {
53
58
  }
54
59
  const scope = parseScope(values.scope);
55
60
  if (scope === 'invalid') {
56
- return { ok: false, error: 'The --scope option needs `uncommitted` or a commit hash (4 to 40 hexadecimal characters).' };
61
+ return { ok: false, error: 'The --scope option needs `uncommitted`, `full` or a commit hash (4 to 40 hexadecimal characters).' };
57
62
  }
58
63
  const reviewerCount = values.reviewers === undefined ? null : reviewerCountSchema.safeParse(Number(values.reviewers));
59
64
  if (reviewerCount !== null && !reviewerCount.success) {
@@ -37,10 +37,13 @@ const commitInfoSchema = z.object({
37
37
  hash: z.string().length(40),
38
38
  subject: z.string(),
39
39
  });
40
- const reviewScopeKinds = ['uncommitted', 'since-commit'];
40
+ const reviewScopeKinds = ['uncommitted', 'since-commit', 'full'];
41
41
  const reviewScopeSchema = z.object({
42
42
  kind: z.enum(reviewScopeKinds),
43
+ // the commit the review starts from; HEAD for `full`
43
44
  base: commitInfoSchema,
45
+ // what the diff is taken against: the base commit, or Git's empty tree for `full`, so every file shows as added
46
+ diffBase: z.string().min(1),
44
47
  changedFiles: z.array(changedFileSchema).min(1),
45
48
  // paths of likely secret files left out of the review
46
49
  excludedFiles: z.array(z.string().min(1)),
@@ -44,6 +44,11 @@ export const createGitRepository = (run) => {
44
44
  },
45
45
  resolveCommit,
46
46
  isInHeadHistory: async (root, hash) => (await git(root, ['merge-base', '--is-ancestor', hash, 'HEAD'])) !== null,
47
+ readEmptyTreeHash: async (root) => {
48
+ // Hashing nothing as a tree gives the empty tree, also in SHA-256 repositories.
49
+ const output = await git(root, ['hash-object', '-t', 'tree', '/dev/null']);
50
+ return output === null ? null : output.trim();
51
+ },
47
52
  listChangedFiles: async (root, baseHash) => {
48
53
  // `git diff <base>` compares the base with the working tree: staged and unstaged changes.
49
54
  const [diffOutput, untrackedOutput] = await Promise.all([
@@ -9,15 +9,23 @@ const changeLabels = {
9
9
  untracked: 'new, not in the diff: read it in full',
10
10
  };
11
11
  const describeFile = (file) => `- ${file.previousPath === null ? file.path : `${file.previousPath} -> ${file.path}`} (${changeLabels[file.change]})`;
12
+ const describeRange = (scope) => {
13
+ const base = `${scope.base.hash.slice(0, 7)} ${scope.base.subject}`.trim();
14
+ switch (scope.kind) {
15
+ case 'uncommitted':
16
+ return `Uncommitted changes against ${base}.`;
17
+ case 'since-commit':
18
+ return `Everything changed after ${base}, up to the current files with uncommitted changes. The base commit itself is not part of the change.`;
19
+ case 'full':
20
+ return `The entire codebase at ${base}, uncommitted changes included. Every file is part of the change and shows as added in the diff.`;
21
+ }
22
+ };
12
23
  const scopeSection = (session, diffPath) => {
13
24
  const { repository, scope } = session;
14
- const base = `${scope.base.hash.slice(0, 7)} ${scope.base.subject}`.trim();
15
25
  const lines = [
16
26
  '## Scope',
17
27
  `Repository: ${repository.root} (${repository.branch === null ? 'detached HEAD' : `branch ${repository.branch}`})`,
18
- scope.kind === 'uncommitted'
19
- ? `Uncommitted changes against ${base}.`
20
- : `Everything changed after ${base}, up to the current files with uncommitted changes. The base commit itself is not part of the change.`,
28
+ describeRange(scope),
21
29
  `Diff of tracked changes: ${diffPath}`,
22
30
  `Changed files (${scope.changedFiles.length}):`,
23
31
  ...scope.changedFiles.map(describeFile),
@@ -35,7 +35,7 @@ export const createWorkspacePreparer = (git) => async (repositoryRoot, scope) =>
35
35
  activeDirectories.add(directory);
36
36
  const diffPath = path.join(directory, 'change.diff');
37
37
  try {
38
- await writeFile(diffPath, await git.readDiff(repositoryRoot, scope.base.hash, trackedDiffPaths(scope)));
38
+ await writeFile(diffPath, await git.readDiff(repositoryRoot, scope.diffBase, trackedDiffPaths(scope)));
39
39
  }
40
40
  catch (error) {
41
41
  await removeDirectory(directory);
@@ -1,7 +1,17 @@
1
1
  import { isCommitHashFormat } from '../git/git-repository.js';
2
2
  import { partitionReviewFiles } from './excluded-files.js';
3
+ const nothingToReview = (request, base) => {
4
+ switch (request.kind) {
5
+ case 'uncommitted':
6
+ return 'There are no uncommitted changes to review.';
7
+ case 'since-commit':
8
+ return `There are no changes after ${base.hash.slice(0, 7)} to review.`;
9
+ case 'full':
10
+ return 'There are no files to review.';
11
+ }
12
+ };
3
13
  export const buildReviewScope = async (git, root, request) => {
4
- const ref = request.kind === 'uncommitted' ? 'HEAD' : request.commitRef.trim();
14
+ const ref = request.kind === 'since-commit' ? request.commitRef.trim() : 'HEAD';
5
15
  if (request.kind === 'since-commit' && !isCommitHashFormat(ref)) {
6
16
  return { ok: false, error: 'Enter a commit hash: 4 to 40 hexadecimal characters.' };
7
17
  }
@@ -16,14 +26,14 @@ export const buildReviewScope = async (git, root, request) => {
16
26
  error: `Commit ${base.hash.slice(0, 7)} is not in the history of the current branch. Pick one from \`git log\`.`,
17
27
  };
18
28
  }
19
- const { included, excluded } = partitionReviewFiles(await git.listChangedFiles(root, base.hash));
29
+ // The entire codebase is diffed against no files at all, so every file is new.
30
+ const diffBase = request.kind === 'full' ? await git.readEmptyTreeHash(root) : base.hash;
31
+ if (diffBase === null) {
32
+ return { ok: false, error: 'Git could not read the empty tree to compare the codebase with.' };
33
+ }
34
+ const { included, excluded } = partitionReviewFiles(await git.listChangedFiles(root, diffBase));
20
35
  if (included.length === 0) {
21
- return {
22
- ok: false,
23
- error: request.kind === 'uncommitted'
24
- ? 'There are no uncommitted changes to review.'
25
- : `There are no changes after ${base.hash.slice(0, 7)} to review.`,
26
- };
36
+ return { ok: false, error: nothingToReview(request, base) };
27
37
  }
28
- return { ok: true, scope: { kind: request.kind, base, changedFiles: included, excludedFiles: excluded } };
38
+ return { ok: true, scope: { kind: request.kind, base, diffBase, changedFiles: included, excludedFiles: excluded } };
29
39
  };
@@ -1,8 +1,15 @@
1
- export const describeScope = (scope) => {
1
+ const describeRange = (scope) => {
2
2
  const base = scope.base.hash.slice(0, 7);
3
- const range = scope.kind === 'uncommitted'
4
- ? `uncommitted changes on ${base}`
5
- : `changes after ${base} ${scope.base.subject}, uncommitted included`;
3
+ switch (scope.kind) {
4
+ case 'uncommitted':
5
+ return `uncommitted changes on ${base}`;
6
+ case 'since-commit':
7
+ return `changes after ${base} ${scope.base.subject}, uncommitted included`;
8
+ case 'full':
9
+ return `entire codebase on ${base}, uncommitted included`;
10
+ }
11
+ };
12
+ export const describeScope = (scope) => {
6
13
  const excluded = scope.excludedFiles.length === 0 ? '' : `, ${scope.excludedFiles.length} likely secret file(s) excluded`;
7
- return `${range} (${scope.changedFiles.length} files${excluded})`;
14
+ return `${describeRange(scope)} (${scope.changedFiles.length} files${excluded})`;
8
15
  };
@@ -9,6 +9,7 @@ import { theme } from '../theme.js';
9
9
  const kindLabels = {
10
10
  uncommitted: 'Uncommitted only',
11
11
  'since-commit': 'Since commit',
12
+ full: 'Entire codebase',
12
13
  };
13
14
  const hints = {
14
15
  kind: '↑↓ row · Enter confirm',
@@ -20,7 +21,7 @@ export const ScopeStep = ({ git, repository, initialKind, preset, onSubmit, onPr
20
21
  const [commitRef, setCommitRef] = useState(preset?.kind === 'since-commit' ? preset.commitRef : '');
21
22
  const [error, setError] = useState(null);
22
23
  const load = async (request) => {
23
- const previousView = request.kind === 'uncommitted' ? 'kind' : 'commit';
24
+ const previousView = request.kind === 'since-commit' ? 'commit' : 'kind';
24
25
  setView('loading');
25
26
  setError(null);
26
27
  try {
@@ -56,9 +57,10 @@ export const ScopeStep = ({ git, repository, initialKind, preset, onSubmit, onPr
56
57
  return (_jsxs(StepFrame, { title: "Review scope", hints: hints[view], onBack: goBack, children: [view === 'kind' && (_jsx(SelectList, { items: [
57
58
  { key: 'uncommitted', label: kindLabels.uncommitted },
58
59
  { key: 'since-commit', label: kindLabels['since-commit'] },
60
+ { key: 'full', label: kindLabels.full },
59
61
  ], initialKey: initialKind, onSelect: (key) => {
60
- if (key === 'uncommitted') {
61
- void load({ kind: 'uncommitted' });
62
+ if (key === 'uncommitted' || key === 'full') {
63
+ void load({ kind: key });
62
64
  }
63
65
  else {
64
66
  setError(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twistezo/ai-review-council",
3
- "version": "1.2.1",
3
+ "version": "1.3.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": [