happy-stacks 0.6.11 → 0.6.13

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.
Files changed (66) hide show
  1. package/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
  2. package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
  3. package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
  6. package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
  8. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
  9. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1297 -140
  10. package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
  11. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
  12. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
  13. package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
  14. package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
  15. package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
  16. package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
  17. package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
  18. package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
  19. package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
  20. package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
  21. package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
  22. package/docs/commit-audits/happy/pr-desc.original.md +0 -0
  23. package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
  24. package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
  25. package/docs/happy-development.md +18 -1
  26. package/docs/isolated-linux-vm.md +23 -1
  27. package/docs/stacks.md +21 -1
  28. package/package.json +1 -1
  29. package/scripts/auth.mjs +46 -8
  30. package/scripts/daemon.mjs +44 -21
  31. package/scripts/doctor.mjs +2 -2
  32. package/scripts/doctor_cmd.test.mjs +67 -0
  33. package/scripts/happy.mjs +18 -5
  34. package/scripts/import.mjs +19 -13
  35. package/scripts/monorepo.mjs +414 -23
  36. package/scripts/monorepo_port.test.mjs +136 -0
  37. package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
  38. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  39. package/scripts/review.mjs +347 -124
  40. package/scripts/review_pr.mjs +78 -2
  41. package/scripts/run.mjs +2 -1
  42. package/scripts/stack.mjs +265 -19
  43. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  44. package/scripts/stack_happy_cmd.test.mjs +103 -0
  45. package/scripts/utils/cli/prereqs.mjs +12 -1
  46. package/scripts/utils/cli/wizard.mjs +19 -1
  47. package/scripts/utils/dev/daemon.mjs +3 -1
  48. package/scripts/utils/llm/assist.mjs +64 -31
  49. package/scripts/utils/llm/happys_runner.mjs +60 -0
  50. package/scripts/utils/llm/tools.mjs +3 -3
  51. package/scripts/utils/proc/pm.mjs +1 -1
  52. package/scripts/utils/review/detached_worktree.mjs +61 -0
  53. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  54. package/scripts/utils/review/findings.mjs +133 -20
  55. package/scripts/utils/review/findings.test.mjs +88 -1
  56. package/scripts/utils/review/runners/augment.mjs +71 -0
  57. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  58. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  59. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  60. package/scripts/utils/review/sliced_runner.mjs +39 -0
  61. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  62. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  63. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  64. package/scripts/utils/stack/cli_identities.mjs +29 -0
  65. package/scripts/utils/stack/startup.mjs +45 -7
  66. package/scripts/worktrees.mjs +8 -5
@@ -0,0 +1,61 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { ensureDir } from '../fs/ops.mjs';
4
+ import { runCapture } from '../proc/proc.mjs';
5
+
6
+ function sanitizeLabel(raw) {
7
+ return String(raw ?? 'worktree')
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9._-]+/g, '-')
10
+ .replace(/^-+|-+$/g, '');
11
+ }
12
+
13
+ function defaultNonce() {
14
+ const rand = Math.random().toString(16).slice(2, 10);
15
+ return `${process.pid}-${Date.now()}-${rand}`;
16
+ }
17
+
18
+ export function computeDetachedWorktreeDir({ repoRootDir, label, headCommit, nonce } = {}) {
19
+ const root = String(repoRootDir ?? '').trim();
20
+ if (!root) throw new Error('[review] computeDetachedWorktreeDir: missing repoRootDir');
21
+
22
+ const safeLabel = sanitizeLabel(label);
23
+ const short = String(headCommit ?? '').slice(0, 12);
24
+ const n = String(nonce ?? defaultNonce()).trim();
25
+ return join(root, '.project', 'review-worktrees', `${safeLabel}-${short}-${n}`);
26
+ }
27
+
28
+ /**
29
+ * Create a detached git worktree for `headCommit`, run `fn(worktreeDir)`, then clean up.
30
+ *
31
+ * Notes:
32
+ * - The worktree directory name includes a nonce to avoid collisions when a prior run crashed
33
+ * and left behind a directory, or when multiple review runs happen in parallel.
34
+ * - We do best-effort cleanup even if `fn` throws.
35
+ */
36
+ export async function withDetachedWorktree({ repoDir, headCommit, label, env, nonce }, fn) {
37
+ const root = (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: repoDir, env })).toString().trim();
38
+ if (!root) throw new Error('[review] failed to resolve git toplevel');
39
+
40
+ const worktreesRoot = join(root, '.project', 'review-worktrees');
41
+ await ensureDir(worktreesRoot);
42
+ const dir = computeDetachedWorktreeDir({ repoRootDir: root, label, headCommit, nonce });
43
+
44
+ // Extremely defensive: should not happen with nonced dirs, but avoid surprising errors.
45
+ if (existsSync(dir)) {
46
+ throw new Error(`[review] detached worktree dir already exists: ${dir}`);
47
+ }
48
+
49
+ try {
50
+ await runCapture('git', ['worktree', 'add', '--detach', dir, headCommit], { cwd: repoDir, env });
51
+ return await fn(dir);
52
+ } finally {
53
+ try {
54
+ await runCapture('git', ['worktree', 'remove', '--force', dir], { cwd: repoDir, env });
55
+ await runCapture('git', ['worktree', 'prune'], { cwd: repoDir, env });
56
+ } catch {
57
+ // best-effort cleanup; leave an orphaned worktree if needed
58
+ }
59
+ }
60
+ }
61
+
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { run, runCapture } from '../proc/proc.mjs';
7
+ import { computeDetachedWorktreeDir, withDetachedWorktree } from './detached_worktree.mjs';
8
+
9
+ function gitEnv() {
10
+ const clean = {};
11
+ for (const [k, v] of Object.entries(process.env)) {
12
+ if (k.startsWith('HAPPY_STACKS_') || k.startsWith('HAPPY_LOCAL_')) continue;
13
+ clean[k] = v;
14
+ }
15
+ return {
16
+ ...clean,
17
+ GIT_AUTHOR_NAME: 'Test',
18
+ GIT_AUTHOR_EMAIL: 'test@example.com',
19
+ GIT_COMMITTER_NAME: 'Test',
20
+ GIT_COMMITTER_EMAIL: 'test@example.com',
21
+ };
22
+ }
23
+
24
+ test('computeDetachedWorktreeDir includes nonce to avoid collisions', () => {
25
+ const dir1 = computeDetachedWorktreeDir({ repoRootDir: '/repo', label: 'coderabbit-1-of-21', headCommit: 'abcdef0123456789', nonce: 'n1' });
26
+ const dir2 = computeDetachedWorktreeDir({ repoRootDir: '/repo', label: 'coderabbit-1-of-21', headCommit: 'abcdef0123456789', nonce: 'n2' });
27
+ assert.notEqual(dir1, dir2);
28
+ assert.ok(dir1.includes('coderabbit-1-of-21-abcdef012345-n1'));
29
+ assert.ok(dir2.includes('coderabbit-1-of-21-abcdef012345-n2'));
30
+ });
31
+
32
+ test('withDetachedWorktree can be called repeatedly without directory collisions', async () => {
33
+ const repo = await mkdtemp(join(tmpdir(), 'happy-review-wt-'));
34
+ const env = gitEnv();
35
+
36
+ try {
37
+ await run('git', ['init', '-q'], { cwd: repo, env });
38
+ await run('git', ['checkout', '-q', '-b', 'main'], { cwd: repo, env });
39
+ await mkdir(join(repo, 'x'), { recursive: true });
40
+ await writeFile(join(repo, 'x', 'a.txt'), 'a\n', 'utf-8');
41
+ await run('git', ['add', '.'], { cwd: repo, env });
42
+ await run('git', ['commit', '-q', '-m', 'base'], { cwd: repo, env });
43
+
44
+ const head = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: repo, env })).trim();
45
+
46
+ const seen = [];
47
+ await withDetachedWorktree({ repoDir: repo, headCommit: head, label: 'test', env, nonce: 'one' }, async (dir) => {
48
+ seen.push(dir);
49
+ assert.equal((await runCapture('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, env })).trim(), 'true');
50
+ });
51
+ await withDetachedWorktree({ repoDir: repo, headCommit: head, label: 'test', env, nonce: 'two' }, async (dir) => {
52
+ seen.push(dir);
53
+ assert.equal((await runCapture('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, env })).trim(), 'true');
54
+ });
55
+
56
+ assert.equal(seen.length, 2);
57
+ assert.notEqual(seen[0], seen[1]);
58
+ } finally {
59
+ await rm(repo, { recursive: true, force: true });
60
+ }
61
+ });
62
+
@@ -45,7 +45,9 @@ export function parseCodeRabbitPlainOutput(text) {
45
45
 
46
46
  for (let i = 0; i < lines.length; i += 1) {
47
47
  const line = lines[i];
48
- const trimmed = line.trimEnd();
48
+ // The raw log files written by the review runner prefix each line with "[label] ".
49
+ // Strip that prefix so we can parse both prefixed logs and unprefixed stdout.
50
+ const trimmed = line.trimEnd().replace(/^\[[^\]]+\]\s*/g, '');
49
51
 
50
52
  if (trimmed.startsWith('============================================================================')) {
51
53
  flush();
@@ -95,27 +97,129 @@ export function parseCodexReviewText(reviewText) {
95
97
  const s = String(reviewText ?? '');
96
98
  const marker = '===FINDINGS_JSON===';
97
99
  const idx = s.indexOf(marker);
98
- if (idx < 0) return [];
99
- const jsonText = s.slice(idx + marker.length).trim();
100
- if (!jsonText) return [];
101
-
102
- let parsed;
103
- try {
104
- parsed = JSON.parse(jsonText);
105
- } catch {
106
- return [];
100
+ if (idx >= 0) {
101
+ let jsonText = s.slice(idx + marker.length).trim();
102
+ if (!jsonText) return [];
103
+
104
+ // The raw log files written by the review runner prefix each line with "[label] ".
105
+ // Strip that prefix so we can parse both prefixed logs and unprefixed stdout.
106
+ jsonText = jsonText
107
+ .split('\n')
108
+ .map((line) => String(line ?? '').replace(/^\[[^\]]+\]\s*/g, ''))
109
+ .join('\n')
110
+ .trim();
111
+
112
+ // Some reviewers wrap the JSON in a fenced code block:
113
+ // ===FINDINGS_JSON===
114
+ // ```json
115
+ // [...]
116
+ // ```
117
+ //
118
+ // Strip the outer fence so JSON.parse can succeed.
119
+ const fence = jsonText.match(/^```[a-z0-9_-]*\s*\n([\s\S]*?)\n```/i);
120
+ if (fence?.[1]) jsonText = fence[1].trim();
121
+
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(jsonText);
125
+ } catch {
126
+ parsed = null;
127
+ }
128
+
129
+ // Some tools append non-JSON metadata after the array (e.g. "Request ID: ...").
130
+ // As a last resort, try to parse the first top-level JSON array substring.
131
+ if (!Array.isArray(parsed)) {
132
+ const firstBracket = jsonText.indexOf('[');
133
+ const lastBracket = jsonText.lastIndexOf(']');
134
+ if (firstBracket >= 0 && lastBracket > firstBracket) {
135
+ try {
136
+ parsed = JSON.parse(jsonText.slice(firstBracket, lastBracket + 1));
137
+ } catch {
138
+ // ignore
139
+ }
140
+ }
141
+ }
142
+ if (Array.isArray(parsed)) {
143
+ return parsed
144
+ .map((x) => ({
145
+ reviewer: 'codex',
146
+ severity: x?.severity ?? null,
147
+ file: x?.file ?? null,
148
+ lines: x?.lines ?? null,
149
+ title: x?.title ?? null,
150
+ recommendation: x?.recommendation ?? null,
151
+ needsDiscussion: Boolean(x?.needsDiscussion),
152
+ }))
153
+ .filter((x) => x.file && x.title);
154
+ }
155
+ }
156
+
157
+ // Fallback: Codex sometimes returns a human-readable list like:
158
+ // - [P2] Thing — /abs/path/.project/review-worktrees/codex-.../cli/src/foo.ts:10-12
159
+ //
160
+ // Parse these into structured findings so they appear in triage even when the
161
+ // JSON trailer is missing.
162
+ const priorityToSeverity = { 1: 'blocker', 2: 'major', 3: 'minor', 4: 'nit' };
163
+ const lines = s.split('\n');
164
+ const findings = [];
165
+ const seen = new Set();
166
+
167
+ function stripPrefix(line) {
168
+ return String(line ?? '').replace(/^\[[^\]]+\]\s*/g, '').trim();
169
+ }
170
+
171
+ function normalizePath(rawPath) {
172
+ const p = String(rawPath ?? '').trim();
173
+ const marker2 = '/.project/review-worktrees/';
174
+ const i = p.indexOf(marker2);
175
+ if (i < 0) return p;
176
+ const rest = p.slice(i + marker2.length);
177
+ const slash = rest.indexOf('/');
178
+ if (slash < 0) return p;
179
+ return rest.slice(slash + 1);
107
180
  }
108
- if (!Array.isArray(parsed)) return [];
109
- return parsed
110
- .map((x) => ({
181
+
182
+ for (const rawLine of lines) {
183
+ const line = stripPrefix(rawLine);
184
+ const m = line.match(/^- \[P([1-4])\]\s+(.+?)\s+—\s+(.+)$/);
185
+ if (!m) continue;
186
+
187
+ const priority = Number(m[1]);
188
+ const title = String(m[2]).trim();
189
+ const pathPart = String(m[3]).trim();
190
+
191
+ let file = pathPart;
192
+ let range = null;
193
+
194
+ const lastColon = pathPart.lastIndexOf(':');
195
+ if (lastColon > 0) {
196
+ const suffix = pathPart.slice(lastColon + 1).trim();
197
+ const rm = suffix.match(/^(\d+)(?:-(\d+))?$/);
198
+ if (rm) {
199
+ const start = Number(rm[1]);
200
+ const end = Number(rm[2] ?? rm[1]);
201
+ range = { start, end };
202
+ file = pathPart.slice(0, lastColon);
203
+ }
204
+ }
205
+
206
+ const normalizedFile = normalizePath(file);
207
+ const severity = priorityToSeverity[priority] ?? null;
208
+ const key = `${normalizedFile}:${range?.start ?? ''}-${range?.end ?? ''}:${title}`;
209
+ if (seen.has(key)) continue;
210
+ seen.add(key);
211
+ findings.push({
111
212
  reviewer: 'codex',
112
- severity: x?.severity ?? null,
113
- file: x?.file ?? null,
114
- title: x?.title ?? null,
115
- recommendation: x?.recommendation ?? null,
116
- needsDiscussion: Boolean(x?.needsDiscussion),
117
- }))
118
- .filter((x) => x.file && x.title);
213
+ severity,
214
+ file: normalizedFile,
215
+ lines: range,
216
+ title,
217
+ recommendation: null,
218
+ needsDiscussion: false,
219
+ });
220
+ }
221
+
222
+ return findings.filter((x) => x.file && x.title);
119
223
  }
120
224
 
121
225
  export function formatTriageMarkdown({ runLabel, baseRef, findings }) {
@@ -126,6 +230,15 @@ export function formatTriageMarkdown({ runLabel, baseRef, findings }) {
126
230
  `- Base ref: ${baseRef ?? ''}`,
127
231
  `- Findings: ${items.length}`,
128
232
  '',
233
+ '## Trust checklist (READ THIS FIRST)',
234
+ '',
235
+ 'Before you act on reviewer output:',
236
+ '1) Load this file into your context (human/LLM) so you follow the workflow end-to-end.',
237
+ '2) Treat every suggestion as a suggestion: verify against best practices + project invariants.',
238
+ '3) If you are unsure, do not apply; mark **Needs discussion** and capture rationale.',
239
+ '4) Do not skip nits by default: apply them when they improve long-term maintainability without risk.',
240
+ '5) Use web search sparingly when needed to validate best practices, but prefer primary sources/docs.',
241
+ '',
129
242
  '## Mandatory workflow',
130
243
  '',
131
244
  'For each finding below:',
@@ -64,6 +64,93 @@ test('parseCodexReviewText extracts findings JSON trailer', () => {
64
64
  assert.equal(findings[0].severity, 'major');
65
65
  });
66
66
 
67
+ test('parseCodexReviewText extracts findings JSON trailer even when fenced', () => {
68
+ const review = [
69
+ 'All good.',
70
+ '',
71
+ '===FINDINGS_JSON===',
72
+ '```json',
73
+ JSON.stringify(
74
+ [
75
+ {
76
+ severity: 'minor',
77
+ file: 'cli/src/foo.ts',
78
+ title: 'Prefer explicit return type',
79
+ recommendation: 'Add an explicit return type for clarity.',
80
+ },
81
+ ],
82
+ null,
83
+ 2
84
+ ),
85
+ '```',
86
+ ].join('\n');
87
+
88
+ const findings = parseCodexReviewText(review);
89
+ assert.equal(findings.length, 1);
90
+ assert.equal(findings[0].file, 'cli/src/foo.ts');
91
+ assert.equal(findings[0].severity, 'minor');
92
+ });
93
+
94
+ test('parseCodexReviewText extracts findings JSON trailer when lines are log-prefixed', () => {
95
+ const label = '[monorepo:augment:4/39] ';
96
+ const review = [
97
+ `${label}some preamble`,
98
+ `${label}===FINDINGS_JSON===`,
99
+ `${label}\`\`\`json`,
100
+ `${label}[`,
101
+ `${label} {`,
102
+ `${label} \"severity\": \"major\",`,
103
+ `${label} \"file\": \"cli/src/x.ts\",`,
104
+ `${label} \"title\": \"Fix thing\",`,
105
+ `${label} \"recommendation\": \"Do it.\",`,
106
+ `${label} \"needsDiscussion\": false`,
107
+ `${label} }`,
108
+ `${label}]`,
109
+ `${label}\`\`\``,
110
+ `${label}`,
111
+ `${label}Request ID: abc`,
112
+ ].join('\n');
113
+
114
+ const findings = parseCodexReviewText(review);
115
+ assert.equal(findings.length, 1);
116
+ assert.equal(findings[0].file, 'cli/src/x.ts');
117
+ assert.equal(findings[0].severity, 'major');
118
+ });
119
+
120
+ test('parseCodexReviewText falls back to parsing [P#] bullet lines', () => {
121
+ const review = [
122
+ '[monorepo:codex:2/21] Review comment:',
123
+ '[monorepo:codex:2/21] - [P1] Fix thing one — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/cli/src/foo.ts:10-12',
124
+ '[monorepo:codex:2/21] - [P3] Fix thing two — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/expo-app/sources/bar.tsx:7',
125
+ ].join('\n');
126
+
127
+ const findings = parseCodexReviewText(review);
128
+ assert.equal(findings.length, 2);
129
+ assert.equal(findings[0].file, 'cli/src/foo.ts');
130
+ assert.deepEqual(findings[0].lines, { start: 10, end: 12 });
131
+ assert.equal(findings[0].severity, 'blocker');
132
+ assert.equal(findings[0].title, 'Fix thing one');
133
+ assert.equal(findings[1].file, 'expo-app/sources/bar.tsx');
134
+ assert.deepEqual(findings[1].lines, { start: 7, end: 7 });
135
+ assert.equal(findings[1].severity, 'minor');
136
+ assert.equal(findings[1].title, 'Fix thing two');
137
+ });
138
+
139
+ test('parseCodexReviewText falls back when marker exists but JSON is missing/invalid', () => {
140
+ const review = [
141
+ 'instructions...',
142
+ '===FINDINGS_JSON===',
143
+ 'this is not json',
144
+ '[monorepo:codex:2/21] - [P2] Fix thing — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/server/src/x.ts:1-2',
145
+ ].join('\n');
146
+
147
+ const findings = parseCodexReviewText(review);
148
+ assert.equal(findings.length, 1);
149
+ assert.equal(findings[0].file, 'server/src/x.ts');
150
+ assert.deepEqual(findings[0].lines, { start: 1, end: 2 });
151
+ assert.equal(findings[0].severity, 'major');
152
+ });
153
+
67
154
  test('formatTriageMarkdown includes required workflow fields', () => {
68
155
  const md = formatTriageMarkdown({
69
156
  runLabel: 'review-123',
@@ -78,8 +165,8 @@ test('formatTriageMarkdown includes required workflow fields', () => {
78
165
  },
79
166
  ],
80
167
  });
168
+ assert.match(md, /Trust checklist/i);
81
169
  assert.match(md, /Final decision: \*\*TBD\*\*/);
82
170
  assert.match(md, /Verified in validation worktree:/);
83
171
  assert.match(md, /Commit:/);
84
172
  });
85
-
@@ -0,0 +1,71 @@
1
+ import { runCaptureResult } from '../../proc/proc.mjs';
2
+
3
+ export function detectAugmentAuthError({ stdout, stderr }) {
4
+ const combined = `${stdout ?? ''}\n${stderr ?? ''}`;
5
+ return combined.includes('Authentication failed') && combined.includes("Run 'auggie login'");
6
+ }
7
+
8
+ export function buildAugmentReviewArgs({
9
+ prompt,
10
+ workspaceRoot,
11
+ cacheDir,
12
+ model,
13
+ rulesFiles = [],
14
+ retryTimeoutSec,
15
+ maxTurns,
16
+ } = {}) {
17
+ const args = ['--print', '--quiet', '--dont-save-session', '--ask', '--output-format', 'text'];
18
+
19
+ const wr = String(workspaceRoot ?? '').trim();
20
+ if (wr) args.push('--workspace-root', wr);
21
+
22
+ const cd = String(cacheDir ?? '').trim();
23
+ if (cd) args.push('--augment-cache-dir', cd);
24
+
25
+ const m = String(model ?? '').trim();
26
+ if (m) args.push('--model', m);
27
+
28
+ const rt = String(retryTimeoutSec ?? '').trim();
29
+ if (rt) args.push('--retry-timeout', rt);
30
+
31
+ const mt = String(maxTurns ?? '').trim();
32
+ if (mt) args.push('--max-turns', mt);
33
+
34
+ for (const rf of Array.isArray(rulesFiles) ? rulesFiles : []) {
35
+ const p = String(rf ?? '').trim();
36
+ if (!p) continue;
37
+ args.push('--rules', p);
38
+ }
39
+
40
+ const p = String(prompt ?? '').trim();
41
+ if (!p) throw new Error('[review] augment: missing prompt');
42
+ args.push(p);
43
+ return args;
44
+ }
45
+
46
+ export async function runAugmentReview({
47
+ repoDir,
48
+ prompt,
49
+ env,
50
+ streamLabel,
51
+ teeFile,
52
+ teeLabel,
53
+ cacheDir,
54
+ model,
55
+ rulesFiles = [],
56
+ retryTimeoutSec = 60 * 60 * 2,
57
+ maxTurns,
58
+ } = {}) {
59
+ const args = buildAugmentReviewArgs({
60
+ prompt,
61
+ workspaceRoot: repoDir,
62
+ cacheDir,
63
+ model,
64
+ rulesFiles,
65
+ retryTimeoutSec,
66
+ maxTurns,
67
+ });
68
+ const res = await runCaptureResult('auggie', args, { cwd: repoDir, env: env ?? {}, streamLabel, teeFile, teeLabel });
69
+ return { ...res, stdout: res.out, stderr: res.err };
70
+ }
71
+
@@ -0,0 +1,42 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildAugmentReviewArgs, detectAugmentAuthError } from './augment.mjs';
5
+
6
+ test('detectAugmentAuthError matches 401 login guidance', () => {
7
+ const stdout = "❌ Authentication failed: HTTP error: 401 Unauthorized\n Run 'auggie login' to authenticate first.";
8
+ assert.equal(detectAugmentAuthError({ stdout, stderr: '' }), true);
9
+ });
10
+
11
+ test('buildAugmentReviewArgs builds auggie --print args with optional settings', () => {
12
+ const args = buildAugmentReviewArgs({
13
+ prompt: 'Review the code',
14
+ workspaceRoot: '/repo',
15
+ cacheDir: '/cache',
16
+ model: 'gpt-5.2',
17
+ rulesFiles: ['/rules/a.md', '/rules/b.md'],
18
+ retryTimeoutSec: 123,
19
+ maxTurns: 9,
20
+ });
21
+
22
+ assert.equal(args[0], '--print');
23
+ assert.ok(args.includes('--quiet'));
24
+ assert.ok(args.includes('--dont-save-session'));
25
+ assert.ok(args.includes('--ask'));
26
+ assert.ok(args.includes('--workspace-root'));
27
+ assert.ok(args.includes('/repo'));
28
+ assert.ok(args.includes('--augment-cache-dir'));
29
+ assert.ok(args.includes('/cache'));
30
+ assert.ok(args.includes('--model'));
31
+ assert.ok(args.includes('gpt-5.2'));
32
+ assert.ok(args.includes('--retry-timeout'));
33
+ assert.ok(args.includes('123'));
34
+ assert.ok(args.includes('--max-turns'));
35
+ assert.ok(args.includes('9'));
36
+
37
+ const joined = args.join(' ');
38
+ assert.match(joined, /--rules \/rules\/a\.md/);
39
+ assert.match(joined, /--rules \/rules\/b\.md/);
40
+ assert.equal(args.at(-1), 'Review the code');
41
+ });
42
+
@@ -1,5 +1,6 @@
1
1
  import { runCaptureResult } from '../../proc/proc.mjs';
2
2
  import { join } from 'node:path';
3
+ import { appendFile } from 'node:fs/promises';
3
4
 
4
5
  function normalizeType(raw) {
5
6
  const t = String(raw ?? '').trim().toLowerCase();
@@ -8,6 +9,19 @@ function normalizeType(raw) {
8
9
  throw new Error(`[review] invalid coderabbit type: ${raw} (expected: all|committed|uncommitted)`);
9
10
  }
10
11
 
12
+ export function parseCodeRabbitRateLimitRetryMs(text) {
13
+ const s = String(text ?? '');
14
+ const m = s.match(/Rate limit exceeded,\s*please try after\s+(\d+)\s+minutes?\s+and\s+(\d+)\s+seconds?/i);
15
+ if (!m) return null;
16
+ const minutes = Number(m[1]);
17
+ const seconds = Number(m[2]);
18
+ if (!Number.isFinite(minutes) || minutes < 0) return null;
19
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
20
+ // Add +1s padding to avoid retrying too early.
21
+ const totalSeconds = minutes * 60 + seconds + 1;
22
+ return Math.max(1000, totalSeconds * 1000);
23
+ }
24
+
11
25
  export function buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles }) {
12
26
  const args = ['review', '--plain', '--no-color', '--type', normalizeType(type), '--cwd', repoDir];
13
27
  const base = String(baseRef ?? '').trim();
@@ -27,8 +41,15 @@ export function buildCodeRabbitEnv({ env, homeDir }) {
27
41
  const dir = String(homeDir ?? '').trim();
28
42
  if (!dir) return merged;
29
43
 
30
- merged.HOME = dir;
31
- merged.USERPROFILE = dir;
44
+ // IMPORTANT:
45
+ // Do not override HOME/USERPROFILE here.
46
+ //
47
+ // CodeRabbit uses OS credential storage (e.g. macOS Keychain). If HOME is pointed at
48
+ // an isolated directory (like .project/coderabbit-home), the underlying keychain
49
+ // lookup can fail with "Keychain Not Found" and auth will not work in the wrapper.
50
+ //
51
+ // We still isolate CodeRabbit's on-disk config/cache under the provided homeDir via
52
+ // CODERABBIT_HOME + XDG dirs.
32
53
  merged.CODERABBIT_HOME = join(dir, '.coderabbit');
33
54
  merged.XDG_CONFIG_HOME = join(dir, '.config');
34
55
  merged.XDG_CACHE_HOME = join(dir, '.cache');
@@ -50,12 +71,35 @@ export async function runCodeRabbitReview({
50
71
  }) {
51
72
  const homeDir = (env?.HAPPY_STACKS_CODERABBIT_HOME_DIR ?? env?.HAPPY_LOCAL_CODERABBIT_HOME_DIR ?? '').toString().trim();
52
73
  const args = buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles });
53
- const res = await runCaptureResult('coderabbit', args, {
54
- cwd: repoDir,
55
- env: buildCodeRabbitEnv({ env, homeDir }),
56
- streamLabel,
57
- teeFile,
58
- teeLabel,
59
- });
60
- return { ...res, stdout: res.out, stderr: res.err };
74
+ const maxAttempts = 50;
75
+ let last = null;
76
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
77
+ // eslint-disable-next-line no-await-in-loop
78
+ const res = await runCaptureResult('coderabbit', args, {
79
+ cwd: repoDir,
80
+ env: buildCodeRabbitEnv({ env, homeDir }),
81
+ streamLabel,
82
+ teeFile,
83
+ teeLabel,
84
+ });
85
+ last = res;
86
+ if (res.ok) return { ...res, stdout: res.out, stderr: res.err };
87
+
88
+ const retryMs = parseCodeRabbitRateLimitRetryMs(`${res.out ?? ''}\n${res.err ?? ''}`);
89
+ if (!retryMs) return { ...res, stdout: res.out, stderr: res.err };
90
+
91
+ const seconds = Math.ceil(retryMs / 1000);
92
+ const msg = `[review] coderabbit rate limited; retrying in ${seconds}s (attempt ${attempt}/${maxAttempts})\n`;
93
+ try {
94
+ if (teeFile) await appendFile(teeFile, msg);
95
+ } catch {
96
+ // ignore
97
+ }
98
+ // eslint-disable-next-line no-console
99
+ console.warn(msg.trimEnd());
100
+ // eslint-disable-next-line no-await-in-loop
101
+ await new Promise((r) => setTimeout(r, retryMs));
102
+ }
103
+
104
+ return { ...last, stdout: last?.out ?? '', stderr: last?.err ?? '' };
61
105
  }