happy-stacks 0.4.0 → 0.5.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.
Files changed (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -1,18 +1,29 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { coerceHappyMonorepoRootFromPath, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
6
6
  import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
7
7
  import { resolveBaseRef } from './utils/review/base_ref.mjs';
8
8
  import { isStackMode, resolveDefaultStackReviewComponents } from './utils/review/targets.mjs';
9
+ import { planCommitChunks } from './utils/review/chunks.mjs';
10
+ import { planPathSlices } from './utils/review/slices.mjs';
11
+ import { createHeadSliceCommits, getChangedOps } from './utils/review/head_slice.mjs';
9
12
  import { runWithConcurrencyLimit } from './utils/proc/parallel.mjs';
10
13
  import { runCodeRabbitReview } from './utils/review/runners/coderabbit.mjs';
11
14
  import { extractCodexReviewFromJsonl, runCodexReview } from './utils/review/runners/codex.mjs';
15
+ import { formatTriageMarkdown, parseCodeRabbitPlainOutput, parseCodexReviewText } from './utils/review/findings.mjs';
16
+ import { join } from 'node:path';
17
+ import { ensureDir } from './utils/fs/ops.mjs';
18
+ import { copyFile, writeFile } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { runCapture } from './utils/proc/proc.mjs';
12
21
 
13
22
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
14
23
  const VALID_COMPONENTS = DEFAULT_COMPONENTS;
15
24
  const VALID_REVIEWERS = ['coderabbit', 'codex'];
25
+ const VALID_DEPTHS = ['deep', 'normal'];
26
+ const DEFAULT_REVIEW_MAX_FILES = 50;
16
27
 
17
28
  function parseCsv(raw) {
18
29
  return String(raw ?? '')
@@ -31,7 +42,7 @@ function normalizeReviewers(list) {
31
42
  function usage() {
32
43
  return [
33
44
  '[review] usage:',
34
- ' happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--concurrency=N] [--json]',
45
+ ' happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--concurrency=N] [--depth=deep|normal] [--chunks|--no-chunks] [--chunking=auto|head-slice|commit-window] [--chunk-max-files=N] [--coderabbit-type=committed|uncommitted|all] [--coderabbit-max-files=N] [--coderabbit-chunks|--no-coderabbit-chunks] [--codex-chunks|--no-codex-chunks] [--run-label=<label>] [--no-stream] [--json]',
35
46
  '',
36
47
  'components:',
37
48
  ` ${VALID_COMPONENTS.join(' | ')}`,
@@ -39,6 +50,9 @@ function usage() {
39
50
  'reviewers:',
40
51
  ` ${VALID_REVIEWERS.join(' | ')}`,
41
52
  '',
53
+ 'depth:',
54
+ ` ${VALID_DEPTHS.join(' | ')}`,
55
+ '',
42
56
  'notes:',
43
57
  '- If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
44
58
  '- In stack mode (invoked via `happys stack review <stack>`), if no components are provided, defaults to stack-pinned non-default components only.',
@@ -59,10 +73,209 @@ function stackRemoteFallbackFromEnv(env) {
59
73
  return String(env.HAPPY_STACKS_STACK_REMOTE ?? env.HAPPY_LOCAL_STACK_REMOTE ?? '').trim();
60
74
  }
61
75
 
76
+ function sanitizeLabel(raw) {
77
+ return String(raw ?? '')
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9._-]+/g, '-')
81
+ .replace(/^-+|-+$/g, '');
82
+ }
83
+
84
+ function tailLines(text, n) {
85
+ const lines = String(text ?? '')
86
+ .split('\n')
87
+ .slice(-n)
88
+ .join('\n')
89
+ .trimEnd();
90
+ return lines;
91
+ }
92
+
93
+ function printReviewOperatorGuidance() {
94
+ // Guidance for the human/LLM running the review (not the reviewer model itself).
95
+ // eslint-disable-next-line no-console
96
+ console.log(
97
+ [
98
+ '[review] operator guidance:',
99
+ '- Treat reviewer output as suggestions; verify against best practices + this codebase before applying.',
100
+ '- Triage every single finding (no skipping): apply / adjust / defer-with-rationale.',
101
+ '- Do not apply changes blindly; when uncertain, record in the report for discussion.',
102
+ '- When a suggestion references external standards, verify via official docs (or note what you checked).',
103
+ '- Prefer unified fixes; avoid duplication; avoid brittle tests (no exact wording assertions).',
104
+ '- This command writes a triage checklist file; work through it item-by-item and record decisions + commits.',
105
+ '',
106
+ ].join('\n')
107
+ );
108
+ }
109
+
110
+ function codexScopePathForComponent(component) {
111
+ switch (component) {
112
+ case 'happy':
113
+ return 'expo-app';
114
+ case 'happy-cli':
115
+ return 'cli';
116
+ case 'happy-server-light':
117
+ case 'happy-server':
118
+ return 'server';
119
+ default:
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function buildCodexDeepPrompt({ component, baseRef }) {
125
+ const scopePath = codexScopePathForComponent(component);
126
+ const diffCmd = scopePath
127
+ ? `cd \"$(git rev-parse --show-toplevel)\" && git diff ${baseRef}...HEAD -- ${scopePath}/`
128
+ : `cd \"$(git rev-parse --show-toplevel)\" && git diff ${baseRef}...HEAD`;
129
+
130
+ return [
131
+ 'Run a deep, long-form code review.',
132
+ '',
133
+ `Base for review: ${baseRef}`,
134
+ scopePath ? `Scope: ${scopePath}/` : 'Scope: full repo (no path filter)',
135
+ '',
136
+ 'Instructions:',
137
+ `- Use: ${diffCmd}`,
138
+ '- Focus on correctness, edge cases, reliability, performance, and security.',
139
+ '- Prefer unified/coherent fixes; avoid duplication.',
140
+ '- Avoid brittle tests that assert on wording/phrasing/config; test real behavior and observable outcomes.',
141
+ '- Ensure i18n coverage is complete: do not introduce hardcoded user-visible strings; add translation keys across locales as needed.',
142
+ '- Treat every recommendation as a suggestion: validate it against best practices and this codebase’s existing patterns. Do not propose changes that violate project invariants.',
143
+ '- Be exhaustive: list all findings you notice, not only the highest-signal ones.',
144
+ '- Clearly mark any item that is uncertain, has tradeoffs, or needs product/UX decisions as "needs discussion".',
145
+ '',
146
+ 'Output format:',
147
+ '- Start with a short overall verdict.',
148
+ '- Then list findings as bullets with severity (blocker/major/minor/nit) and a concrete fix suggestion.',
149
+ '',
150
+ 'Machine-readable output (required):',
151
+ '- After your review, output a JSON array of findings preceded by a line containing exactly: ===FINDINGS_JSON===',
152
+ '- Each finding should include: severity, file, (optional) lines, title, description, recommendation, needsDiscussion (boolean).',
153
+ ].join('\n');
154
+ }
155
+
156
+ function buildCodexMonorepoSlicePrompt({ sliceLabel, baseCommit, baseRef }) {
157
+ const diffCmd = `cd \"$(git rev-parse --show-toplevel)\" && git diff ${baseCommit}...HEAD`;
158
+ return [
159
+ 'Run a deep, long-form code review on the monorepo.',
160
+ '',
161
+ `Base ref: ${baseRef}`,
162
+ `Slice: ${sliceLabel}`,
163
+ '',
164
+ 'Important:',
165
+ '- The base commit for this slice is synthetic: it represents upstream plus all NON-slice changes.',
166
+ '- Therefore, the diff below contains ONLY the changes for this slice, but the checked-out code is the full final HEAD.',
167
+ '',
168
+ 'Instructions:',
169
+ `- Use: ${diffCmd}`,
170
+ '- You may inspect any file in the repo for cross-references (server/cli/ui), but keep findings scoped to this slice diff.',
171
+ '- Focus on correctness, edge cases, reliability, performance, and security.',
172
+ '- Prefer unified/coherent fixes; avoid duplication.',
173
+ '- Avoid brittle tests that assert on wording/phrasing/config; test real behavior and observable outcomes.',
174
+ '- Ensure i18n coverage is complete: do not introduce hardcoded user-visible strings; add translation keys across locales as needed.',
175
+ '- Treat every recommendation as a suggestion: validate it against best practices and this codebase’s existing patterns. Do not propose changes that violate project invariants.',
176
+ '- Be exhaustive within this slice: list all findings you notice, not only the highest-signal ones.',
177
+ '- Clearly mark any item that is uncertain, has tradeoffs, or needs product/UX decisions as "needs discussion".',
178
+ '',
179
+ 'Output format:',
180
+ '- Start with a short overall verdict.',
181
+ '- Then list findings as bullets with severity (blocker/major/minor/nit) and a concrete fix suggestion.',
182
+ '',
183
+ 'Machine-readable output (required):',
184
+ '- After your review, output a JSON array of findings preceded by a line containing exactly: ===FINDINGS_JSON===',
185
+ '- Each finding should include: severity, file, (optional) lines, title, description, recommendation, needsDiscussion (boolean).',
186
+ ].join('\n');
187
+ }
188
+
189
+ async function gitLines({ cwd, args, env }) {
190
+ const out = await runCapture('git', args, { cwd, env });
191
+ return String(out ?? '')
192
+ .split('\n')
193
+ .map((l) => l.trimEnd())
194
+ .filter(Boolean);
195
+ }
196
+
197
+ async function countChangedFiles({ cwd, base, env }) {
198
+ const lines = await gitLines({ cwd, env, args: ['diff', '--name-only', `${base}...HEAD`] });
199
+ return lines.length;
200
+ }
201
+
202
+ async function countChangedFilesBetween({ cwd, base, head, env }) {
203
+ const lines = await gitLines({ cwd, env, args: ['diff', '--name-only', `${base}...${head}`] });
204
+ return lines.length;
205
+ }
206
+
207
+ async function mergeBase({ cwd, a, b, env }) {
208
+ const out = await runCapture('git', ['merge-base', a, b], { cwd, env });
209
+ const mb = String(out ?? '').trim();
210
+ if (!mb) throw new Error('[review] failed to compute merge-base');
211
+ return mb;
212
+ }
213
+
214
+ async function listCommitsBetween({ cwd, base, head, env }) {
215
+ return await gitLines({ cwd, env, args: ['rev-list', '--reverse', `${base}..${head}`] });
216
+ }
217
+
218
+ async function withDetachedWorktree({ repoDir, headCommit, label, env }, fn) {
219
+ const root = (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: repoDir, env })).toString().trim();
220
+ if (!root) throw new Error('[review] failed to resolve git toplevel');
221
+
222
+ const safeLabel = String(label ?? 'worktree')
223
+ .toLowerCase()
224
+ .replace(/[^a-z0-9._-]+/g, '-')
225
+ .replace(/^-+|-+$/g, '');
226
+ const short = String(headCommit).slice(0, 12);
227
+ const dir = join(root, '.project', 'review-worktrees', `${safeLabel}-${short}`);
228
+
229
+ await ensureDir(join(root, '.project', 'review-worktrees'));
230
+
231
+ try {
232
+ await runCapture('git', ['worktree', 'add', '--detach', dir, headCommit], { cwd: repoDir, env });
233
+ return await fn(dir);
234
+ } finally {
235
+ try {
236
+ await runCapture('git', ['worktree', 'remove', '--force', dir], { cwd: repoDir, env });
237
+ await runCapture('git', ['worktree', 'prune'], { cwd: repoDir, env });
238
+ } catch {
239
+ // best-effort cleanup; leave an orphaned worktree if needed
240
+ }
241
+ }
242
+ }
243
+
244
+ async function pickCoderabbitBaseCommitForMaxFiles({ cwd, baseRef, maxFiles, env }) {
245
+ const commits = await gitLines({ cwd, env, args: ['rev-list', '--reverse', `${baseRef}..HEAD`] });
246
+ if (!commits.length) return null;
247
+
248
+ let lo = 0;
249
+ let hi = commits.length - 1;
250
+ let best = null;
251
+
252
+ while (lo <= hi) {
253
+ const mid = Math.floor((lo + hi) / 2);
254
+ const startCommit = commits[mid];
255
+ let baseCommit = '';
256
+ try {
257
+ baseCommit = (await runCapture('git', ['rev-parse', `${startCommit}^`], { cwd, env })).toString().trim();
258
+ } catch {
259
+ baseCommit = (await runCapture('git', ['rev-parse', startCommit], { cwd, env })).toString().trim();
260
+ }
261
+
262
+ const n = await countChangedFiles({ cwd, env, base: baseCommit });
263
+ if (n <= maxFiles) {
264
+ best = baseCommit;
265
+ hi = mid - 1;
266
+ } else {
267
+ lo = mid + 1;
268
+ }
269
+ }
270
+
271
+ return best;
272
+ }
273
+
62
274
  async function main() {
63
275
  const argv = process.argv.slice(2);
64
276
  const { flags, kv } = parseArgs(argv);
65
277
  const json = wantsJson(argv, { flags });
278
+ const stream = !json && !flags.has('--no-stream');
66
279
 
67
280
  if (wantsHelp(argv, { flags })) {
68
281
  printResult({ json, data: { usage: usage() }, text: usage() });
@@ -120,18 +333,109 @@ async function main() {
120
333
  const stackRemoteFallback = stackRemoteFallbackFromEnv(process.env);
121
334
  const concurrency = (kv.get('--concurrency') ?? '').trim();
122
335
  const limit = concurrency ? Number(concurrency) : 4;
336
+ const depth = (kv.get('--depth') ?? 'deep').toString().trim().toLowerCase();
337
+ const coderabbitType = (kv.get('--coderabbit-type') ?? 'committed').toString().trim().toLowerCase();
338
+ const chunkingMode = (kv.get('--chunking') ?? 'auto').toString().trim().toLowerCase();
339
+ const chunkMaxFilesRaw = (kv.get('--chunk-max-files') ?? '').toString().trim();
340
+ const coderabbitMaxFilesRaw = (kv.get('--coderabbit-max-files') ?? '').toString().trim();
341
+ const coderabbitMaxFiles = coderabbitMaxFilesRaw ? Number(coderabbitMaxFilesRaw) : DEFAULT_REVIEW_MAX_FILES;
342
+ const chunkMaxFiles = chunkMaxFilesRaw ? Number(chunkMaxFilesRaw) : coderabbitMaxFiles;
343
+ const globalChunks = flags.has('--chunks') ? true : flags.has('--no-chunks') ? false : null;
344
+ const coderabbitChunksOverride = flags.has('--coderabbit-chunks')
345
+ ? true
346
+ : flags.has('--no-coderabbit-chunks')
347
+ ? false
348
+ : null;
349
+ const codexChunksOverride = flags.has('--codex-chunks') ? true : flags.has('--no-codex-chunks') ? false : null;
350
+ if (!VALID_DEPTHS.includes(depth)) {
351
+ throw new Error(`[review] invalid --depth=${depth} (expected: ${VALID_DEPTHS.join(' | ')})`);
352
+ }
353
+ if (!['auto', 'head-slice', 'commit-window'].includes(chunkingMode)) {
354
+ throw new Error('[review] invalid --chunking (expected: auto|head-slice|commit-window)');
355
+ }
356
+
357
+ const deepInstructionsPath = join(rootDir, 'scripts', 'utils', 'review', 'instructions', 'deep.md');
358
+ const coderabbitConfigFiles = depth === 'deep' ? [deepInstructionsPath] : [];
359
+
360
+ if (reviewers.includes('coderabbit')) {
361
+ const coderabbitHomeKey = 'HAPPY_STACKS_CODERABBIT_HOME_DIR';
362
+ if (!(process.env[coderabbitHomeKey] ?? '').toString().trim()) {
363
+ process.env[coderabbitHomeKey] = join(rootDir, '.project', 'coderabbit-home');
364
+ }
365
+ await ensureDir(process.env[coderabbitHomeKey]);
366
+ }
367
+
368
+ if (reviewers.includes('codex')) {
369
+ const codexHomeKey = 'HAPPY_STACKS_CODEX_HOME_DIR';
370
+ if (!(process.env[codexHomeKey] ?? '').toString().trim()) {
371
+ process.env[codexHomeKey] = join(rootDir, '.project', 'codex-home');
372
+ }
373
+ await ensureDir(process.env[codexHomeKey]);
374
+
375
+ if (!(process.env.HAPPY_STACKS_CODEX_SANDBOX ?? '').toString().trim()) {
376
+ process.env.HAPPY_STACKS_CODEX_SANDBOX = 'workspace-write';
377
+ }
378
+
379
+ // Seed Codex auth/config into the isolated CODEX_HOME to avoid sandbox permission issues
380
+ // writing under the real ~/.codex. We never print or inspect auth contents.
381
+ try {
382
+ const realHome = (process.env.HOME ?? '').toString().trim();
383
+ const overrideHome = process.env[codexHomeKey];
384
+ if (realHome && overrideHome && realHome !== overrideHome) {
385
+ const srcAuth = join(realHome, '.codex', 'auth.json');
386
+ const srcCfg = join(realHome, '.codex', 'config.toml');
387
+ const destAuth = join(overrideHome, 'auth.json');
388
+ const destCfg = join(overrideHome, 'config.toml');
389
+ if (existsSync(srcAuth) && !existsSync(destAuth)) await copyFile(srcAuth, destAuth);
390
+ if (existsSync(srcCfg) && !existsSync(destCfg)) await copyFile(srcCfg, destCfg);
391
+ }
392
+ } catch {
393
+ // ignore (codex will surface auth issues if seeding fails)
394
+ }
395
+ }
396
+
397
+ if (stream) {
398
+ // eslint-disable-next-line no-console
399
+ console.log('[review] note: this can take a long time (up to 60+ minutes per reviewer). No timeout is enforced.');
400
+ printReviewOperatorGuidance();
401
+ }
123
402
 
124
- const jobs = [];
125
- for (const component of components) {
126
- const repoDir = getComponentDir(rootDir, component);
127
- jobs.push({ component, repoDir });
403
+ const resolved = components.map((component) => ({ component, repoDir: getComponentDir(rootDir, component) }));
404
+ const monoRoots = new Set(resolved.map((x) => coerceHappyMonorepoRootFromPath(x.repoDir)).filter(Boolean));
405
+ if (monoRoots.size > 1) {
406
+ const roots = Array.from(monoRoots).sort();
407
+ throw new Error(
408
+ `[review] multiple monorepo roots detected across selected component dirs:\n` +
409
+ roots.map((r) => `- ${r}`).join('\n') +
410
+ `\n\n` +
411
+ `Fix: ensure all monorepo components (happy/happy-cli/happy-server(-light)) point at the same worktree.\n` +
412
+ `- Stack mode: use \`happys stack wt <stack> -- use happy <worktree>\` (monorepo-aware)\n` +
413
+ `- One-shot: pass --happy=... --happy-cli=... --happy-server-light=... all pointing into the same monorepo worktree`
414
+ );
128
415
  }
416
+ const monorepoRoot = monoRoots.size === 1 ? Array.from(monoRoots)[0] : null;
417
+
418
+ const jobs = monorepoRoot
419
+ ? [{ component: 'monorepo', repoDir: monorepoRoot, monorepo: true }]
420
+ : resolved.map((x) => ({ component: x.component, repoDir: x.repoDir, monorepo: false }));
421
+
422
+ // Review artifacts: always create a per-run directory containing raw outputs + a triage checklist.
423
+ const reviewsRootDir = join(rootDir, '.project', 'reviews');
424
+ await ensureDir(reviewsRootDir);
425
+ const runLabelOverride = (kv.get('--run-label') ?? '').toString().trim();
426
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
427
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').toString().trim();
428
+ const defaultLabel = `review-${ts}${stackName ? `-${sanitizeLabel(stackName)}` : ''}`;
429
+ const runLabel = sanitizeLabel(runLabelOverride || defaultLabel) || defaultLabel;
430
+ const runDir = join(reviewsRootDir, runLabel);
431
+ await ensureDir(runDir);
432
+ await ensureDir(join(runDir, 'raw'));
129
433
 
130
434
  const jobResults = await runWithConcurrencyLimit({
131
435
  items: jobs,
132
436
  limit,
133
437
  fn: async (job) => {
134
- const { component, repoDir } = job;
438
+ const { component, repoDir, monorepo } = job;
135
439
  const base = await resolveBaseRef({
136
440
  cwd: repoDir,
137
441
  baseRefOverride,
@@ -140,23 +444,294 @@ async function main() {
140
444
  stackRemoteFallback,
141
445
  });
142
446
 
447
+ const maxFiles = Number.isFinite(chunkMaxFiles) && chunkMaxFiles > 0 ? chunkMaxFiles : 300;
448
+ const wantChunksCoderabbit = coderabbitChunksOverride ?? globalChunks;
449
+ const wantChunksCodex = codexChunksOverride ?? globalChunks;
450
+ const effectiveChunking = chunkingMode === 'auto' ? (monorepo ? 'head-slice' : 'commit-window') : chunkingMode;
451
+
452
+ if (monorepo && stream) {
453
+ // eslint-disable-next-line no-console
454
+ console.log(`[review] monorepo detected at ${repoDir}; running a single unified review (chunking=${effectiveChunking}).`);
455
+ }
456
+
143
457
  const perReviewer = await Promise.all(
144
458
  reviewers.map(async (reviewer) => {
145
459
  if (reviewer === 'coderabbit') {
146
- const res = await runCodeRabbitReview({ repoDir, baseRef: base.baseRef, env: process.env });
460
+ const fileCount = await countChangedFiles({ cwd: repoDir, env: process.env, base: base.baseRef });
461
+ const autoChunks = fileCount > maxFiles;
462
+
463
+ let coderabbitBaseCommit = null;
464
+ let note = '';
465
+
466
+ // Monorepo: prefer HEAD-sliced chunking so each slice is reviewed in the final HEAD state.
467
+ if (monorepo && effectiveChunking === 'head-slice' && (wantChunksCoderabbit ?? autoChunks)) {
468
+ const headCommit = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: repoDir, env: process.env })).trim();
469
+ const baseCommit = (await runCapture('git', ['rev-parse', base.baseRef], { cwd: repoDir, env: process.env })).trim();
470
+ const ops = await getChangedOps({ cwd: repoDir, baseRef: baseCommit, headRef: headCommit, env: process.env });
471
+ const slices = planPathSlices({ changedPaths: Array.from(ops.all), maxFiles });
472
+
473
+ const sliceResults = [];
474
+ for (let i = 0; i < slices.length; i += 1) {
475
+ const slice = slices[i];
476
+ const logFile = join(runDir, 'raw', `coderabbit-slice-${i + 1}-of-${slices.length}-${sanitizeLabel(slice.label)}.log`);
477
+ // eslint-disable-next-line no-await-in-loop
478
+ const rr = await withDetachedWorktree(
479
+ { repoDir, headCommit: baseCommit, label: `coderabbit-${i + 1}-of-${slices.length}`, env: process.env },
480
+ async (worktreeDir) => {
481
+ const { baseSliceCommit } = await createHeadSliceCommits({
482
+ cwd: worktreeDir,
483
+ env: process.env,
484
+ baseRef: baseCommit,
485
+ headCommit,
486
+ ops,
487
+ slicePaths: slice.paths,
488
+ label: slice.label.replace(/\/+$/g, ''),
489
+ });
490
+ return await runCodeRabbitReview({
491
+ repoDir: worktreeDir,
492
+ baseRef: null,
493
+ baseCommit: baseSliceCommit,
494
+ env: process.env,
495
+ type: coderabbitType,
496
+ configFiles: coderabbitConfigFiles,
497
+ streamLabel: stream ? `monorepo:coderabbit:${i + 1}/${slices.length}` : undefined,
498
+ teeFile: logFile,
499
+ teeLabel: `monorepo:coderabbit:${i + 1}/${slices.length}`,
500
+ });
501
+ }
502
+ );
503
+ sliceResults.push({
504
+ index: i + 1,
505
+ of: slices.length,
506
+ slice: slice.label,
507
+ fileCount: slice.paths.length,
508
+ logFile,
509
+ ok: Boolean(rr.ok),
510
+ exitCode: rr.exitCode,
511
+ signal: rr.signal,
512
+ durationMs: rr.durationMs,
513
+ stdout: rr.stdout ?? '',
514
+ stderr: rr.stderr ?? '',
515
+ });
516
+ }
517
+
518
+ const okAll = sliceResults.every((r) => r.ok);
519
+ return {
520
+ reviewer,
521
+ ok: okAll,
522
+ exitCode: okAll ? 0 : 1,
523
+ signal: null,
524
+ durationMs: sliceResults.reduce((acc, r) => acc + (r.durationMs ?? 0), 0),
525
+ stdout: '',
526
+ stderr: '',
527
+ note: `monorepo head-slice: ${sliceResults.length} slices (maxFiles=${maxFiles})`,
528
+ slices: sliceResults,
529
+ };
530
+ }
531
+
532
+ // Non-monorepo or non-sliced: optionally chunk by commit windows (older behavior).
533
+ if (fileCount > maxFiles && effectiveChunking === 'commit-window' && (wantChunksCoderabbit ?? false)) {
534
+ // fall through to commit-window chunking below
535
+ } else if (fileCount > maxFiles && (wantChunksCoderabbit === false || wantChunksCoderabbit == null)) {
536
+ coderabbitBaseCommit = await pickCoderabbitBaseCommitForMaxFiles({
537
+ cwd: repoDir,
538
+ env: process.env,
539
+ baseRef: base.baseRef,
540
+ maxFiles,
541
+ });
542
+ note = coderabbitBaseCommit
543
+ ? `diff too large (${fileCount} files vs limit ${maxFiles}); using --base-commit ${coderabbitBaseCommit} for a partial review`
544
+ : `diff too large (${fileCount} files vs limit ${maxFiles}); unable to pick a --base-commit automatically`;
545
+ // eslint-disable-next-line no-console
546
+ console.log(`[review] coderabbit: ${note}`);
547
+ }
548
+
549
+ if (!(fileCount > maxFiles && effectiveChunking === 'commit-window' && (wantChunksCoderabbit ?? false))) {
550
+ const logFile = join(runDir, 'raw', `coderabbit-${sanitizeLabel(component)}.log`);
551
+ const res = await runCodeRabbitReview({
552
+ repoDir,
553
+ baseRef: coderabbitBaseCommit ? null : base.baseRef,
554
+ baseCommit: coderabbitBaseCommit,
555
+ env: process.env,
556
+ type: coderabbitType,
557
+ configFiles: coderabbitConfigFiles,
558
+ streamLabel: stream ? `${component}:coderabbit` : undefined,
559
+ teeFile: logFile,
560
+ teeLabel: `${component}:coderabbit`,
561
+ });
562
+ return {
563
+ reviewer,
564
+ ok: Boolean(res.ok),
565
+ exitCode: res.exitCode,
566
+ signal: res.signal,
567
+ durationMs: res.durationMs,
568
+ stdout: res.stdout ?? '',
569
+ stderr: res.stderr ?? '',
570
+ note,
571
+ logFile,
572
+ };
573
+ }
574
+
575
+ // Chunked mode: split the commit range into <=maxFiles windows and review each window by
576
+ // running CodeRabbit in a detached worktree checked out at the window head.
577
+ const mb = await mergeBase({ cwd: repoDir, env: process.env, a: base.baseRef, b: 'HEAD' });
578
+ const commits = await listCommitsBetween({ cwd: repoDir, env: process.env, base: mb, head: 'HEAD' });
579
+ const planned = await planCommitChunks({
580
+ baseCommit: mb,
581
+ commits,
582
+ maxFiles,
583
+ countFilesBetween: async ({ base: baseCommit, head }) =>
584
+ await countChangedFilesBetween({ cwd: repoDir, env: process.env, base: baseCommit, head }),
585
+ });
586
+
587
+ const chunks = planned.map((ch) => ({
588
+ baseCommit: ch.base,
589
+ headCommit: ch.head,
590
+ fileCount: ch.fileCount,
591
+ overLimit: Boolean(ch.overLimit),
592
+ }));
593
+
594
+ const chunkResults = [];
595
+ for (let i = 0; i < chunks.length; i += 1) {
596
+ const ch = chunks[i];
597
+ const logFile = join(
598
+ runDir,
599
+ 'raw',
600
+ `coderabbit-${sanitizeLabel(component)}-window-${i + 1}-of-${chunks.length}-${String(ch.headCommit).slice(0, 12)}.log`
601
+ );
602
+ // eslint-disable-next-line no-await-in-loop
603
+ const rr = await withDetachedWorktree(
604
+ { repoDir, headCommit: ch.headCommit, label: `coderabbit-${component}-${i + 1}-of-${chunks.length}`, env: process.env },
605
+ async (worktreeDir) => {
606
+ return await runCodeRabbitReview({
607
+ repoDir: worktreeDir,
608
+ baseRef: null,
609
+ baseCommit: ch.baseCommit,
610
+ env: process.env,
611
+ type: coderabbitType,
612
+ configFiles: coderabbitConfigFiles,
613
+ streamLabel: stream ? `${component}:coderabbit:${i + 1}/${chunks.length}` : undefined,
614
+ teeFile: logFile,
615
+ teeLabel: `${component}:coderabbit:${i + 1}/${chunks.length}`,
616
+ });
617
+ }
618
+ );
619
+ chunkResults.push({
620
+ index: i + 1,
621
+ of: chunks.length,
622
+ baseCommit: ch.baseCommit,
623
+ headCommit: ch.headCommit,
624
+ fileCount: ch.fileCount,
625
+ overLimit: ch.overLimit,
626
+ logFile,
627
+ ok: Boolean(rr.ok),
628
+ exitCode: rr.exitCode,
629
+ signal: rr.signal,
630
+ durationMs: rr.durationMs,
631
+ stdout: rr.stdout ?? '',
632
+ stderr: rr.stderr ?? '',
633
+ });
634
+ }
635
+
636
+ const okAll = chunkResults.every((r) => r.ok);
147
637
  return {
148
638
  reviewer,
149
- ok: Boolean(res.ok),
150
- exitCode: res.exitCode,
151
- signal: res.signal,
152
- durationMs: res.durationMs,
153
- stdout: res.stdout ?? '',
154
- stderr: res.stderr ?? '',
639
+ ok: okAll,
640
+ exitCode: okAll ? 0 : 1,
641
+ signal: null,
642
+ durationMs: chunkResults.reduce((acc, r) => acc + (r.durationMs ?? 0), 0),
643
+ stdout: '',
644
+ stderr: '',
645
+ note: `chunked: ${chunkResults.length} windows (maxFiles=${maxFiles})`,
646
+ chunks: chunkResults,
155
647
  };
156
648
  }
157
649
  if (reviewer === 'codex') {
158
- const res = await runCodexReview({ repoDir, baseRef: base.baseRef, env: process.env, jsonMode: true });
159
- const extracted = extractCodexReviewFromJsonl(res.stdout ?? '');
650
+ const jsonMode = json;
651
+ const usePromptMode = depth === 'deep';
652
+ const fileCount = await countChangedFiles({ cwd: repoDir, env: process.env, base: base.baseRef });
653
+ const autoChunks = usePromptMode && fileCount > maxFiles;
654
+
655
+ if (monorepo && effectiveChunking === 'head-slice' && usePromptMode && (wantChunksCodex ?? autoChunks)) {
656
+ const headCommit = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: repoDir, env: process.env })).trim();
657
+ const baseCommit = (await runCapture('git', ['rev-parse', base.baseRef], { cwd: repoDir, env: process.env })).trim();
658
+ const ops = await getChangedOps({ cwd: repoDir, baseRef: baseCommit, headRef: headCommit, env: process.env });
659
+ const slices = planPathSlices({ changedPaths: Array.from(ops.all), maxFiles });
660
+
661
+ const sliceResults = [];
662
+ for (let i = 0; i < slices.length; i += 1) {
663
+ const slice = slices[i];
664
+ const logFile = join(runDir, 'raw', `codex-slice-${i + 1}-of-${slices.length}-${sanitizeLabel(slice.label)}.log`);
665
+ // eslint-disable-next-line no-await-in-loop
666
+ const rr = await withDetachedWorktree(
667
+ { repoDir, headCommit: baseCommit, label: `codex-${i + 1}-of-${slices.length}`, env: process.env },
668
+ async (worktreeDir) => {
669
+ const { baseSliceCommit } = await createHeadSliceCommits({
670
+ cwd: worktreeDir,
671
+ env: process.env,
672
+ baseRef: baseCommit,
673
+ headCommit,
674
+ ops,
675
+ slicePaths: slice.paths,
676
+ label: slice.label.replace(/\/+$/g, ''),
677
+ });
678
+ const prompt = buildCodexMonorepoSlicePrompt({ sliceLabel: slice.label, baseCommit: baseSliceCommit, baseRef: base.baseRef });
679
+ return await runCodexReview({
680
+ repoDir: worktreeDir,
681
+ baseRef: null,
682
+ env: process.env,
683
+ jsonMode,
684
+ prompt,
685
+ streamLabel: stream && !jsonMode ? `monorepo:codex:${i + 1}/${slices.length}` : undefined,
686
+ teeFile: logFile,
687
+ teeLabel: `monorepo:codex:${i + 1}/${slices.length}`,
688
+ });
689
+ }
690
+ );
691
+ const extracted = jsonMode ? extractCodexReviewFromJsonl(rr.stdout ?? '') : null;
692
+ sliceResults.push({
693
+ index: i + 1,
694
+ of: slices.length,
695
+ slice: slice.label,
696
+ fileCount: slice.paths.length,
697
+ logFile,
698
+ ok: Boolean(rr.ok),
699
+ exitCode: rr.exitCode,
700
+ signal: rr.signal,
701
+ durationMs: rr.durationMs,
702
+ stdout: rr.stdout ?? '',
703
+ stderr: rr.stderr ?? '',
704
+ review_output: extracted,
705
+ });
706
+ }
707
+
708
+ const okAll = sliceResults.every((r) => r.ok);
709
+ return {
710
+ reviewer,
711
+ ok: okAll,
712
+ exitCode: okAll ? 0 : 1,
713
+ signal: null,
714
+ durationMs: sliceResults.reduce((acc, r) => acc + (r.durationMs ?? 0), 0),
715
+ stdout: '',
716
+ stderr: '',
717
+ note: `monorepo head-slice: ${sliceResults.length} slices (maxFiles=${maxFiles})`,
718
+ slices: sliceResults,
719
+ };
720
+ }
721
+
722
+ const prompt = usePromptMode ? buildCodexDeepPrompt({ component, baseRef: base.baseRef }) : '';
723
+ const logFile = join(runDir, 'raw', `codex-${sanitizeLabel(component)}.log`);
724
+ const res = await runCodexReview({
725
+ repoDir,
726
+ baseRef: usePromptMode ? null : base.baseRef,
727
+ env: process.env,
728
+ jsonMode,
729
+ prompt,
730
+ streamLabel: stream && !jsonMode ? `${component}:codex` : undefined,
731
+ teeFile: logFile,
732
+ teeLabel: `${component}:codex`,
733
+ });
734
+ const extracted = jsonMode ? extractCodexReviewFromJsonl(res.stdout ?? '') : null;
160
735
  return {
161
736
  reviewer,
162
737
  ok: Boolean(res.ok),
@@ -166,6 +741,7 @@ async function main() {
166
741
  stdout: res.stdout ?? '',
167
742
  stderr: res.stderr ?? '',
168
743
  review_output: extracted,
744
+ logFile,
169
745
  };
170
746
  }
171
747
  return { reviewer, ok: false, exitCode: null, signal: null, durationMs: 0, stdout: '', stderr: 'unknown reviewer\n' };
@@ -176,6 +752,119 @@ async function main() {
176
752
  },
177
753
  });
178
754
 
755
+ // Persist a structured triage checklist for the operator (human/LLM) to work through.
756
+ try {
757
+ const meta = {
758
+ runLabel,
759
+ startedAt: ts,
760
+ stackName: stackName || null,
761
+ reviewers,
762
+ jobs: jobs.map((j) => ({ component: j.component, repoDir: j.repoDir, monorepo: j.monorepo })),
763
+ depth,
764
+ chunkMaxFiles: Number.isFinite(chunkMaxFiles) ? chunkMaxFiles : null,
765
+ coderabbitMaxFiles,
766
+ chunkingMode,
767
+ argv,
768
+ };
769
+ await writeFile(join(runDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
770
+
771
+ const allFindings = [];
772
+ let cr = 0;
773
+ let cx = 0;
774
+
775
+ for (const job of jobResults) {
776
+ for (const rr of job.results) {
777
+ if (rr.reviewer === 'coderabbit') {
778
+ const sliceLike = rr.slices ?? rr.chunks ?? null;
779
+ if (Array.isArray(sliceLike)) {
780
+ for (const s of sliceLike) {
781
+ const parsed = parseCodeRabbitPlainOutput(s.stdout ?? '');
782
+ for (const f of parsed) {
783
+ cr += 1;
784
+ allFindings.push({
785
+ ...f,
786
+ id: `CR-${String(cr).padStart(3, '0')}`,
787
+ job: job.component,
788
+ slice: s.slice ?? `${s.index}/${s.of}`,
789
+ sourceLog: s.logFile ?? null,
790
+ });
791
+ }
792
+ }
793
+ } else {
794
+ const parsed = parseCodeRabbitPlainOutput(rr.stdout ?? '');
795
+ for (const f of parsed) {
796
+ cr += 1;
797
+ allFindings.push({
798
+ ...f,
799
+ id: `CR-${String(cr).padStart(3, '0')}`,
800
+ job: job.component,
801
+ slice: null,
802
+ sourceLog: rr.logFile ?? null,
803
+ });
804
+ }
805
+ }
806
+ }
807
+
808
+ if (rr.reviewer === 'codex') {
809
+ const sliceLike = rr.slices ?? rr.chunks ?? null;
810
+ const consumeText = (reviewText, slice, sourceLog) => {
811
+ const parsed = parseCodexReviewText(reviewText);
812
+ for (const f of parsed) {
813
+ cx += 1;
814
+ allFindings.push({
815
+ ...f,
816
+ id: `CX-${String(cx).padStart(3, '0')}`,
817
+ job: job.component,
818
+ slice,
819
+ sourceLog: sourceLog ?? null,
820
+ });
821
+ }
822
+ };
823
+
824
+ if (Array.isArray(sliceLike)) {
825
+ for (const s of sliceLike) {
826
+ const reviewText = s.review_output ?? extractCodexReviewFromJsonl(s.stdout ?? '') ?? (s.stdout ?? '');
827
+ consumeText(reviewText, s.slice ?? `${s.index}/${s.of}`, s.logFile ?? null);
828
+ }
829
+ } else {
830
+ const reviewText = rr.review_output ?? extractCodexReviewFromJsonl(rr.stdout ?? '') ?? (rr.stdout ?? '');
831
+ consumeText(reviewText, null, rr.logFile ?? null);
832
+ }
833
+ }
834
+ }
835
+ }
836
+
837
+ await writeFile(join(runDir, 'findings.json'), JSON.stringify(allFindings, null, 2), 'utf-8');
838
+ const triage = formatTriageMarkdown({ runLabel, baseRef: jobResults?.[0]?.base?.baseRef ?? '', findings: allFindings });
839
+ await writeFile(join(runDir, 'triage.md'), triage, 'utf-8');
840
+
841
+ if (stream) {
842
+ // eslint-disable-next-line no-console
843
+ console.log(`[review] trust/triage checklist (READ THIS NEXT): ${join(runDir, 'triage.md')}`);
844
+ // eslint-disable-next-line no-console
845
+ console.log(`[review] findings (raw, parsed): ${join(runDir, 'findings.json')}`);
846
+ // eslint-disable-next-line no-console
847
+ console.log(`[review] raw outputs: ${join(runDir, 'raw')}`);
848
+ // eslint-disable-next-line no-console
849
+ console.log(
850
+ [
851
+ '[review] next steps (mandatory):',
852
+ `- STOP: open ${join(runDir, 'triage.md')} now and load it into your context before doing anything else.`,
853
+ `- Then load ${join(runDir, 'findings.json')} (full parsed finding details + source logs).`,
854
+ `- Treat reviewer output as suggestions: verify against codebase invariants + best practices (use web search when needed) before applying.`,
855
+ `- For each finding: verify in the validation worktree, decide apply/adjust/defer, and record rationale + commit refs in triage.md.`,
856
+ `- For tests: validate behavior/logic; avoid brittle "wording/policing" assertions.`,
857
+ `- Do not start a new review run until the checklist has no remaining TBD decisions.`,
858
+ ].join('\n')
859
+ );
860
+ }
861
+ } catch (e) {
862
+ if (stream) {
863
+ // eslint-disable-next-line no-console
864
+ console.warn('[review] warning: failed to write triage artifacts:', e);
865
+ }
866
+ }
867
+
179
868
  const ok = jobResults.every((r) => r.results.every((x) => x.ok));
180
869
  if (json) {
181
870
  printResult({ json, data: { ok, reviewers, components, results: jobResults } });
@@ -194,13 +883,16 @@ async function main() {
194
883
  lines.push('');
195
884
  const status = rr.ok ? '✅ ok' : '❌ failed';
196
885
  lines.push(`[${rr.reviewer}] ${status} (exit=${rr.exitCode ?? 'null'} durMs=${rr.durationMs ?? '?'})`);
197
- if (rr.stderr) {
198
- lines.push('--- stderr ---');
199
- lines.push(String(rr.stderr).trimEnd());
200
- }
201
- if (rr.stdout) {
202
- lines.push('--- stdout ---');
203
- lines.push(String(rr.stdout).trimEnd());
886
+ if (rr.note) lines.push(`note: ${rr.note}`);
887
+ if (!rr.ok) {
888
+ if (rr.stderr) {
889
+ lines.push('--- stderr (tail) ---');
890
+ lines.push(tailLines(rr.stderr, 120));
891
+ }
892
+ if (rr.stdout) {
893
+ lines.push('--- stdout (tail) ---');
894
+ lines.push(tailLines(rr.stdout, 120));
895
+ }
204
896
  }
205
897
  }
206
898
  lines.push('');
@@ -214,4 +906,3 @@ main().catch((err) => {
214
906
  console.error('[review] failed:', err);
215
907
  process.exit(1);
216
908
  });
217
-