happy-stacks 0.6.12 → 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 (59) 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 +1236 -92
  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/provision/linux-ubuntu-review-pr.sh +5 -1
  35. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  36. package/scripts/review.mjs +347 -124
  37. package/scripts/review_pr.mjs +78 -2
  38. package/scripts/run.mjs +2 -1
  39. package/scripts/stack.mjs +265 -19
  40. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  41. package/scripts/stack_happy_cmd.test.mjs +103 -0
  42. package/scripts/utils/cli/prereqs.mjs +12 -1
  43. package/scripts/utils/dev/daemon.mjs +3 -1
  44. package/scripts/utils/proc/pm.mjs +1 -1
  45. package/scripts/utils/review/detached_worktree.mjs +61 -0
  46. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  47. package/scripts/utils/review/findings.mjs +133 -20
  48. package/scripts/utils/review/findings.test.mjs +88 -1
  49. package/scripts/utils/review/runners/augment.mjs +71 -0
  50. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  51. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  52. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  53. package/scripts/utils/review/sliced_runner.mjs +39 -0
  54. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  55. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  56. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  57. package/scripts/utils/stack/cli_identities.mjs +29 -0
  58. package/scripts/utils/stack/startup.mjs +45 -7
  59. package/scripts/worktrees.mjs +8 -5
@@ -18,6 +18,14 @@ function runNode(args, { cwd, env }) {
18
18
  });
19
19
  }
20
20
 
21
+ async function writeDummyAuth({ cliHomeDir }) {
22
+ // For these tests, we don't care about the auth format—only that credentials exist.
23
+ // Happy Stacks will short-circuit daemon start when access.key is missing.
24
+ await mkdir(cliHomeDir, { recursive: true });
25
+ await writeFile(join(cliHomeDir, 'access.key'), 'dummy\n', 'utf-8');
26
+ await writeFile(join(cliHomeDir, 'settings.json'), JSON.stringify({ machineId: 'test-machine' }) + '\n', 'utf-8');
27
+ }
28
+
21
29
  async function writeStubHappyCli({ cliDir }) {
22
30
  await mkdir(join(cliDir, 'bin'), { recursive: true });
23
31
  await mkdir(join(cliDir, 'dist'), { recursive: true });
@@ -107,6 +115,7 @@ test('happys stack daemon <name> restart restarts only the daemon', async () =>
107
115
  const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
108
116
  const stackCliHome = join(storageDir, stackName, 'cli');
109
117
  await mkdir(stackCliHome, { recursive: true });
118
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
110
119
 
111
120
  const envPath = join(storageDir, stackName, 'env');
112
121
  await mkdir(dirname(envPath), { recursive: true });
@@ -162,3 +171,190 @@ test('happys stack daemon <name> restart restarts only the daemon', async () =>
162
171
  await rm(tmp, { recursive: true, force: true });
163
172
  });
164
173
 
174
+ test('happys stack <name> daemon start works (stack name first)', async () => {
175
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
176
+ const rootDir = dirname(scriptsDir);
177
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-daemon-name-first-'));
178
+
179
+ const storageDir = join(tmp, 'storage');
180
+ const homeDir = join(tmp, 'home');
181
+ const stackName = 'exp-test';
182
+
183
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
184
+ const stackCliHome = join(storageDir, stackName, 'cli');
185
+ await mkdir(stackCliHome, { recursive: true });
186
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
187
+
188
+ const envPath = join(storageDir, stackName, 'env');
189
+ await mkdir(dirname(envPath), { recursive: true });
190
+ await writeFile(
191
+ envPath,
192
+ [
193
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
194
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
195
+ `HAPPY_STACKS_SERVER_PORT=4101`,
196
+ '',
197
+ ].join('\n'),
198
+ 'utf-8'
199
+ );
200
+
201
+ const baseEnv = {
202
+ ...process.env,
203
+ HAPPY_STACKS_HOME_DIR: homeDir,
204
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
205
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
206
+ };
207
+
208
+ const startRes = await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', stackName, 'daemon', 'start', '--json'], {
209
+ cwd: rootDir,
210
+ env: baseEnv,
211
+ });
212
+ assert.equal(
213
+ startRes.code,
214
+ 0,
215
+ `expected start exit 0, got ${startRes.code}\nstdout:\n${startRes.stdout}\nstderr:\n${startRes.stderr}`
216
+ );
217
+ assert.ok(!startRes.stdout.includes('[stack] unknown command'), `unexpected unknown command output\n${startRes.stdout}`);
218
+
219
+ const logPath = join(stackCliHome, 'stub-daemon.log');
220
+ const logText = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
221
+ assert.ok(logText.includes('start'), `expected stub daemon start to be called\n${logText}`);
222
+
223
+ await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', stackName, 'daemon', 'stop', '--json'], { cwd: rootDir, env: baseEnv });
224
+ await rm(tmp, { recursive: true, force: true });
225
+ });
226
+
227
+ test('happys stack daemon <name> start/stop with --identity uses an isolated cli home dir', async () => {
228
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
229
+ const rootDir = dirname(scriptsDir);
230
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-daemon-identity-'));
231
+
232
+ const storageDir = join(tmp, 'storage');
233
+ const homeDir = join(tmp, 'home');
234
+ const stackName = 'exp-test';
235
+ const identity = 'account-b';
236
+
237
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
238
+ const stackCliHome = join(storageDir, stackName, 'cli');
239
+ await mkdir(stackCliHome, { recursive: true });
240
+ const identityHome = join(storageDir, stackName, 'cli-identities', identity);
241
+ await writeDummyAuth({ cliHomeDir: identityHome });
242
+
243
+ const envPath = join(storageDir, stackName, 'env');
244
+ await mkdir(dirname(envPath), { recursive: true });
245
+ await writeFile(
246
+ envPath,
247
+ [
248
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
249
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
250
+ `HAPPY_STACKS_SERVER_PORT=4101`,
251
+ '',
252
+ ].join('\n'),
253
+ 'utf-8'
254
+ );
255
+
256
+ const baseEnv = {
257
+ ...process.env,
258
+ HAPPY_STACKS_HOME_DIR: homeDir,
259
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
260
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
261
+ };
262
+
263
+ const startRes = await runNode(
264
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'daemon', stackName, 'start', `--identity=${identity}`, '--json'],
265
+ { cwd: rootDir, env: baseEnv }
266
+ );
267
+ assert.equal(
268
+ startRes.code,
269
+ 0,
270
+ `expected start exit 0, got ${startRes.code}\nstdout:\n${startRes.stdout}\nstderr:\n${startRes.stderr}`
271
+ );
272
+
273
+ const logPath = join(identityHome, 'stub-daemon.log');
274
+ const logText = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
275
+ assert.ok(logText.includes('start'), `expected stub daemon start to be called in identity home\n${logText}`);
276
+
277
+ const stopRes = await runNode(
278
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'daemon', stackName, 'stop', `--identity=${identity}`, '--json'],
279
+ { cwd: rootDir, env: baseEnv }
280
+ );
281
+ assert.equal(
282
+ stopRes.code,
283
+ 0,
284
+ `expected stop exit 0, got ${stopRes.code}\nstdout:\n${stopRes.stdout}\nstderr:\n${stopRes.stderr}`
285
+ );
286
+
287
+ const logTextAfter = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
288
+ assert.ok(logTextAfter.includes('stop'), `expected stub daemon stop to be called for identity\n${logTextAfter}`);
289
+
290
+ await rm(tmp, { recursive: true, force: true });
291
+ });
292
+
293
+ test('happys stack auth <name> login --identity=<name> --print prints identity-scoped HAPPY_HOME_DIR', async () => {
294
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
295
+ const rootDir = dirname(scriptsDir);
296
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-auth-identity-'));
297
+
298
+ const storageDir = join(tmp, 'storage');
299
+ const homeDir = join(tmp, 'home');
300
+ const stackName = 'exp-test';
301
+ const identity = 'account-b';
302
+
303
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
304
+ const stackCliHome = join(storageDir, stackName, 'cli');
305
+ await mkdir(stackCliHome, { recursive: true });
306
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
307
+
308
+ const envPath = join(storageDir, stackName, 'env');
309
+ await mkdir(dirname(envPath), { recursive: true });
310
+ await writeFile(
311
+ envPath,
312
+ [
313
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
314
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
315
+ `HAPPY_STACKS_SERVER_PORT=4101`,
316
+ '',
317
+ ].join('\n'),
318
+ 'utf-8'
319
+ );
320
+
321
+ const baseEnv = {
322
+ ...process.env,
323
+ HAPPY_STACKS_HOME_DIR: homeDir,
324
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
325
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
326
+ };
327
+
328
+ const res = await runNode(
329
+ [
330
+ join(rootDir, 'bin', 'happys.mjs'),
331
+ 'stack',
332
+ 'auth',
333
+ stackName,
334
+ 'login',
335
+ `--identity=${identity}`,
336
+ '--no-open',
337
+ '--print',
338
+ '--json',
339
+ ],
340
+ { cwd: rootDir, env: baseEnv }
341
+ );
342
+ assert.equal(
343
+ res.code,
344
+ 0,
345
+ `expected auth login --print exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`
346
+ );
347
+
348
+ const parsed = JSON.parse(res.stdout.trim());
349
+ assert.equal(parsed?.cliIdentity, identity);
350
+ assert.ok(
351
+ parsed?.cmd?.includes(`HAPPY_HOME_DIR="${join(storageDir, stackName, 'cli-identities', identity)}"`),
352
+ `expected printed cmd to include identity home dir\n${parsed?.cmd}`
353
+ );
354
+ assert.ok(
355
+ parsed?.cmd?.includes('--no-open'),
356
+ `expected printed cmd to include --no-open\n${parsed?.cmd}`
357
+ );
358
+
359
+ await rm(tmp, { recursive: true, force: true });
360
+ });
@@ -38,6 +38,21 @@ async function writeStubHappyCli({ root, message }) {
38
38
  return cliDir;
39
39
  }
40
40
 
41
+ async function writeFailingStubHappyCli({ root, errorMessage }) {
42
+ const cliDir = join(root, 'happy-cli');
43
+ await mkdir(join(cliDir, 'dist'), { recursive: true });
44
+ await writeFile(
45
+ join(cliDir, 'dist', 'index.mjs'),
46
+ [
47
+ `console.error(${JSON.stringify(errorMessage)});`,
48
+ `process.exit(1);`,
49
+ '',
50
+ ].join('\n'),
51
+ 'utf-8'
52
+ );
53
+ return cliDir;
54
+ }
55
+
41
56
  test('happys stack happy <name> runs happy-cli under that stack env', async () => {
42
57
  const scriptsDir = dirname(fileURLToPath(import.meta.url));
43
58
  const rootDir = dirname(scriptsDir);
@@ -82,6 +97,53 @@ test('happys stack happy <name> runs happy-cli under that stack env', async () =
82
97
  assert.equal(out.serverUrl, 'http://127.0.0.1:3999');
83
98
  });
84
99
 
100
+ test('happys stack happy <name> --identity=<name> uses identity-scoped HAPPY_HOME_DIR', async () => {
101
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
102
+ const rootDir = dirname(scriptsDir);
103
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-identity-'));
104
+
105
+ const storageDir = join(tmp, 'storage');
106
+ const homeDir = join(tmp, 'home');
107
+ const stackName = 'exp-test';
108
+ const identity = 'account-a';
109
+
110
+ const stubRoot = join(tmp, 'stub-components');
111
+ const cliDir = await writeStubHappyCli({ root: stubRoot, message: 'identity' });
112
+
113
+ const stackCliHome = join(storageDir, stackName, 'cli');
114
+ const envPath = join(storageDir, stackName, 'env');
115
+ await mkdir(dirname(envPath), { recursive: true });
116
+ await writeFile(
117
+ envPath,
118
+ [
119
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
120
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
121
+ `HAPPY_STACKS_SERVER_PORT=3999`,
122
+ '',
123
+ ].join('\n'),
124
+ 'utf-8'
125
+ );
126
+
127
+ const baseEnv = {
128
+ ...process.env,
129
+ HAPPY_STACKS_HOME_DIR: homeDir,
130
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
131
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
132
+ };
133
+
134
+ const res = await runNode(
135
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'happy', stackName, `--identity=${identity}`],
136
+ { cwd: rootDir, env: baseEnv }
137
+ );
138
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
139
+
140
+ const out = JSON.parse(res.stdout.trim());
141
+ assert.equal(out.message, 'identity');
142
+ assert.equal(out.stack, stackName);
143
+ assert.equal(out.homeDir, join(storageDir, stackName, 'cli-identities', identity));
144
+ assert.equal(out.serverUrl, 'http://127.0.0.1:3999');
145
+ });
146
+
85
147
  test('happys <stack> happy ... shorthand runs happy-cli under that stack env', async () => {
86
148
  const scriptsDir = dirname(fileURLToPath(import.meta.url));
87
149
  const rootDir = dirname(scriptsDir);
@@ -124,3 +186,44 @@ test('happys <stack> happy ... shorthand runs happy-cli under that stack env', a
124
186
  assert.equal(out.serverUrl, 'http://127.0.0.1:4101');
125
187
  });
126
188
 
189
+ test('happys stack happy <name> does not print wrapper stack traces on happy-cli failure', async () => {
190
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
191
+ const rootDir = dirname(scriptsDir);
192
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-fail-'));
193
+
194
+ const storageDir = join(tmp, 'storage');
195
+ const homeDir = join(tmp, 'home');
196
+ const stackName = 'exp-test';
197
+
198
+ const stubRoot = join(tmp, 'stub-components');
199
+ const cliDir = await writeFailingStubHappyCli({ root: stubRoot, errorMessage: 'stub failure' });
200
+
201
+ const stackCliHome = join(storageDir, stackName, 'cli');
202
+ const envPath = join(storageDir, stackName, 'env');
203
+ await mkdir(dirname(envPath), { recursive: true });
204
+ await writeFile(
205
+ envPath,
206
+ [
207
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
208
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
209
+ `HAPPY_STACKS_SERVER_PORT=3999`,
210
+ '',
211
+ ].join('\n'),
212
+ 'utf-8'
213
+ );
214
+
215
+ const baseEnv = {
216
+ ...process.env,
217
+ HAPPY_STACKS_HOME_DIR: homeDir,
218
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
219
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
220
+ }; const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', 'happy', stackName, 'attach', 'abc'], {
221
+ cwd: rootDir,
222
+ env: baseEnv,
223
+ });
224
+ assert.equal(res.code, 1, `expected exit 1, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
225
+ assert.ok(res.stderr.includes('stub failure'), `expected stderr to include stub failure, got:\n${res.stderr}`);
226
+ assert.ok(!res.stderr.includes('[happy] failed:'), `expected no [happy] failed stack trace, got:\n${res.stderr}`);
227
+ assert.ok(!res.stderr.includes('[stack] failed:'), `expected no [stack] failed stack trace, got:\n${res.stderr}`);
228
+ assert.ok(!res.stderr.includes('node:internal'), `expected no node:internal stack trace, got:\n${res.stderr}`);
229
+ });
@@ -4,7 +4,7 @@ function formatMissingTool({ name, why, install }) {
4
4
  return [`- ${name}: ${why}`, ...(install?.length ? install.map((l) => ` ${l}`) : [])].join('\n');
5
5
  }
6
6
 
7
- export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false } = {}) {
7
+ export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false, augment = false } = {}) {
8
8
  const missing = [];
9
9
 
10
10
  if (git) {
@@ -65,6 +65,17 @@ export async function assertCliPrereqs({ git = false, pnpm = false, codex = fals
65
65
  }
66
66
  }
67
67
 
68
+ if (augment) {
69
+ const hasAuggie = await commandExists('auggie');
70
+ if (!hasAuggie) {
71
+ missing.push({
72
+ name: 'auggie',
73
+ why: 'required to run Augment (Auggie) review',
74
+ install: ['Install Auggie CLI: `npm install -g @augmentcode/auggie`', 'Then authenticate: `auggie login`'],
75
+ });
76
+ }
77
+ }
78
+
68
79
  if (!missing.length) return;
69
80
 
70
81
  throw new Error(
@@ -47,7 +47,9 @@ export async function prepareDaemonAuthSeed({
47
47
  serverComponentName,
48
48
  serverDir,
49
49
  env: serverEnv,
50
- bestEffort: serverComponentName === 'happy-server',
50
+ // This probe is used only for auth seeding heuristics (and should never block stack startup).
51
+ // For unified server-light, running migrations here can race the running server and lock SQLite.
52
+ bestEffort: true,
51
53
  });
52
54
  return await prepareDaemonAuthSeedIfNeeded({
53
55
  rootDir,
@@ -105,7 +105,7 @@ function resolveStackCacheBaseDirFromEnv(env) {
105
105
  }
106
106
  }
107
107
 
108
- async function applyStackCacheEnv(baseEnv) {
108
+ export async function applyStackCacheEnv(baseEnv) {
109
109
  const env = { ...(baseEnv && typeof baseEnv === 'object' ? baseEnv : process.env) };
110
110
  const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
111
111
  const stackCacheBase = resolveStackCacheBaseDirFromEnv(env);
@@ -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:',