@twistezo/ai-review-council 1.2.0 → 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
@@ -12,21 +12,21 @@ Changes: [CHANGELOG.md](CHANGELOG.md). Internals: [ARCHITECTURE.md](ARCHITECTURE
12
12
  ## Run
13
13
 
14
14
  ```bash
15
+ # install
15
16
  npm install -g @twistezo/ai-review-council
16
- ```
17
17
 
18
- ```bash
19
- # every option is optional and skips its wizard question
18
+ # run; every option is optional and skips its wizard question
20
19
  ai-review-council --dir ~/projects/my-app --lang pl --scope uncommitted --reviewers 2
21
- ```
22
20
 
23
- Without installing: `npx @twistezo/ai-review-council`.
21
+ # or run without installing
22
+ npx @twistezo/ai-review-council
23
+ ```
24
24
 
25
25
  | Option | Value | Default |
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
 
@@ -61,29 +61,33 @@ A new review type needs a file in both folders and an entry in `reviewTypes` in
61
61
  ## Develop
62
62
 
63
63
  ```bash
64
+ # install dependencies
64
65
  npm install
65
- ```
66
66
 
67
- ```bash
68
- # same options as the installed command
67
+ # run from source; same options as the installed command
69
68
  npm run dev -- --dir ~/projects/my-app
70
- ```
71
69
 
72
- ```bash
70
+ # tests, typecheck, build into `dist/`
73
71
  npm test
74
- ```
75
-
76
- ```bash
77
72
  npm run typecheck
78
- ```
79
-
80
- ```bash
81
73
  npm run build
82
74
  ```
83
75
 
84
- Release: bump the version in `package.json` and `package-lock.json`, add its `CHANGELOG.md` section, then commit, tag and push. A GitHub release for the tag publishes it to npm ([`publish.yml`](.github/workflows/publish.yml)):
76
+ Release: a GitHub release for the tag publishes the version to npm ([`publish.yml`](.github/workflows/publish.yml)).
85
77
 
86
78
  ```bash
87
- # notes: the version's CHANGELOG section
88
- VERSION=1.2.0 && gh release create "v$VERSION" --title "v$VERSION" --notes "$(awk -v v="## $VERSION " '/^## [0-9]/{p=index($0, v)==1} p' CHANGELOG.md)"
79
+ # version to release; add its section to CHANGELOG.md first
80
+ VERSION=1.2.1
81
+
82
+ # bump package.json and package-lock.json, no Git commit or tag
83
+ npm version "$VERSION" --no-git-tag-version
84
+
85
+ # commit, tag and push
86
+ git add CHANGELOG.md package.json package-lock.json
87
+ git commit -m "Release v$VERSION"
88
+ git tag "v$VERSION"
89
+ git push origin main "v$VERSION"
90
+
91
+ # GitHub release with the version's CHANGELOG section as notes; CI publishes to npm
92
+ gh release create "v$VERSION" --title "v$VERSION" --notes "$(awk -v v="## $VERSION " '/^## [0-9]/{p=index($0, v)==1} p' CHANGELOG.md)"
89
93
  ```
@@ -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.0",
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": [