happy-stacks 0.1.2 → 0.2.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 (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -0,0 +1,1828 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { resolveStackEnvPath, getComponentDir, getRootDir } from './utils/paths.mjs';
5
+ import { parseDotenv } from './utils/dotenv.mjs';
6
+ import { pathExists } from './utils/fs.mjs';
7
+ import { run, runCapture } from './utils/proc.mjs';
8
+ import { resolveLocalhostHost } from './utils/localhost_host.mjs';
9
+ import { join } from 'node:path';
10
+ import { spawn } from 'node:child_process';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { mkdir, lstat, rename, symlink, writeFile, readdir, chmod } from 'node:fs/promises';
13
+ import os from 'node:os';
14
+
15
+ const COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
16
+
17
+ function cleanHappyStacksEnv(baseEnv) {
18
+ const cleaned = { ...baseEnv };
19
+ for (const k of Object.keys(cleaned)) {
20
+ if (k === 'HAPPY_LOCAL_ENV_FILE' || k === 'HAPPY_STACKS_ENV_FILE') continue;
21
+ if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
22
+ if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
23
+ delete cleaned[k];
24
+ }
25
+ }
26
+ return cleaned;
27
+ }
28
+
29
+ async function readExistingEnv(path) {
30
+ try {
31
+ const raw = await readFile(path, 'utf-8');
32
+ return raw;
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ async function readJsonIfExists(path) {
39
+ try {
40
+ if (!path || !(await pathExists(path))) return null;
41
+ const raw = await readFile(path, 'utf-8');
42
+ const parsed = JSON.parse(raw);
43
+ return parsed && typeof parsed === 'object' ? parsed : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function inferServerPortFromRuntimeState(runtimeState) {
50
+ try {
51
+ const port = runtimeState?.ports?.server;
52
+ const n = Number(port);
53
+ return Number.isFinite(n) && n > 0 ? n : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function isPidAlive(pid) {
60
+ const n = Number(pid);
61
+ if (!Number.isFinite(n) || n <= 1) return false;
62
+ try {
63
+ process.kill(n, 0);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function isRuntimeStateAlive(runtimeState) {
71
+ try {
72
+ const ownerPid = runtimeState?.ownerPid;
73
+ if (isPidAlive(ownerPid)) return true;
74
+ // fallback: if ownerPid missing, accept if serverPid is alive
75
+ const serverPid = runtimeState?.processes?.serverPid;
76
+ return isPidAlive(serverPid);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function isQaValidateCommand(edisonArgs) {
83
+ const args = Array.isArray(edisonArgs) ? edisonArgs : [];
84
+ for (let i = 0; i < args.length - 1; i++) {
85
+ if (args[i] === 'qa' && args[i + 1] === 'validate') return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function isQaRunCommand(edisonArgs) {
91
+ const args = Array.isArray(edisonArgs) ? edisonArgs : [];
92
+ for (let i = 0; i < args.length - 1; i++) {
93
+ if (args[i] === 'qa' && args[i + 1] === 'run') return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function hasArg(args, flag) {
99
+ const a = Array.isArray(args) ? args : [];
100
+ return a.includes(flag) || a.some((x) => typeof x === 'string' && x.startsWith(`${flag}=`));
101
+ }
102
+
103
+ function getArgValue(args, flag) {
104
+ const a = Array.isArray(args) ? args : [];
105
+ for (let i = 0; i < a.length; i++) {
106
+ const v = a[i];
107
+ if (v === flag) return a[i + 1] ?? '';
108
+ if (typeof v === 'string' && v.startsWith(`${flag}=`)) return v.slice(flag.length + 1);
109
+ }
110
+ return '';
111
+ }
112
+
113
+ function enforceValidatePresetPolicy({ edisonArgs }) {
114
+ // Policy (Happy Stacks):
115
+ // - CodeRabbit is required for *validation* (qa validate --execute), but must not run automatically.
116
+ // - We enforce this by requiring a `*-validate` preset in execute mode, which makes CodeRabbit evidence
117
+ // required at preflight (command-coderabbit.txt). Preflight will instruct the operator to run
118
+ // `evidence capture --only coderabbit` (manual, explicit) when missing.
119
+ if (!isQaValidateCommand(edisonArgs)) return;
120
+ const willExecute = hasArg(edisonArgs, '--execute') && !hasArg(edisonArgs, '--dry-run');
121
+ if (!willExecute) return;
122
+
123
+ const preset = String(getArgValue(edisonArgs, '--preset') ?? '').trim();
124
+ if (!preset) {
125
+ throw new Error(
126
+ '[edison] QA validation in happy-local requires an explicit validation preset.\n' +
127
+ '\n' +
128
+ 'Use one of:\n' +
129
+ ' - --preset standard-validate\n' +
130
+ ' - --preset standard-ui-validate\n' +
131
+ ' - --preset quick (docs-only)\n' +
132
+ '\n' +
133
+ 'This ensures CodeRabbit evidence is mandatory at preflight but never auto-runs.'
134
+ );
135
+ }
136
+ if (preset === 'quick') return;
137
+ if (!preset.endsWith('-validate')) {
138
+ throw new Error(
139
+ `[edison] Invalid preset for qa validate --execute: ${preset}\n` +
140
+ '\n' +
141
+ 'In happy-local, execute-mode validation must use a validation-only preset so CodeRabbit evidence is required:\n' +
142
+ ' - --preset standard-validate\n' +
143
+ ' - --preset standard-ui-validate\n' +
144
+ '\n' +
145
+ 'If you intended to capture implementation evidence only, use `evidence capture` (not `qa validate --execute`).'
146
+ );
147
+ }
148
+ }
149
+
150
+ async function enforceQaRunPreflightPolicy({ rootDir, env, edisonArgs }) {
151
+ // `edison qa run <validator> <task>` executes a single validator directly (no preset support).
152
+ // In happy-local, we still want "validation-grade" prerequisites to be enforced and surfaced
153
+ // *before* execution, without auto-running heavier evidence like CodeRabbit.
154
+ //
155
+ // Policy:
156
+ // - For execute-mode qa run (i.e. not --dry-run), require validation-only evidence to be satisfied.
157
+ // - This makes CodeRabbit evidence mandatory (via the validation-only preset), but does not run it automatically.
158
+ if (!isQaRunCommand(edisonArgs)) return;
159
+ if (hasArg(edisonArgs, '--dry-run')) return;
160
+
161
+ // Extract positional args: qa run <validator_id> <task_id>
162
+ const args = Array.isArray(edisonArgs) ? edisonArgs : [];
163
+ let validatorId = '';
164
+ let taskId = '';
165
+ for (let i = 0; i < args.length - 3; i++) {
166
+ if (args[i] === 'qa' && args[i + 1] === 'run') {
167
+ validatorId = String(args[i + 2] ?? '').trim();
168
+ taskId = String(args[i + 3] ?? '').trim();
169
+ break;
170
+ }
171
+ }
172
+ if (!validatorId || !taskId) return;
173
+
174
+ // Pick the validation-only preset for the scope:
175
+ // - browser-e2e runs in the UI validate preset
176
+ // - everything else uses the standard validate preset
177
+ const preset = validatorId === 'browser-e2e' ? 'standard-ui-validate' : 'standard-validate';
178
+
179
+ // Preflight by checking evidence status (fail-closed if required evidence is missing).
180
+ let status = null;
181
+ try {
182
+ const raw = await runCapture('edison', ['evidence', 'status', taskId, '--preset', preset, '--json'], {
183
+ cwd: rootDir,
184
+ env,
185
+ });
186
+ status = JSON.parse(String(raw ?? '').trim() || 'null');
187
+ } catch {
188
+ status = null;
189
+ }
190
+ const ok = Boolean(status?.success);
191
+ if (ok) return;
192
+
193
+ const missing = Array.isArray(status?.missingCommands) ? status.missingCommands.filter(Boolean) : [];
194
+ const missingHint = missing.length ? ` --only ${missing.join(',')}` : '';
195
+ throw new Error(
196
+ `[edison] Refusing to run qa run without validation-only evidence (preset=${preset}).\n\n` +
197
+ `Fix:\n` +
198
+ ` happys edison --stack=$HAPPY_STACKS_STACK -- evidence capture ${taskId} --preset ${preset}${missingHint}\n\n` +
199
+ `Rationale:\n` +
200
+ ` This keeps CodeRabbit evidence mandatory for validation while ensuring it never runs automatically.\n`
201
+ );
202
+ }
203
+
204
+ async function ensureStackServerPortForWebServerValidation({ rootDir, stackName, env, edisonArgs, json }) {
205
+ const currentPort = (env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
206
+ if (currentPort) return;
207
+ if (!isQaValidateCommand(edisonArgs)) return;
208
+
209
+ const { baseDir } = resolveStackEnvPath(stackName);
210
+ const runtimePath = join(baseDir, 'stack.runtime.json');
211
+
212
+ const existing = await readJsonIfExists(runtimePath);
213
+ const existingPort = inferServerPortFromRuntimeState(existing);
214
+ const existingAlive = existingPort && isRuntimeStateAlive(existing);
215
+ if (existingPort && existingAlive) {
216
+ env.HAPPY_STACKS_SERVER_PORT = String(existingPort);
217
+ env.HAPPY_LOCAL_SERVER_PORT = String(existingPort);
218
+ return;
219
+ }
220
+
221
+ // Option A: Happy-local wrapper responsibility.
222
+ // If the stack uses ephemeral ports and isn't running yet, start it (detached) so we can
223
+ // discover the chosen port via stack.runtime.json before Edison expands web_server URLs.
224
+ if (!json) {
225
+ // eslint-disable-next-line no-console
226
+ console.log(`[edison] stack=${stackName}: server port not set; starting stack to resolve runtime port...`);
227
+ }
228
+
229
+ try {
230
+ const child = spawn(
231
+ process.execPath,
232
+ // Do NOT force `--restart` here:
233
+ // - If the prior ephemeral port is still occupied (common after a crash), `--restart` fails closed.
234
+ // - For validation we prefer to bring up the stack on a fresh port rather than fail preflight.
235
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'start', stackName],
236
+ { cwd: rootDir, env, stdio: 'ignore', detached: true }
237
+ );
238
+ child.unref();
239
+ } catch {
240
+ // If we fail to spawn, we still proceed; Edison will fail closed when URL probing fails.
241
+ }
242
+
243
+ const deadline = Date.now() + 60_000;
244
+ while (Date.now() < deadline) {
245
+ // eslint-disable-next-line no-await-in-loop
246
+ const st = await readJsonIfExists(runtimePath);
247
+ const port = inferServerPortFromRuntimeState(st);
248
+ if (port) {
249
+ env.HAPPY_STACKS_SERVER_PORT = String(port);
250
+ env.HAPPY_LOCAL_SERVER_PORT = String(port);
251
+ return;
252
+ }
253
+ // eslint-disable-next-line no-await-in-loop
254
+ await new Promise((r) => setTimeout(r, 250));
255
+ }
256
+ }
257
+
258
+ async function readFrontmatterFile(path) {
259
+ const text = await readFile(path, 'utf-8');
260
+ const { fm } = parseFrontmatter(text);
261
+ return fm ?? {};
262
+ }
263
+
264
+ async function resolveTaskFilePath({ rootDir, taskId }) {
265
+ const taskPath = join(rootDir, '.project', 'tasks');
266
+ const taskGlobRoots = ['todo', 'wip', 'done', 'validated', 'blocked'];
267
+ for (const st of taskGlobRoots) {
268
+ const p = join(taskPath, st, `${taskId}.md`);
269
+ // eslint-disable-next-line no-await-in-loop
270
+ if (await pathExists(p)) return p;
271
+ }
272
+ // Also check session-scoped tasks: .project/sessions/<sess-state>/<session-id>/tasks/<task-state>/<id>.md
273
+ const sessionsRoot = join(rootDir, '.project', 'sessions');
274
+ try {
275
+ const sessStates = await readdir(sessionsRoot, { withFileTypes: true });
276
+ for (const s of sessStates) {
277
+ if (!s.isDirectory()) continue;
278
+ const sessStateDir = join(sessionsRoot, s.name);
279
+ // eslint-disable-next-line no-await-in-loop
280
+ const sessIds = await readdir(sessStateDir, { withFileTypes: true }).catch(() => []);
281
+ for (const sid of sessIds) {
282
+ if (!sid.isDirectory()) continue;
283
+ const base = join(sessStateDir, sid.name, 'tasks');
284
+ for (const st of taskGlobRoots) {
285
+ const p = join(base, st, `${taskId}.md`);
286
+ // eslint-disable-next-line no-await-in-loop
287
+ if (await pathExists(p)) return p;
288
+ }
289
+ }
290
+ }
291
+ } catch {
292
+ // ignore
293
+ }
294
+ return '';
295
+ }
296
+
297
+ async function resolveQaFilePath({ rootDir, qaId }) {
298
+ const qaPath = join(rootDir, '.project', 'qa');
299
+ const qaStates = ['waiting', 'todo', 'wip', 'done', 'validated'];
300
+ for (const st of qaStates) {
301
+ const p = join(qaPath, st, `${qaId}.md`);
302
+ // eslint-disable-next-line no-await-in-loop
303
+ if (await pathExists(p)) return p;
304
+ }
305
+ // Also check session-scoped QA: .project/sessions/<sess-state>/<session-id>/qa/<qa-state>/<id>.md
306
+ const sessionsRoot = join(rootDir, '.project', 'sessions');
307
+ try {
308
+ const sessStates = await readdir(sessionsRoot, { withFileTypes: true });
309
+ for (const s of sessStates) {
310
+ if (!s.isDirectory()) continue;
311
+ const sessStateDir = join(sessionsRoot, s.name);
312
+ // eslint-disable-next-line no-await-in-loop
313
+ const sessIds = await readdir(sessStateDir, { withFileTypes: true }).catch(() => []);
314
+ for (const sid of sessIds) {
315
+ if (!sid.isDirectory()) continue;
316
+ const base = join(sessStateDir, sid.name, 'qa');
317
+ for (const st of qaStates) {
318
+ const p = join(base, st, `${qaId}.md`);
319
+ // eslint-disable-next-line no-await-in-loop
320
+ if (await pathExists(p)) return p;
321
+ }
322
+ }
323
+ }
324
+ } catch {
325
+ // ignore
326
+ }
327
+ return '';
328
+ }
329
+
330
+ async function inferStackFromRecordId({ rootDir, recordId }) {
331
+ const id = String(recordId ?? '').trim();
332
+ if (!id) return '';
333
+ const taskPath = await resolveTaskFilePath({ rootDir, taskId: id });
334
+ if (taskPath) {
335
+ const fm = await readFrontmatterFile(taskPath);
336
+ return String(fm?.stack ?? '').trim();
337
+ }
338
+ const qaPath = await resolveQaFilePath({ rootDir, qaId: id });
339
+ if (qaPath) {
340
+ const fm = await readFrontmatterFile(qaPath);
341
+ return String(fm?.stack ?? '').trim();
342
+ }
343
+ return '';
344
+ }
345
+
346
+ async function inferStackFromArgs({ rootDir, edisonArgs }) {
347
+ const args = Array.isArray(edisonArgs) ? edisonArgs : [];
348
+ for (const a of args) {
349
+ if (!a || a.startsWith('-')) continue;
350
+ // eslint-disable-next-line no-await-in-loop
351
+ const s = await inferStackFromRecordId({ rootDir, recordId: a });
352
+ if (s) return s;
353
+ }
354
+ return '';
355
+ }
356
+
357
+ async function inferTaskIdFromArgs({ rootDir, edisonArgs }) {
358
+ const args = Array.isArray(edisonArgs) ? edisonArgs : [];
359
+ for (const a of args) {
360
+ if (!a || a.startsWith('-')) continue;
361
+ const id = String(a).trim();
362
+ if (!id) continue;
363
+ // eslint-disable-next-line no-await-in-loop
364
+ const taskPath = await resolveTaskFilePath({ rootDir, taskId: id });
365
+ if (taskPath) return id;
366
+ // eslint-disable-next-line no-await-in-loop
367
+ const qaPath = await resolveQaFilePath({ rootDir, qaId: id });
368
+ if (qaPath) {
369
+ // Some commands might pass a QA id; use its task_id to determine targeted components.
370
+ // eslint-disable-next-line no-await-in-loop
371
+ const fm = await readFrontmatterFile(qaPath);
372
+ const tid = String(fm?.task_id ?? fm?.taskId ?? '').trim();
373
+ if (tid) return tid;
374
+ }
375
+ }
376
+ return '';
377
+ }
378
+
379
+ function parseEnvToObject(raw) {
380
+ const parsed = parseDotenv(raw);
381
+ return Object.fromEntries(parsed.entries());
382
+ }
383
+
384
+ function resolveComponentDirsFromStackEnv({ rootDir, stackEnv }) {
385
+ const out = [];
386
+
387
+ for (const name of COMPONENTS) {
388
+ const key = `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
389
+ const legacyKey = `HAPPY_LOCAL_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
390
+ const raw = (stackEnv[key] ?? stackEnv[legacyKey] ?? '').toString().trim();
391
+ const dir = raw || getComponentDir(rootDir, name);
392
+ out.push(dir);
393
+ }
394
+ return out;
395
+ }
396
+
397
+ function hasFlag(argv, name) {
398
+ return argv.some((a) => a === name || a.startsWith(`${name}=`));
399
+ }
400
+
401
+ function parseFrontmatter(content) {
402
+ const text = String(content ?? '');
403
+ if (!text.startsWith('---')) return { fm: {}, body: text };
404
+ const idx = text.indexOf('\n---', 3);
405
+ if (idx === -1) return { fm: {}, body: text };
406
+ const fmText = text.slice(3, idx + 1).trim();
407
+ const body = text.slice(idx + 4);
408
+
409
+ const fm = {};
410
+ let currentKey = '';
411
+ for (const rawLine of fmText.split('\n')) {
412
+ const line = rawLine.replace(/\r$/, '');
413
+ if (!line.trim() || line.trim().startsWith('#')) continue;
414
+ const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
415
+ if (m) {
416
+ currentKey = m[1];
417
+ const v = m[2].trim();
418
+ if (v === '[]') {
419
+ fm[currentKey] = [];
420
+ } else if (v.startsWith('[') && v.endsWith(']')) {
421
+ const inside = v.slice(1, -1);
422
+ fm[currentKey] = inside
423
+ .split(',')
424
+ .map((p) => p.trim())
425
+ .filter(Boolean)
426
+ .map((p) => p.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'));
427
+ } else if (v === '') {
428
+ fm[currentKey] = '';
429
+ } else {
430
+ fm[currentKey] = v.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
431
+ }
432
+ continue;
433
+ }
434
+ const li = line.match(/^\s*-\s*(.*)$/);
435
+ if (li && currentKey) {
436
+ if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
437
+ fm[currentKey].push(li[1].trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'));
438
+ }
439
+ }
440
+ return { fm, body };
441
+ }
442
+
443
+ function resolveComponentsFromFrontmatter(fm) {
444
+ const hsKind = String(fm?.hs_kind ?? '').trim().toLowerCase();
445
+ if (hsKind === 'component') {
446
+ const c = String(fm?.component ?? '').trim();
447
+ if (c) return [c];
448
+ }
449
+ const v = fm?.components;
450
+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
451
+ if (typeof v === 'string' && v.trim()) return v.split(',').map((p) => p.trim()).filter(Boolean);
452
+ return [];
453
+ }
454
+
455
+ function sanitizeStackName(raw) {
456
+ return String(raw ?? '')
457
+ .trim()
458
+ .toLowerCase()
459
+ .replace(/[^a-z0-9-]+/g, '-')
460
+ .replace(/-+/g, '-')
461
+ .replace(/^-+/, '')
462
+ .replace(/-+$/, '')
463
+ .slice(0, 64);
464
+ }
465
+
466
+ function yamlQuote(v) {
467
+ const s = String(v ?? '');
468
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
469
+ }
470
+
471
+ function renderYamlFrontmatter(obj) {
472
+ const lines = ['---'];
473
+ for (const [k, v] of Object.entries(obj)) {
474
+ if (v === undefined) continue;
475
+ if (Array.isArray(v)) {
476
+ lines.push(`${k}:`);
477
+ for (const item of v) {
478
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
479
+ const keys = Object.keys(item);
480
+ if (!keys.length) continue;
481
+ lines.push(` - ${keys[0]}: ${yamlQuote(item[keys[0]])}`);
482
+ for (const kk of keys.slice(1)) {
483
+ lines.push(` ${kk}: ${yamlQuote(item[kk])}`);
484
+ }
485
+ } else {
486
+ lines.push(` - ${yamlQuote(item)}`);
487
+ }
488
+ }
489
+ continue;
490
+ }
491
+ if (v && typeof v === 'object') {
492
+ lines.push(`${k}: ${yamlQuote(JSON.stringify(v))}`);
493
+ continue;
494
+ }
495
+ if (typeof v === 'number' || typeof v === 'boolean') {
496
+ lines.push(`${k}: ${v}`);
497
+ continue;
498
+ }
499
+ if (v === null) {
500
+ lines.push(`${k}: null`);
501
+ continue;
502
+ }
503
+ lines.push(`${k}: ${yamlQuote(v)}`);
504
+ }
505
+ lines.push('---');
506
+ return lines.join('\n') + '\n';
507
+ }
508
+
509
+ async function listExistingTaskIds({ rootDir }) {
510
+ const taskRoot = join(rootDir, '.project', 'tasks');
511
+ const states = ['todo', 'wip', 'done', 'validated', 'blocked'];
512
+ const ids = new Set();
513
+ for (const st of states) {
514
+ const dir = join(taskRoot, st);
515
+ // eslint-disable-next-line no-await-in-loop
516
+ if (!(await pathExists(dir))) continue;
517
+ // eslint-disable-next-line no-await-in-loop
518
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
519
+ for (const e of entries) {
520
+ if (!e.isFile()) continue;
521
+ if (!e.name.endsWith('.md')) continue;
522
+ ids.add(e.name.slice(0, -3));
523
+ }
524
+ }
525
+ return ids;
526
+ }
527
+
528
+ async function listTaskFiles({ rootDir }) {
529
+ const taskRoot = join(rootDir, '.project', 'tasks');
530
+ const states = ['todo', 'wip', 'done', 'validated', 'blocked'];
531
+ const out = [];
532
+ for (const st of states) {
533
+ const dir = join(taskRoot, st);
534
+ // eslint-disable-next-line no-await-in-loop
535
+ if (!(await pathExists(dir))) continue;
536
+ // eslint-disable-next-line no-await-in-loop
537
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
538
+ for (const e of entries) {
539
+ if (!e.isFile()) continue;
540
+ if (!e.name.endsWith('.md')) continue;
541
+ out.push({ id: e.name.slice(0, -3), path: join(dir, e.name) });
542
+ }
543
+ }
544
+ return out;
545
+ }
546
+
547
+ async function scanTasks({ rootDir }) {
548
+ const files = await listTaskFiles({ rootDir });
549
+ const tasks = [];
550
+ for (const f of files) {
551
+ // eslint-disable-next-line no-await-in-loop
552
+ const text = await readFile(f.path, 'utf-8').catch(() => '');
553
+ const { fm } = parseFrontmatter(text);
554
+ tasks.push({ id: f.id, path: f.path, fm });
555
+ }
556
+ return tasks;
557
+ }
558
+
559
+ function nextChildId(parentId, existingIds) {
560
+ const prefix = `${parentId}.`;
561
+ let max = 0;
562
+ for (const id of existingIds) {
563
+ if (!String(id).startsWith(prefix)) continue;
564
+ const rest = String(id).slice(prefix.length);
565
+ const first = rest.split('.')[0];
566
+ if (!/^\d+$/.test(first)) continue;
567
+ const n = Number(first);
568
+ if (Number.isFinite(n) && n > max) max = n;
569
+ }
570
+ return `${parentId}.${max + 1}`;
571
+ }
572
+
573
+ async function ensureTaskFile({ rootDir, taskId, frontmatter, body, state = 'todo' }) {
574
+ const dir = join(rootDir, '.project', 'tasks', state);
575
+ await mkdir(dir, { recursive: true });
576
+ const path = join(dir, `${taskId}.md`);
577
+ if (await pathExists(path)) return { path, created: false };
578
+ const text = renderYamlFrontmatter(frontmatter) + '\n' + String(body || '').trim() + '\n';
579
+ await writeFile(path, text, 'utf-8');
580
+ return { path, created: true };
581
+ }
582
+
583
+ async function ensureQaFile({ rootDir, taskId, title, frontmatterExtra = {} }) {
584
+ const qaId = `${taskId}-qa`;
585
+ const dir = join(rootDir, '.project', 'qa', 'waiting');
586
+ await mkdir(dir, { recursive: true });
587
+ const path = join(dir, `${qaId}.md`);
588
+ if (await pathExists(path)) return { path, created: false };
589
+ const fm = {
590
+ id: qaId,
591
+ task_id: taskId,
592
+ title,
593
+ round: 0,
594
+ ...frontmatterExtra,
595
+ };
596
+ const body =
597
+ `# ${title}\n\n` +
598
+ `## Automated Checks (Happy Stacks)\n\n` +
599
+ `- Evidence capture (stack-scoped): \`happys edison --stack=${frontmatterExtra.stack ?? '<stack>'} -- evidence capture ${taskId}\`\n`;
600
+ const text = renderYamlFrontmatter(fm) + '\n' + body;
601
+ await writeFile(path, text, 'utf-8');
602
+ return { path, created: true };
603
+ }
604
+
605
+ async function cmdTaskScaffold({ rootDir, argv, json }) {
606
+ const { flags, kv } = parseArgs(argv);
607
+ const positionals = argv.filter((a) => !a.startsWith('--'));
608
+ const taskId = positionals[1]?.trim?.() ? positionals[1].trim() : '';
609
+ if (!taskId) {
610
+ throw new Error(
611
+ '[edison] usage: happys edison task:scaffold <task-id> [--mode=upstream|fork|both] [--tracks=upstream,fork] [--yes] [--json]'
612
+ );
613
+ }
614
+
615
+ const mode = (kv.get('--mode') ?? '').trim().toLowerCase() || 'upstream';
616
+ const yes = flags.has('--yes');
617
+
618
+ const taskPath = join(rootDir, '.project', 'tasks');
619
+ const taskGlobRoots = ['todo', 'wip', 'done', 'validated', 'blocked'];
620
+ let mdPath = '';
621
+ for (const st of taskGlobRoots) {
622
+ const p = join(taskPath, st, `${taskId}.md`);
623
+ // eslint-disable-next-line no-await-in-loop
624
+ if (await pathExists(p)) {
625
+ mdPath = p;
626
+ break;
627
+ }
628
+ }
629
+ if (!mdPath) {
630
+ throw new Error(`[edison] task not found: ${taskId} (expected under .project/tasks/{todo,wip,done,validated,blocked}/${taskId}.md)`);
631
+ }
632
+
633
+ const taskText = await readFile(mdPath, 'utf-8');
634
+ const { fm } = parseFrontmatter(taskText);
635
+
636
+ const hsKind = String(fm?.hs_kind ?? '').trim().toLowerCase();
637
+ if (!hsKind || !['parent', 'track', 'component'].includes(hsKind)) {
638
+ throw new Error(`[edison] missing/invalid hs_kind in task frontmatter.\nFix: edit ${mdPath} and set:\n hs_kind: parent|track|component`);
639
+ }
640
+
641
+ const components = resolveComponentsFromFrontmatter(fm);
642
+ const stackFromTask = String(fm.stack ?? '').trim();
643
+
644
+ const desiredTracksRaw = (kv.get('--tracks') ?? '').trim();
645
+ let trackNames = desiredTracksRaw ? desiredTracksRaw.split(',').map((p) => p.trim()).filter(Boolean) : [];
646
+ if (!trackNames.length) {
647
+ trackNames = mode === 'both' ? ['upstream', 'fork'] : [mode === 'fork' ? 'fork' : 'upstream'];
648
+ }
649
+
650
+ const stacksJson = await runCapture('node', ['./bin/happys.mjs', 'stack', 'list', '--json'], { cwd: rootDir });
651
+ let stacks = [];
652
+ try {
653
+ const parsed = JSON.parse(stacksJson || '[]');
654
+ if (Array.isArray(parsed)) {
655
+ stacks = parsed;
656
+ } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.stacks)) {
657
+ stacks = parsed.stacks;
658
+ } else {
659
+ stacks = [];
660
+ }
661
+ } catch {
662
+ stacks = [];
663
+ }
664
+
665
+ const existingIds = await listExistingTaskIds({ rootDir });
666
+ const tasks = await scanTasks({ rootDir });
667
+ const createdStacks = [];
668
+ const createdTasks = [];
669
+ const createdQas = [];
670
+ const createdWorktrees = [];
671
+ const pinned = [];
672
+
673
+ if (hsKind === 'parent') {
674
+ if (!components.length) {
675
+ throw new Error(
676
+ `[edison] parent task must declare components.\nFix: edit ${mdPath} and set:\n components:\n - happy\n - happy-cli\nThen run:\n happys edison task:scaffold ${taskId} --yes`
677
+ );
678
+ }
679
+ if (!yes) {
680
+ throw new Error(
681
+ `[edison] parent scaffold will create track + component subtasks, stacks, and worktrees.\nRe-run with:\n happys edison task:scaffold ${taskId} --yes`
682
+ );
683
+ }
684
+
685
+ for (const track of trackNames) {
686
+ const existingTrack = tasks.find((t) => {
687
+ if (!String(t.id).startsWith(`${taskId}.`)) return false;
688
+ const k = String(t.fm?.hs_kind ?? '').trim().toLowerCase();
689
+ if (k !== 'track') return false;
690
+ const bt = String(t.fm?.base_task ?? '').trim();
691
+ const tn = String(t.fm?.track ?? '').trim();
692
+ return bt === taskId && tn === track;
693
+ });
694
+
695
+ const trackTaskId = existingTrack?.id || nextChildId(taskId, existingIds);
696
+ existingIds.add(trackTaskId);
697
+ const stack = String(existingTrack?.fm?.stack ?? '').trim() || sanitizeStackName(`${taskId}-${track}`);
698
+ const trackTitle = `Track: ${track} (${stack})`;
699
+
700
+ const trackFm = {
701
+ id: trackTaskId,
702
+ title: trackTitle,
703
+ hs_kind: 'track',
704
+ track,
705
+ stack,
706
+ base_task: taskId,
707
+ components,
708
+ relationships: [{ type: 'parent', target: taskId }],
709
+ };
710
+ const trackBody =
711
+ `# ${trackTitle}\n\n` +
712
+ `## Scope\n\n` +
713
+ `- Parent: ${taskId}\n` +
714
+ `- Track: ${track}\n` +
715
+ `- Stack: ${stack}\n` +
716
+ `- Components: ${components.join(', ')}\n\n` +
717
+ `## Commands (MANDATORY)\n\n` +
718
+ `- Run inside stack context: \`happys edison --stack=${stack} -- <edison ...>\`\n` +
719
+ `- Evidence: \`happys edison --stack=${stack} -- evidence capture ${trackTaskId}\`\n`;
720
+
721
+ const trackRes = await ensureTaskFile({ rootDir, taskId: trackTaskId, frontmatter: trackFm, body: trackBody, state: 'todo' });
722
+ createdTasks.push({ id: trackTaskId, kind: 'track', stack, path: trackRes.path, created: trackRes.created });
723
+
724
+ const stackExists = Array.isArray(stacks) && stacks.some((s) => String(s?.name ?? '') === stack);
725
+ if (!stackExists) {
726
+ await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, '--json'], { cwd: rootDir });
727
+ createdStacks.push({ stack });
728
+ stacks.push({ name: stack });
729
+ }
730
+
731
+ const qaRes = await ensureQaFile({
732
+ rootDir,
733
+ taskId: trackTaskId,
734
+ title: `QA: ${trackTitle}`,
735
+ frontmatterExtra: { track, stack, components },
736
+ });
737
+ createdQas.push({ id: `${trackTaskId}-qa`, path: qaRes.path, created: qaRes.created });
738
+
739
+ for (const c of components) {
740
+ const existingComp = tasks.find((t) => {
741
+ if (!String(t.id).startsWith(`${trackTaskId}.`)) return false;
742
+ const k = String(t.fm?.hs_kind ?? '').trim().toLowerCase();
743
+ if (k !== 'component') return false;
744
+ const tn = String(t.fm?.track ?? '').trim();
745
+ const st = String(t.fm?.stack ?? '').trim();
746
+ const comp = String(t.fm?.component ?? '').trim();
747
+ return tn === track && st === stack && comp === c;
748
+ });
749
+
750
+ const compTaskId = existingComp?.id || nextChildId(trackTaskId, existingIds);
751
+ existingIds.add(compTaskId);
752
+ const compTitle = `Component: ${c} (${track})`;
753
+ const baseWorktree = String(existingComp?.fm?.base_worktree ?? '').trim() || `edison/${compTaskId}`;
754
+ const compFm = {
755
+ id: compTaskId,
756
+ title: compTitle,
757
+ hs_kind: 'component',
758
+ track,
759
+ stack,
760
+ base_task: taskId,
761
+ base_worktree: baseWorktree,
762
+ components: [c],
763
+ component: c,
764
+ relationships: [{ type: 'parent', target: trackTaskId }],
765
+ };
766
+ const compBody =
767
+ `# ${compTitle}\n\n` +
768
+ `## Scope\n\n` +
769
+ `- Parent feature: ${taskId}\n` +
770
+ `- Track task: ${trackTaskId}\n` +
771
+ `- Stack: ${stack}\n` +
772
+ `- Component: ${c}\n\n` +
773
+ `## Commands (MANDATORY)\n\n` +
774
+ `- Run inside stack context: \`happys edison --stack=${stack} -- <edison ...>\`\n` +
775
+ `- Evidence: \`happys edison --stack=${stack} -- evidence capture ${compTaskId}\`\n`;
776
+
777
+ const compRes = await ensureTaskFile({ rootDir, taskId: compTaskId, frontmatter: compFm, body: compBody, state: 'todo' });
778
+ createdTasks.push({ id: compTaskId, kind: 'component', component: c, stack, path: compRes.path, created: compRes.created });
779
+
780
+ const qa2 = await ensureQaFile({
781
+ rootDir,
782
+ taskId: compTaskId,
783
+ title: `QA: ${compTitle}`,
784
+ frontmatterExtra: { track, stack, components: [c], component: c },
785
+ });
786
+ createdQas.push({ id: `${compTaskId}-qa`, path: qa2.path, created: qa2.created });
787
+
788
+ const from = track === 'fork' ? 'origin' : 'upstream';
789
+ const stdout = await runCapture(
790
+ 'node',
791
+ ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
792
+ { cwd: rootDir }
793
+ );
794
+ const res = JSON.parse(stdout);
795
+ createdWorktrees.push({ component: c, variant: from, taskId: compTaskId, path: res.path, branch: res.branch });
796
+ await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
797
+ pinned.push({ stack, component: c, taskId: compTaskId, path: res.path });
798
+ }
799
+ }
800
+ } else {
801
+ const stack = (kv.get('--stack') ?? '').trim() || stackFromTask;
802
+ if (!stack) {
803
+ throw new Error(`[edison] missing task stack in frontmatter.\nFix: edit ${mdPath} and set:\n stack: <name>`);
804
+ }
805
+ if (!components.length) {
806
+ throw new Error(`[edison] missing task components in frontmatter.\nFix: edit ${mdPath} and set:\n components:\n - happy\n(or set component: happy for hs_kind=component)`);
807
+ }
808
+
809
+ const stackExists = Array.isArray(stacks) && stacks.some((s) => String(s?.name ?? '') === stack);
810
+ if (!stackExists) {
811
+ if (!yes) {
812
+ throw new Error(
813
+ `[edison] stack "${stack}" does not exist.\n` +
814
+ `Fix:\n` +
815
+ ` happys stack new ${stack} --interactive\n` +
816
+ `Or re-run non-interactively with --yes:\n` +
817
+ ` happys edison task:scaffold ${taskId} --yes\n`
818
+ );
819
+ }
820
+ await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, '--json'], { cwd: rootDir });
821
+ createdStacks.push({ stack });
822
+ }
823
+
824
+ for (const c of components) {
825
+ const baseWorktree = `edison/${taskId}`;
826
+ const from = mode === 'fork' ? 'origin' : 'upstream';
827
+ const stdout = await runCapture(
828
+ 'node',
829
+ ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
830
+ { cwd: rootDir }
831
+ );
832
+ const res = JSON.parse(stdout);
833
+ createdWorktrees.push({ component: c, variant: from, taskId, path: res.path, branch: res.branch });
834
+ await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
835
+ pinned.push({ stack, component: c, taskId, path: res.path });
836
+ }
837
+ }
838
+
839
+ printResult({
840
+ json,
841
+ data: {
842
+ ok: true,
843
+ taskId,
844
+ hsKind,
845
+ mode,
846
+ trackNames,
847
+ createdStacks,
848
+ createdTasks,
849
+ createdQas,
850
+ createdWorktrees,
851
+ pinned,
852
+ },
853
+ text: [
854
+ `[edison] scaffold ok: ${taskId}`,
855
+ `- hs_kind: ${hsKind}`,
856
+ `- mode: ${mode}`,
857
+ hsKind === 'parent' ? `- tracks: ${trackNames.join(', ')}` : '',
858
+ `- created stacks: ${createdStacks.length}`,
859
+ `- created tasks: ${createdTasks.filter((t) => t.created).length} (total touched: ${createdTasks.length})`,
860
+ `- pinned stack worktrees: ${pinned.length}`,
861
+ '',
862
+ 'next:',
863
+ hsKind === 'parent' ? `- claim a TRACK or COMPONENT task (parent tasks are not claimable).` : '',
864
+ ]
865
+ .filter(Boolean)
866
+ .join('\n'),
867
+ });
868
+ }
869
+
870
+ async function ensureSymlink({ linkPath, targetPath }) {
871
+ try {
872
+ const st = await lstat(linkPath);
873
+ if (st.isSymbolicLink()) {
874
+ return { ok: true, skipped: true, reason: 'already_symlink' };
875
+ }
876
+ return { ok: false, skipped: false, reason: 'exists_not_symlink' };
877
+ } catch {
878
+ // missing
879
+ }
880
+ await mkdir(join(linkPath, '..'), { recursive: true }).catch(() => {});
881
+ await symlink(targetPath, linkPath);
882
+ return { ok: true, skipped: false, reason: 'created' };
883
+ }
884
+
885
+ async function cmdMetaInit({ rootDir, json }) {
886
+ // Historical note: earlier versions used a git worktree under `.worktrees/_meta` plus symlinks.
887
+ // The recommended approach now is to keep Edison/tooling state directly in the repo root
888
+ // (and rely on gitignore + npmignore for cleanliness).
889
+ //
890
+ // This command is kept as an idempotent "make sure directories exist" helper.
891
+ const dirs = [
892
+ '.claude',
893
+ '.cursor',
894
+ '.edison',
895
+ '.edison/config',
896
+ '.edison/packs',
897
+ '.edison/agents',
898
+ '.edison/constitutions',
899
+ '.edison/guidelines',
900
+ '.edison/validators',
901
+ '.edison/scripts',
902
+ '.edison/_generated',
903
+ '.project',
904
+ '.project/tasks',
905
+ '.project/qa',
906
+ '.project/sessions',
907
+ '.project/logs',
908
+ '.project/archive',
909
+ '.project/plans',
910
+ ];
911
+
912
+ const results = [];
913
+ for (const rel of dirs) {
914
+ const abs = join(rootDir, rel);
915
+ // eslint-disable-next-line no-await-in-loop
916
+ await mkdir(abs, { recursive: true });
917
+ results.push({ path: rel, ok: true });
918
+ }
919
+
920
+ // If a legacy `.worktrees/_meta` exists (non-git worktree), keep it as a backup dir by renaming.
921
+ const legacyMeta = join(rootDir, '.worktrees', '_meta');
922
+ try {
923
+ const st = await lstat(legacyMeta);
924
+ if (st.isDirectory()) {
925
+ const backup = join(rootDir, '.worktrees', `_meta_backup_${Date.now()}`);
926
+ await mkdir(join(rootDir, '.worktrees'), { recursive: true });
927
+ await rename(legacyMeta, backup);
928
+ results.push({ path: '.worktrees/_meta', ok: true, movedTo: backup });
929
+ }
930
+ } catch {
931
+ // ignore
932
+ }
933
+
934
+ printResult({
935
+ json,
936
+ data: { ok: true, results },
937
+ text: [
938
+ '[edison] meta init: ok',
939
+ ...results.map((r) => `- ✅ ${r.path}${r.movedTo ? ` (moved to ${r.movedTo})` : ''}`),
940
+ ].join('\n'),
941
+ });
942
+ }
943
+
944
+ function truncateLines(raw, maxLines) {
945
+ const n = Number(maxLines);
946
+ if (!Number.isFinite(n) || n <= 0) return String(raw ?? '');
947
+ const lines = String(raw ?? '').split('\n');
948
+ if (lines.length <= n) return String(raw ?? '');
949
+ return `${lines.slice(0, n).join('\n')}\n… (${lines.length - n} more lines truncated)`;
950
+ }
951
+
952
+ function pathListSeparator() {
953
+ return process.platform === 'win32' ? ';' : ':';
954
+ }
955
+
956
+ async function maybeInstallCodexWrapper({ rootDir, env }) {
957
+ // Why this exists:
958
+ // - Edison validators may run Codex in a restricted sandbox mode.
959
+ // - Codex still needs to write its own rollout logs under ~/.codex/sessions for resume/history.
960
+ // - If ~/.codex isn't explicitly allowed as a writable dir, Codex can fail with permission errors.
961
+ //
962
+ // Our approach:
963
+ // - Do NOT override CODEX_HOME or copy credentials.
964
+ // - Instead, inject a tiny PATH-local wrapper for `codex` that forwards args and appends:
965
+ // --add-dir <home>/.codex
966
+ // when not already present.
967
+ //
968
+ // This applies only to processes launched via this `happys edison` wrapper.
969
+ let real = '';
970
+ try {
971
+ // NOTE: use the current PATH (before we inject our own wrapper) to find the real binary.
972
+ real = (await runCapture('which', ['codex'], { cwd: rootDir, env })).toString().trim();
973
+ } catch {
974
+ real = '';
975
+ }
976
+ if (!real) return;
977
+
978
+ const addDir = join(os.homedir(), '.codex');
979
+ const binDir = join(rootDir, '.edison', '_tmp', 'hs-bin');
980
+ const wrapperPath = join(binDir, process.platform === 'win32' ? 'codex.cmd' : 'codex');
981
+ const wrapperJsPath = join(binDir, 'codex-wrapper.mjs');
982
+
983
+ await mkdir(binDir, { recursive: true }).catch(() => {});
984
+
985
+ const wrapperJs = `#!/usr/bin/env node
986
+ import { spawn } from "node:child_process";
987
+ import os from "node:os";
988
+ import path from "node:path";
989
+
990
+ const realBin = process.env.CODEX_REAL_BIN || "";
991
+ if (!realBin) {
992
+ console.error("[happys edison] CODEX_REAL_BIN is not set; refusing to run codex wrapper.");
993
+ process.exit(127);
994
+ }
995
+
996
+ const desiredAddDir = process.env.CODEX_ADD_DIR || path.join(os.homedir(), ".codex");
997
+ const argv = process.argv.slice(2);
998
+
999
+ // If caller already set --add-dir, don't inject a duplicate.
1000
+ let hasAddDir = false;
1001
+ for (let i = 0; i < argv.length; i++) {
1002
+ const a = argv[i];
1003
+ if (a === "--add-dir") { hasAddDir = true; break; }
1004
+ if (a.startsWith("--add-dir=")) { hasAddDir = true; break; }
1005
+ }
1006
+
1007
+ const finalArgs = hasAddDir ? argv : ["--add-dir", desiredAddDir, ...argv];
1008
+
1009
+ const child = spawn(realBin, finalArgs, {
1010
+ stdio: "inherit",
1011
+ env: { ...process.env, CODEX_WRAPPER_ACTIVE: "1" },
1012
+ });
1013
+
1014
+ child.on("exit", (code, signal) => {
1015
+ if (signal) process.kill(process.pid, signal);
1016
+ process.exit(code ?? 1);
1017
+ });
1018
+ `;
1019
+
1020
+ await writeFile(wrapperJsPath, wrapperJs, 'utf-8');
1021
+ await chmod(wrapperJsPath, 0o755).catch(() => {});
1022
+
1023
+ if (process.platform === 'win32') {
1024
+ const cmd = `@echo off\r\nsetlocal\r\nnode "%~dp0\\codex-wrapper.mjs" %*\r\n`;
1025
+ await writeFile(wrapperPath, cmd, 'utf-8');
1026
+ } else {
1027
+ const sh = `#!/usr/bin/env bash\nset -euo pipefail\nexec node \"${wrapperJsPath}\" \"$@\"\n`;
1028
+ await writeFile(wrapperPath, sh, 'utf-8');
1029
+ await chmod(wrapperPath, 0o755).catch(() => {});
1030
+ }
1031
+
1032
+ const sep = pathListSeparator();
1033
+ const prevPath = (env.PATH ?? '').toString();
1034
+ env.PATH = `${binDir}${sep}${prevPath}`;
1035
+ env.CODEX_REAL_BIN = real;
1036
+ env.CODEX_ADD_DIR = addDir;
1037
+ }
1038
+
1039
+ function parseJsonArrayFromEnv(value) {
1040
+ try {
1041
+ const v = JSON.parse(String(value ?? ''));
1042
+ return Array.isArray(v) ? v : [];
1043
+ } catch {
1044
+ return [];
1045
+ }
1046
+ }
1047
+
1048
+ function firstExistingPath(paths) {
1049
+ for (const p of paths) {
1050
+ if (typeof p === 'string' && p.trim()) return p.trim();
1051
+ }
1052
+ return '';
1053
+ }
1054
+
1055
+ async function resolveRealBinary({ rootDir, env, name, ignorePrefix }) {
1056
+ // Prefer a non-wrapper binary if a wrapper is already on PATH.
1057
+ try {
1058
+ const raw = (await runCapture('which', ['-a', name], { cwd: rootDir, env })).toString();
1059
+ const candidates = raw
1060
+ .split('\n')
1061
+ .map((s) => s.trim())
1062
+ .filter(Boolean);
1063
+ for (const c of candidates) {
1064
+ if (ignorePrefix && c.startsWith(ignorePrefix)) continue;
1065
+ return c;
1066
+ }
1067
+ } catch {
1068
+ // fall through
1069
+ }
1070
+ // Fallback: best-effort single path.
1071
+ try {
1072
+ const single = (await runCapture('which', [name], { cwd: rootDir, env })).toString().trim();
1073
+ if (ignorePrefix && single.startsWith(ignorePrefix)) return '';
1074
+ return single;
1075
+ } catch {
1076
+ return '';
1077
+ }
1078
+ }
1079
+
1080
+ async function maybeInstallCodeRabbitWrapper({ rootDir, env, componentDirs = [] }) {
1081
+ // Problem:
1082
+ // - Some tools may call `coderabbit` without args in non-interactive mode, which can yield
1083
+ // progress-only output. Provide a deterministic default invocation in that case.
1084
+ //
1085
+ // Fix:
1086
+ // - Inject a PATH-local wrapper for `coderabbit` so that when Edison calls it with no args,
1087
+ // we force a deterministic, non-interactive `coderabbit review --plain ...` run.
1088
+ const binDir = join(rootDir, '.edison', '_tmp', 'hs-bin');
1089
+ const real = await resolveRealBinary({ rootDir, env, name: 'coderabbit', ignorePrefix: binDir });
1090
+ if (!real) return;
1091
+
1092
+ const wrapperPath = join(binDir, process.platform === 'win32' ? 'coderabbit.cmd' : 'coderabbit');
1093
+ const wrapperJsPath = join(binDir, 'coderabbit-wrapper.mjs');
1094
+
1095
+ await mkdir(binDir, { recursive: true }).catch(() => {});
1096
+
1097
+ const wrapperJs = `#!/usr/bin/env node
1098
+ import { spawn } from "node:child_process";
1099
+
1100
+ const realBin = process.env.CODERABBIT_REAL_BIN || "";
1101
+ if (!realBin) {
1102
+ console.error("[happys edison] CODERABBIT_REAL_BIN is not set; refusing to run coderabbit wrapper.");
1103
+ process.exit(127);
1104
+ }
1105
+
1106
+ const argv = process.argv.slice(2);
1107
+ const defaultCwd = process.env.CODERABBIT_CWD || process.cwd();
1108
+ const configFile = process.env.CODERABBIT_CONFIG_FILE || "";
1109
+
1110
+ // If Edison calls 'coderabbit' with no args, force a deterministic review run.
1111
+ // If args are present, pass through unchanged.
1112
+ const finalArgs = argv.length
1113
+ ? argv
1114
+ : [
1115
+ "review",
1116
+ "--plain",
1117
+ "--type",
1118
+ "all",
1119
+ "--no-color",
1120
+ "--cwd",
1121
+ defaultCwd,
1122
+ ...(configFile ? ["--config", configFile] : []),
1123
+ ];
1124
+
1125
+ let attempts = 0;
1126
+ function spawnOnce() {
1127
+ attempts += 1;
1128
+ const child = spawn(realBin, finalArgs, {
1129
+ stdio: "inherit",
1130
+ env: { ...process.env, CODERABBIT_WRAPPER_ACTIVE: "1" },
1131
+ });
1132
+
1133
+ child.on("error", (err) => {
1134
+ if (err && err.code === "EAGAIN" && attempts < 5) {
1135
+ setTimeout(spawnOnce, 250);
1136
+ return;
1137
+ }
1138
+ console.error(err);
1139
+ process.exit(1);
1140
+ });
1141
+
1142
+ child.on("exit", (code, signal) => {
1143
+ if (signal) process.kill(process.pid, signal);
1144
+ process.exit(code ?? 1);
1145
+ });
1146
+ }
1147
+
1148
+ spawnOnce();
1149
+ `;
1150
+
1151
+ await writeFile(wrapperJsPath, wrapperJs, 'utf-8');
1152
+ await chmod(wrapperJsPath, 0o755).catch(() => {});
1153
+
1154
+ if (process.platform === 'win32') {
1155
+ const cmd = `@echo off\r\nsetlocal\r\nnode "%~dp0\\coderabbit-wrapper.mjs" %*\r\n`;
1156
+ await writeFile(wrapperPath, cmd, 'utf-8');
1157
+ } else {
1158
+ const sh = `#!/usr/bin/env bash\nset -euo pipefail\nexec node \"${wrapperJsPath}\" \"$@\"\n`;
1159
+ await writeFile(wrapperPath, sh, 'utf-8');
1160
+ await chmod(wrapperPath, 0o755).catch(() => {});
1161
+ }
1162
+
1163
+ // Choose a review cwd that matches the task scope as closely as possible.
1164
+ // Happy-stacks sets EDISON_CI__FINGERPRINT__GIT_ROOTS to the targeted component repos.
1165
+ const roots = parseJsonArrayFromEnv(env.EDISON_CI__FINGERPRINT__GIT_ROOTS);
1166
+ const wt = componentDirs.find((p) => typeof p === 'string' && p.includes('/components/.worktrees/')) || '';
1167
+ const inferredCwd = wt || firstExistingPath(roots) || (env.AGENTS_PROJECT_ROOT ?? '').toString() || rootDir;
1168
+
1169
+ const sep = pathListSeparator();
1170
+ const prevPath = (env.PATH ?? '').toString();
1171
+ env.PATH = `${binDir}${sep}${prevPath}`;
1172
+ env.CODERABBIT_REAL_BIN = real;
1173
+ env.CODERABBIT_CWD = inferredCwd;
1174
+ }
1175
+
1176
+ async function listTaskFilesAll({ rootDir }) {
1177
+ const out = await listTaskFiles({ rootDir });
1178
+ const taskStates = ['todo', 'wip', 'done', 'validated', 'blocked'];
1179
+ const sessionsRoot = join(rootDir, '.project', 'sessions');
1180
+ try {
1181
+ const sessStates = await readdir(sessionsRoot, { withFileTypes: true });
1182
+ for (const s of sessStates) {
1183
+ if (!s.isDirectory()) continue;
1184
+ const sessStateDir = join(sessionsRoot, s.name);
1185
+ // eslint-disable-next-line no-await-in-loop
1186
+ const sessIds = await readdir(sessStateDir, { withFileTypes: true }).catch(() => []);
1187
+ for (const sid of sessIds) {
1188
+ if (!sid.isDirectory()) continue;
1189
+ const base = join(sessStateDir, sid.name, 'tasks');
1190
+ for (const st of taskStates) {
1191
+ const dir = join(base, st);
1192
+ // eslint-disable-next-line no-await-in-loop
1193
+ if (!(await pathExists(dir))) continue;
1194
+ // eslint-disable-next-line no-await-in-loop
1195
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
1196
+ for (const e of entries) {
1197
+ if (!e.isFile()) continue;
1198
+ if (!e.name.endsWith('.md')) continue;
1199
+ out.push({ id: e.name.slice(0, -3), path: join(dir, e.name) });
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+ } catch {
1205
+ // ignore
1206
+ }
1207
+ return out;
1208
+ }
1209
+
1210
+ async function scanTasksAll({ rootDir }) {
1211
+ const files = await listTaskFilesAll({ rootDir });
1212
+ const tasks = [];
1213
+ for (const f of files) {
1214
+ // eslint-disable-next-line no-await-in-loop
1215
+ const text = await readFile(f.path, 'utf-8').catch(() => '');
1216
+ const { fm } = parseFrontmatter(text);
1217
+ tasks.push({ id: f.id, path: f.path, fm });
1218
+ }
1219
+ return tasks;
1220
+ }
1221
+
1222
+ async function gitCapture({ cwd, args }) {
1223
+ return (await runCapture('git', args, { cwd })).toString();
1224
+ }
1225
+
1226
+ async function gitOk({ cwd }) {
1227
+ try {
1228
+ const out = await gitCapture({ cwd, args: ['rev-parse', '--is-inside-work-tree'] });
1229
+ return out.trim() === 'true';
1230
+ } catch {
1231
+ return false;
1232
+ }
1233
+ }
1234
+
1235
+ async function gitHead({ cwd }) {
1236
+ return (await gitCapture({ cwd, args: ['rev-parse', 'HEAD'] })).trim();
1237
+ }
1238
+
1239
+ async function gitHasObject({ cwd, sha }) {
1240
+ try {
1241
+ await gitCapture({ cwd, args: ['cat-file', '-e', `${sha}^{commit}`] });
1242
+ return true;
1243
+ } catch {
1244
+ return false;
1245
+ }
1246
+ }
1247
+
1248
+ async function gitMergeBase({ cwd, left, right }) {
1249
+ try {
1250
+ return (await gitCapture({ cwd, args: ['merge-base', left, right] })).trim();
1251
+ } catch {
1252
+ return '';
1253
+ }
1254
+ }
1255
+
1256
+ function parseLeftRightLog(raw) {
1257
+ const missing = [];
1258
+ const extra = [];
1259
+ for (const line of String(raw || '').split('\n')) {
1260
+ if (!line.trim()) continue;
1261
+ const trimmed = line.trimStart();
1262
+ if (trimmed.startsWith('<')) missing.push(trimmed);
1263
+ else if (trimmed.startsWith('>')) extra.push(trimmed);
1264
+ }
1265
+ return { missing, extra };
1266
+ }
1267
+
1268
+ function resolveComponentDirFromStackEnv({ rootDir, stackEnv, component }) {
1269
+ const idx = COMPONENTS.indexOf(component);
1270
+ if (idx < 0) return '';
1271
+ const dirs = resolveComponentDirsFromStackEnv({ rootDir, stackEnv });
1272
+ return String(dirs[idx] ?? '').trim();
1273
+ }
1274
+
1275
+ async function resolveTargetComponentsForTask({ rootDir, taskId }) {
1276
+ const id = String(taskId ?? '').trim();
1277
+ if (!id) return [];
1278
+ const mdPath = await resolveTaskFilePath({ rootDir, taskId: id });
1279
+ if (!mdPath) return [];
1280
+ const fm = await readFrontmatterFile(mdPath);
1281
+ const hsKind = String(fm?.hs_kind ?? '').trim().toLowerCase();
1282
+
1283
+ const readComponents = (v) => {
1284
+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
1285
+ if (typeof v === 'string') return v.split(',').map((p) => p.trim()).filter(Boolean);
1286
+ return [];
1287
+ };
1288
+
1289
+ if (hsKind === 'component') {
1290
+ const c = String(fm?.component ?? '').trim();
1291
+ const comps = c ? [c] : readComponents(fm?.components);
1292
+ return comps;
1293
+ }
1294
+
1295
+ // Track/parent: can declare multiple components.
1296
+ return readComponents(fm?.components);
1297
+ }
1298
+
1299
+ async function resolveFingerprintGitRoots({ rootDir, stackEnv, edisonArgs }) {
1300
+ // Default: include only the stack's component repos (never include the happy-local orchestration repo).
1301
+ const fallback = resolveComponentDirsFromStackEnv({ rootDir, stackEnv }).filter(Boolean);
1302
+
1303
+ const taskId = await inferTaskIdFromArgs({ rootDir, edisonArgs });
1304
+ if (!taskId) return fallback;
1305
+
1306
+ const targets = await resolveTargetComponentsForTask({ rootDir, taskId });
1307
+ if (!targets.length) return fallback;
1308
+
1309
+ const dirs = [];
1310
+ for (const c of targets) {
1311
+ // Allow tasks to explicitly target the orchestration repo (happy-local) by declaring it.
1312
+ // Otherwise, keep happy-local out of evidence fingerprints to avoid invalidating evidence
1313
+ // when editing wrapper scripts/docs unrelated to the component task under review.
1314
+ if (c === 'happy-local' || c === 'happy-stacks' || c === 'happy_local' || c === 'happyStacks') {
1315
+ dirs.push(rootDir);
1316
+ continue;
1317
+ }
1318
+ const d = resolveComponentDirFromStackEnv({ rootDir, stackEnv, component: c });
1319
+ if (d) dirs.push(d);
1320
+ }
1321
+ return dirs.length ? dirs : fallback;
1322
+ }
1323
+
1324
+ async function cmdTrackCoherence({ rootDir, argv, json }) {
1325
+ const { flags, kv } = parseArgs(argv);
1326
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1327
+ const taskId = positionals[1]?.trim?.() ? positionals[1].trim() : '';
1328
+ if (!taskId) {
1329
+ throw new Error(
1330
+ '[edison] usage: happys edison track:coherence <task-id> [--source=upstream] [--targets=fork,integration] [--max-lines=120] [--fail-on-extra] [--enforce] [--json]'
1331
+ );
1332
+ }
1333
+
1334
+ const source = (kv.get('--source') ?? '').toString().trim() || 'upstream';
1335
+ const targetsRaw = (kv.get('--targets') ?? '').toString().trim();
1336
+ const maxLinesRaw = (kv.get('--max-lines') ?? '').toString().trim();
1337
+ const maxLines = maxLinesRaw ? Number(maxLinesRaw) : 120;
1338
+ const failOnExtra = flags.has('--fail-on-extra');
1339
+ const enforce =
1340
+ flags.has('--enforce') || (process.env.HAPPY_STACKS_TRACK_COHERENCE_ENFORCE ?? '').toString().trim() === '1';
1341
+ const includeDiff = !flags.has('--no-diff');
1342
+
1343
+ const mdPath = await resolveTaskFilePath({ rootDir, taskId });
1344
+ if (!mdPath) {
1345
+ throw new Error(`[edison] task not found: ${taskId}`);
1346
+ }
1347
+ const fm = await readFrontmatterFile(mdPath);
1348
+ const hsKind = String(fm?.hs_kind ?? '').trim().toLowerCase();
1349
+ if (!hsKind || !['parent', 'track', 'component'].includes(hsKind)) {
1350
+ throw new Error(`[edison] missing/invalid hs_kind in task frontmatter (task=${taskId})`);
1351
+ }
1352
+
1353
+ const baseTask = String(fm?.base_task ?? '').trim() || (hsKind === 'parent' ? taskId : '');
1354
+ if (!baseTask) {
1355
+ throw new Error(`[edison] missing base_task in task frontmatter (task=${taskId}).`);
1356
+ }
1357
+
1358
+ const tasks = await scanTasksAll({ rootDir });
1359
+ const trackTasks = tasks
1360
+ .filter((t) => String(t.fm?.hs_kind ?? '').trim().toLowerCase() === 'track')
1361
+ .filter((t) => String(t.fm?.base_task ?? '').trim() === baseTask);
1362
+
1363
+ const trackMap = new Map();
1364
+ for (const t of trackTasks) {
1365
+ const track = String(t.fm?.track ?? '').trim();
1366
+ const stack = String(t.fm?.stack ?? '').trim();
1367
+ if (!track || !stack) continue;
1368
+ trackMap.set(track, { taskId: t.id, stack, fm: t.fm });
1369
+ }
1370
+
1371
+ const sourceTrack = trackMap.get(source);
1372
+ if (!sourceTrack) {
1373
+ printResult({
1374
+ json,
1375
+ data: { ok: true, skipped: true, reason: 'missing_source_track', source, baseTask, taskId },
1376
+ text: `[edison] track:coherence: SKIP (no "${source}" track found for base_task=${baseTask})`,
1377
+ });
1378
+ return;
1379
+ }
1380
+
1381
+ const targetTracks = targetsRaw
1382
+ ? targetsRaw.split(',').map((p) => p.trim()).filter(Boolean)
1383
+ : Array.from(trackMap.keys()).filter((t) => t !== source);
1384
+
1385
+ if (!targetTracks.length) {
1386
+ printResult({
1387
+ json,
1388
+ data: { ok: true, skipped: true, reason: 'no_targets', source, baseTask, taskId },
1389
+ text: `[edison] track:coherence: SKIP (no target tracks to compare; base_task=${baseTask})`,
1390
+ });
1391
+ return;
1392
+ }
1393
+
1394
+ const components = resolveComponentsFromFrontmatter(fm);
1395
+ const compsToCheck = hsKind === 'component' ? components.slice(0, 1) : components;
1396
+ if (!compsToCheck.length) {
1397
+ throw new Error(`[edison] track:coherence: missing components/component in task frontmatter (task=${taskId})`);
1398
+ }
1399
+
1400
+ const sourceEnvPath = resolveStackEnvPath(sourceTrack.stack).envPath;
1401
+ const sourceEnvRaw = await readExistingEnv(sourceEnvPath);
1402
+ if (!sourceEnvRaw.trim()) {
1403
+ throw new Error(
1404
+ `[edison] track:coherence: source stack env missing/empty for stack="${sourceTrack.stack}" (expected ${sourceEnvPath})`
1405
+ );
1406
+ }
1407
+ const sourceEnv = parseEnvToObject(sourceEnvRaw);
1408
+
1409
+ const results = [];
1410
+ const failures = [];
1411
+
1412
+ for (const targetName of targetTracks) {
1413
+ const targetTrack = trackMap.get(targetName);
1414
+ if (!targetTrack) continue;
1415
+
1416
+ const targetEnvPath = resolveStackEnvPath(targetTrack.stack).envPath;
1417
+ const targetEnvRaw = await readExistingEnv(targetEnvPath);
1418
+ if (!targetEnvRaw.trim()) {
1419
+ failures.push({
1420
+ kind: 'missing_stack_env',
1421
+ target: targetName,
1422
+ stack: targetTrack.stack,
1423
+ envPath: targetEnvPath,
1424
+ });
1425
+ continue;
1426
+ }
1427
+ const targetEnv = parseEnvToObject(targetEnvRaw);
1428
+
1429
+ for (const comp of compsToCheck) {
1430
+ const a = resolveComponentDirFromStackEnv({ rootDir, stackEnv: sourceEnv, component: comp });
1431
+ const b = resolveComponentDirFromStackEnv({ rootDir, stackEnv: targetEnv, component: comp });
1432
+ if (!a || !b) {
1433
+ failures.push({ kind: 'missing_component_dir', component: comp, source, target: targetName });
1434
+ continue;
1435
+ }
1436
+ const aIsRepo = await gitOk({ cwd: a });
1437
+ const bIsRepo = await gitOk({ cwd: b });
1438
+ if (!aIsRepo || !bIsRepo) {
1439
+ failures.push({
1440
+ kind: 'not_git_repo',
1441
+ component: comp,
1442
+ source,
1443
+ target: targetName,
1444
+ sourceDir: a,
1445
+ targetDir: b,
1446
+ });
1447
+ continue;
1448
+ }
1449
+
1450
+ const shaA = await gitHead({ cwd: a });
1451
+ const shaB = await gitHead({ cwd: b });
1452
+
1453
+ // Ensure both SHAs are visible from the repo's object database (worktrees should share it).
1454
+ const aHasB = await gitHasObject({ cwd: a, sha: shaB });
1455
+ const bHasA = await gitHasObject({ cwd: b, sha: shaA });
1456
+ if (!aHasB && !bHasA) {
1457
+ failures.push({
1458
+ kind: 'missing_git_objects',
1459
+ component: comp,
1460
+ source,
1461
+ target: targetName,
1462
+ sourceDir: a,
1463
+ targetDir: b,
1464
+ sourceHead: shaA,
1465
+ targetHead: shaB,
1466
+ });
1467
+ continue;
1468
+ }
1469
+
1470
+ // Prefer running comparisons from the side that can see both objects.
1471
+ const compareCwd = aHasB ? a : b;
1472
+ const left = shaA;
1473
+ const right = shaB;
1474
+
1475
+ let logOut = '';
1476
+ try {
1477
+ logOut = await gitCapture({
1478
+ cwd: compareCwd,
1479
+ args: ['log', '--left-right', '--cherry-pick', '--no-merges', '--oneline', `${left}...${right}`],
1480
+ });
1481
+ } catch (e) {
1482
+ failures.push({
1483
+ kind: 'git_log_failed',
1484
+ component: comp,
1485
+ source,
1486
+ target: targetName,
1487
+ err: String(e?.message ?? e),
1488
+ });
1489
+ continue;
1490
+ }
1491
+
1492
+ const { missing, extra } = parseLeftRightLog(logOut);
1493
+
1494
+ let diffShortstat = '';
1495
+ let diffNameStatus = '';
1496
+ let mergeBase = '';
1497
+ let rangeDiff = '';
1498
+ if (includeDiff) {
1499
+ try {
1500
+ diffShortstat = (await gitCapture({ cwd: compareCwd, args: ['diff', '--shortstat', `${left}..${right}`] })).trim();
1501
+ } catch {
1502
+ diffShortstat = '';
1503
+ }
1504
+ try {
1505
+ diffNameStatus = truncateLines(
1506
+ await gitCapture({ cwd: compareCwd, args: ['diff', '--name-status', `${left}..${right}`] }),
1507
+ maxLines
1508
+ ).trim();
1509
+ } catch {
1510
+ diffNameStatus = '';
1511
+ }
1512
+ mergeBase = await gitMergeBase({ cwd: compareCwd, left, right });
1513
+ if (mergeBase) {
1514
+ try {
1515
+ rangeDiff = truncateLines(
1516
+ await gitCapture({ cwd: compareCwd, args: ['range-diff', `${mergeBase}..${left}`, `${mergeBase}..${right}`] }),
1517
+ maxLines
1518
+ ).trim();
1519
+ } catch {
1520
+ rangeDiff = '';
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ const entry = {
1526
+ component: comp,
1527
+ source,
1528
+ target: targetName,
1529
+ sourceStack: sourceTrack.stack,
1530
+ targetStack: targetTrack.stack,
1531
+ sourceDir: a,
1532
+ targetDir: b,
1533
+ sourceHead: shaA,
1534
+ targetHead: shaB,
1535
+ missing,
1536
+ extra,
1537
+ diffShortstat,
1538
+ diffNameStatus,
1539
+ mergeBase,
1540
+ rangeDiff,
1541
+ };
1542
+ results.push(entry);
1543
+
1544
+ if (missing.length) {
1545
+ failures.push({
1546
+ kind: 'missing_patches',
1547
+ component: comp,
1548
+ source,
1549
+ target: targetName,
1550
+ missingCount: missing.length,
1551
+ });
1552
+ }
1553
+ if (failOnExtra && extra.length) {
1554
+ failures.push({
1555
+ kind: 'extra_patches',
1556
+ component: comp,
1557
+ source,
1558
+ target: targetName,
1559
+ extraCount: extra.length,
1560
+ });
1561
+ }
1562
+ }
1563
+ }
1564
+
1565
+ const ok = failures.length === 0;
1566
+ const lines = [];
1567
+ lines.push(`[edison] track:coherence (${baseTask})`);
1568
+ lines.push(`- task: ${taskId} (hs_kind=${hsKind})`);
1569
+ lines.push(`- source: ${source} (stack=${sourceTrack.stack})`);
1570
+ lines.push(`- targets: ${targetTracks.join(', ')}`);
1571
+ lines.push(`- components: ${compsToCheck.join(', ')}`);
1572
+ lines.push(`- include diff: ${includeDiff ? 'yes' : 'no'}`);
1573
+ lines.push('');
1574
+
1575
+ for (const r of results) {
1576
+ const aWt = String(r.sourceDir).includes('/components/.worktrees/');
1577
+ const bWt = String(r.targetDir).includes('/components/.worktrees/');
1578
+ lines.push(`component: ${r.component} (${r.source} → ${r.target})`);
1579
+ lines.push(`- source stack: ${r.sourceStack}`);
1580
+ lines.push(`- target stack: ${r.targetStack}`);
1581
+ lines.push(`- source dir: ${r.sourceDir}${aWt ? '' : ' (WARNING: not a worktree path)'}`);
1582
+ lines.push(`- target dir: ${r.targetDir}${bWt ? '' : ' (WARNING: not a worktree path)'}`);
1583
+ if (r.diffShortstat) lines.push(`- diff: ${r.diffShortstat}`);
1584
+ if (r.diffNameStatus) {
1585
+ lines.push('- diff (name-status):');
1586
+ for (const ln of String(r.diffNameStatus).split('\n')) lines.push(` ${ln}`);
1587
+ }
1588
+ if (r.mergeBase && r.rangeDiff) {
1589
+ lines.push(`- merge-base: ${r.mergeBase}`);
1590
+ lines.push('- range-diff:');
1591
+ for (const ln of String(r.rangeDiff).split('\n')) lines.push(` ${ln}`);
1592
+ }
1593
+ lines.push(`- missing patches: ${r.missing.length}`);
1594
+ if (r.missing.length) {
1595
+ for (const m of r.missing.slice(0, maxLines)) lines.push(` ${m}`);
1596
+ if (r.missing.length > maxLines) lines.push(` … (${r.missing.length - maxLines} more missing truncated)`);
1597
+ }
1598
+ lines.push(`- extra patches: ${r.extra.length}${failOnExtra ? ' (FAIL-ON-EXTRA enabled)' : ''}`);
1599
+ if (r.extra.length) {
1600
+ for (const ex of r.extra.slice(0, maxLines)) lines.push(` ${ex}`);
1601
+ if (r.extra.length > maxLines) lines.push(` … (${r.extra.length - maxLines} more extra truncated)`);
1602
+ if (!failOnExtra) lines.push(' note: extra patches are allowed by default; use --fail-on-extra to make them fatal.');
1603
+ }
1604
+ lines.push('');
1605
+ }
1606
+
1607
+ if (!ok) {
1608
+ lines.push('FAILURES:');
1609
+ for (const f of failures) {
1610
+ lines.push(`- ${f.kind}: ${JSON.stringify(f)}`);
1611
+ }
1612
+ lines.push('');
1613
+ lines.push('tips:');
1614
+ lines.push('- ensure you created both tracks via: happys edison task:scaffold <parent-task-id> --mode=both --yes');
1615
+ lines.push('- ensure stacks point at the intended worktrees: happys stack wt <stack> -- status');
1616
+ lines.push('- if git objects are missing, sync mirrors: happys wt sync-all');
1617
+ }
1618
+
1619
+ printResult({
1620
+ json,
1621
+ data: {
1622
+ ok,
1623
+ baseTask,
1624
+ taskId,
1625
+ source,
1626
+ targets: targetTracks,
1627
+ results,
1628
+ failures,
1629
+ failOnExtra,
1630
+ includeDiff,
1631
+ maxLines,
1632
+ },
1633
+ text: lines.join('\n'),
1634
+ });
1635
+
1636
+ if (!ok && enforce) process.exit(1);
1637
+ }
1638
+
1639
+ async function main() {
1640
+ const rootDir = getRootDir(import.meta.url);
1641
+ const argvRaw = process.argv.slice(2);
1642
+ const argv = argvRaw[0] === '--' ? argvRaw.slice(1) : argvRaw;
1643
+ const { flags, kv } = parseArgs(argv);
1644
+ const json = wantsJson(argv, { flags });
1645
+
1646
+ if (wantsHelp(argv, { flags })) {
1647
+ printResult({
1648
+ json,
1649
+ data: { flags: ['--stack=<name>', '--json'], examples: true },
1650
+ text: [
1651
+ '[edison] usage:',
1652
+ ' happys edison [--stack=<name>] -- <edison args...>',
1653
+ ' happys edison meta:init [--json]',
1654
+ ' happys edison task:scaffold <task-id> [--mode=upstream|fork|both] [--tracks=upstream,fork] [--yes] [--json]',
1655
+ ' happys edison track:coherence <task-id> [--source=upstream] [--targets=fork,integration] [--max-lines=120] [--fail-on-extra] [--enforce] [--no-diff] [--json]',
1656
+ '',
1657
+ 'examples:',
1658
+ ' happys edison -- compose all',
1659
+ ' happys edison --stack=exp1 -- evidence capture T-123',
1660
+ ' happys edison task:scaffold T-123 --yes',
1661
+ ' happys edison track:coherence T-123.1 --json',
1662
+ ' happys edison meta:init',
1663
+ '',
1664
+ 'notes:',
1665
+ '- When --stack is provided, this wrapper:',
1666
+ ' - exports HAPPY_STACKS_ENV_FILE + HAPPY_STACKS_STACK for stack-scoped commands',
1667
+ ' - configures Edison evidence fingerprinting to include the stack’s resolved component repos',
1668
+ '',
1669
+ 'happy-stacks task model (MANDATORY):',
1670
+ '- hs_kind=parent is a planning umbrella (NOT claimable)',
1671
+ '- hs_kind=track owns exactly one stack (one stack per track)',
1672
+ '- hs_kind=component implements exactly one component under a track',
1673
+ ].join('\n'),
1674
+ });
1675
+ return;
1676
+ }
1677
+
1678
+ // One-time setup helper for this repo: keep Edison/tool state in `.worktrees/_meta` via symlinks,
1679
+ // without using git worktrees.
1680
+ if (argv[0] === 'meta:init') {
1681
+ await cmdMetaInit({ rootDir, json });
1682
+ return;
1683
+ }
1684
+ if (argv[0] === 'task:scaffold') {
1685
+ await cmdTaskScaffold({ rootDir, argv, json });
1686
+ return;
1687
+ }
1688
+ if (argv[0] === 'track:coherence') {
1689
+ await cmdTrackCoherence({ rootDir, argv, json });
1690
+ return;
1691
+ }
1692
+
1693
+ const stackFlag = (kv.get('--stack') ?? '').toString().trim();
1694
+ // Back-compat: older parseArgs implementations used `kv.stack`; keep it if present.
1695
+ const legacyStackFlag = (kv.stack ?? '').toString().trim();
1696
+ let stackName = stackFlag || legacyStackFlag || (process.env.HAPPY_STACKS_STACK ?? '').toString().trim();
1697
+
1698
+ let env = { ...process.env };
1699
+ // If no stack was provided, best-effort infer it from a task/QA id passed to the command.
1700
+ // This allows `happys edison -- evidence capture <task-id>` (no explicit --stack) to be stack-scoped automatically.
1701
+ if (!stackName) {
1702
+ const inferred = await inferStackFromArgs({ rootDir, edisonArgs: argv.filter((a) => a !== '--') });
1703
+ if (inferred) stackName = inferred;
1704
+ }
1705
+ if (stackName) {
1706
+ const { envPath } = resolveStackEnvPath(stackName);
1707
+ const raw = await readExistingEnv(envPath);
1708
+ if (!raw.trim()) {
1709
+ throw new Error(
1710
+ `[edison] stack "${stackName}" inferred/provided but env file is missing/empty.\n` +
1711
+ `Fix:\n` +
1712
+ ` happys stack new ${stackName} --interactive\n`
1713
+ );
1714
+ }
1715
+ const stackEnv = parseEnvToObject(raw);
1716
+
1717
+ const cleaned = cleanHappyStacksEnv(env);
1718
+ env = {
1719
+ ...cleaned,
1720
+ // IMPORTANT: stack env file must be authoritative.
1721
+ // Export its full contents so Edison/guards/evidence runs are fail-closed and stack-scoped.
1722
+ ...stackEnv,
1723
+ HAPPY_STACKS_STACK: stackName,
1724
+ HAPPY_STACKS_ENV_FILE: envPath,
1725
+ HAPPY_LOCAL_STACK: stackName,
1726
+ HAPPY_LOCAL_ENV_FILE: envPath,
1727
+ // Marker for Edison-core wrapper enforcement in this repo.
1728
+ HAPPY_STACKS_EDISON_WRAPPER: '1',
1729
+ };
1730
+
1731
+ // We intentionally DO NOT include the happy-local repo root in evidence fingerprints by default.
1732
+ // Fingerprints should reflect only the task's target component repos (happy/happy-cli/etc).
1733
+ const componentDirs = resolveComponentDirsFromStackEnv({ rootDir, stackEnv });
1734
+
1735
+ if (!json) {
1736
+ const pretty = COMPONENTS.map((name, i) => {
1737
+ const p = componentDirs[i];
1738
+ const isWt = String(p).includes('/components/.worktrees/');
1739
+ return ` - ${name}: ${p}${isWt ? '' : ' (WARNING: not a worktree path)'}`;
1740
+ });
1741
+ // eslint-disable-next-line no-console
1742
+ console.log(
1743
+ [
1744
+ `[edison] stack=${stackName} (stack-scoped)`,
1745
+ '[edison] IMPORTANT:',
1746
+ '- never edit default checkouts under components/<component>',
1747
+ '- always run inside the stack context + component worktrees',
1748
+ '- task model: parent (planning) -> track (owns 1 stack) -> component (owns 1 component)',
1749
+ '[edison] component dirs (from stack env):',
1750
+ ...pretty,
1751
+ ].join('\n')
1752
+ );
1753
+ }
1754
+ }
1755
+ // Marker for Edison-core wrapper enforcement in this repo (ensure it survives any env merges).
1756
+ env.HAPPY_STACKS_EDISON_WRAPPER = '1';
1757
+ // Provide a stack-scoped localhost hostname for validators and browser flows.
1758
+ // This ensures origin isolation even if ports are reused later (common with ephemeral ports).
1759
+ const localhostHost = resolveLocalhostHost({ stackMode: Boolean(stackName), stackName: stackName || 'main' });
1760
+ env.HAPPY_STACKS_LOCALHOST_HOST = localhostHost;
1761
+ env.HAPPY_LOCAL_LOCALHOST_HOST = localhostHost;
1762
+
1763
+ // Forward all args to `edison`.
1764
+ //
1765
+ // IMPORTANT: Edison CLI does not accept `--repo-root` as a global flag (it is a per-command flag),
1766
+ // so we MUST NOT prepend `--repo-root <rootDir>` ahead of the domain.
1767
+ //
1768
+ // Instead, set AGENTS_PROJECT_ROOT so Edison resolves the correct repo root automatically.
1769
+ // Do not forward wrapper flags (e.g. --stack=...) to the Python `edison` CLI.
1770
+ const forward = [];
1771
+ for (let i = 0; i < argv.length; i++) {
1772
+ const a = argv[i];
1773
+ if (a === '--') continue;
1774
+ // Wrapper-only stack flag (both forms). Never forward to Python edison.
1775
+ if (a === '--stack') {
1776
+ i += 1; // skip the value
1777
+ continue;
1778
+ }
1779
+ if (a.startsWith('--stack=')) continue;
1780
+ forward.push(a);
1781
+ }
1782
+ env.AGENTS_PROJECT_ROOT = env.AGENTS_PROJECT_ROOT || rootDir;
1783
+ const edisonArgs = forward;
1784
+ enforceValidatePresetPolicy({ edisonArgs });
1785
+
1786
+ // Configure Edison evidence fingerprinting to include ONLY repos the task targets (not happy-local itself).
1787
+ // This prevents unrelated changes in happy-local scripts/docs from invalidating command evidence
1788
+ // for tasks that target component repos (happy, happy-cli, etc).
1789
+ if (stackName) {
1790
+ const { envPath } = resolveStackEnvPath(stackName);
1791
+ const raw = await readExistingEnv(envPath);
1792
+ const stackEnv = parseEnvToObject(raw);
1793
+
1794
+ const roots = await resolveFingerprintGitRoots({ rootDir, stackEnv, edisonArgs });
1795
+ env.EDISON_CI__FINGERPRINT__GIT_ROOTS = JSON.stringify(roots);
1796
+ // Stack env file still matters for stack-scoped commands (component dir overrides, server URLs, etc).
1797
+ env.EDISON_CI__FINGERPRINT__EXTRA_FILES = JSON.stringify([envPath]);
1798
+ }
1799
+
1800
+ // Best-effort: if `edison` is not installed, print a helpful message.
1801
+ try {
1802
+ // eslint-disable-next-line no-console
1803
+ if (stackName && !json) console.log(`[edison] stack=${stackName}`);
1804
+ if (stackName) {
1805
+ await ensureStackServerPortForWebServerValidation({ rootDir, stackName, env, edisonArgs, json });
1806
+ }
1807
+ await maybeInstallCodexWrapper({ rootDir, env });
1808
+ await maybeInstallCodeRabbitWrapper({ rootDir, env });
1809
+ await enforceQaRunPreflightPolicy({ rootDir, env, edisonArgs });
1810
+ if (!(await pathExists(rootDir))) {
1811
+ throw new Error(`[edison] missing repo root: ${rootDir}`);
1812
+ }
1813
+ await run('edison', edisonArgs, { cwd: rootDir, env });
1814
+ } catch (e) {
1815
+ const msg = String(e?.message ?? e);
1816
+ const hint = msg.includes('ENOENT') || msg.toLowerCase().includes('not found')
1817
+ ? '\n[edison] tip: install edison (alpha) in your environment, or run it via your local dev checkout.\n'
1818
+ : '';
1819
+ printResult({ json, data: { ok: false, error: msg }, text: `[edison] failed: ${msg}${hint}` });
1820
+ process.exit(1);
1821
+ }
1822
+ }
1823
+
1824
+ main().catch((err) => {
1825
+ console.error('[edison] failed:', err);
1826
+ process.exit(1);
1827
+ });
1828
+