happy-stacks 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
  2. package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
  3. package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
  6. package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
  8. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
  9. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1297 -140
  10. package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
  11. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
  12. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
  13. package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
  14. package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
  15. package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
  16. package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
  17. package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
  18. package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
  19. package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
  20. package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
  21. package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
  22. package/docs/commit-audits/happy/pr-desc.original.md +0 -0
  23. package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
  24. package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
  25. package/docs/happy-development.md +18 -1
  26. package/docs/isolated-linux-vm.md +23 -1
  27. package/docs/stacks.md +21 -1
  28. package/package.json +1 -1
  29. package/scripts/auth.mjs +46 -8
  30. package/scripts/daemon.mjs +44 -21
  31. package/scripts/doctor.mjs +2 -2
  32. package/scripts/doctor_cmd.test.mjs +67 -0
  33. package/scripts/happy.mjs +18 -5
  34. package/scripts/import.mjs +19 -13
  35. package/scripts/monorepo.mjs +414 -23
  36. package/scripts/monorepo_port.test.mjs +136 -0
  37. package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
  38. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  39. package/scripts/review.mjs +347 -124
  40. package/scripts/review_pr.mjs +78 -2
  41. package/scripts/run.mjs +2 -1
  42. package/scripts/stack.mjs +265 -19
  43. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  44. package/scripts/stack_happy_cmd.test.mjs +103 -0
  45. package/scripts/utils/cli/prereqs.mjs +12 -1
  46. package/scripts/utils/cli/wizard.mjs +19 -1
  47. package/scripts/utils/dev/daemon.mjs +3 -1
  48. package/scripts/utils/llm/assist.mjs +64 -31
  49. package/scripts/utils/llm/happys_runner.mjs +60 -0
  50. package/scripts/utils/llm/tools.mjs +3 -3
  51. package/scripts/utils/proc/pm.mjs +1 -1
  52. package/scripts/utils/review/detached_worktree.mjs +61 -0
  53. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  54. package/scripts/utils/review/findings.mjs +133 -20
  55. package/scripts/utils/review/findings.test.mjs +88 -1
  56. package/scripts/utils/review/runners/augment.mjs +71 -0
  57. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  58. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  59. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  60. package/scripts/utils/review/sliced_runner.mjs +39 -0
  61. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  62. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  63. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  64. package/scripts/utils/stack/cli_identities.mjs +29 -0
  65. package/scripts/utils/stack/startup.mjs +45 -7
  66. package/scripts/worktrees.mjs +8 -5
@@ -14,6 +14,7 @@ import { bold, cyan, dim, green, red, yellow } from './utils/ui/ansi.mjs';
14
14
  import { clipboardAvailable, copyTextToClipboard } from './utils/ui/clipboard.mjs';
15
15
  import { detectInstalledLlmTools } from './utils/llm/tools.mjs';
16
16
  import { launchLlmAssistant } from './utils/llm/assist.mjs';
17
+ import { buildHappyStacksRunnerShellSnippet } from './utils/llm/happys_runner.mjs';
17
18
 
18
19
  function usage() {
19
20
  return [
@@ -258,7 +259,7 @@ async function ensureClonedHappyMonorepo({ targetPath, repoUrl }) {
258
259
  return dest;
259
260
  }
260
261
 
261
- async function resolveOrCloneTargetRepoRoot({ targetInput, targetArg, flags, kv }) {
262
+ async function resolveOrCloneTargetRepoRoot({ targetInput, targetArg, flags, kv, progress } = {}) {
262
263
  const hint = String(targetInput ?? '').trim();
263
264
  if (!hint) throw new Error('[monorepo] missing target');
264
265
 
@@ -284,7 +285,9 @@ async function resolveOrCloneTargetRepoRoot({ targetInput, targetArg, flags, kv
284
285
  throw new Error('[monorepo] --clone-target requires an explicit --target=<dir>');
285
286
  }
286
287
  const targetRepo = String(kv?.get?.('--target-repo') ?? '').trim();
288
+ const spin = progress?.spinner?.(`Cloning target monorepo into ${hint}`);
287
289
  const cloned = await ensureClonedHappyMonorepo({ targetPath: hint, repoUrl: targetRepo || 'https://github.com/slopus/happy.git' });
290
+ spin?.succeed?.(`Cloned target monorepo (${hint})`);
288
291
  const clonedRoot = await resolveGitRoot(cloned);
289
292
  if (!clonedRoot || !isHappyMonorepoRoot(clonedRoot)) {
290
293
  throw new Error(`[monorepo] cloned target does not look like a slopus/happy monorepo root: ${cloned}`);
@@ -296,7 +299,7 @@ async function resolveOrCloneTargetRepoRoot({ targetInput, targetArg, flags, kv
296
299
  throw new Error(`[monorepo] target is not a git repo: ${hint}`);
297
300
  }
298
301
 
299
- async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRef = '' }) {
302
+ async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRef = '', progress } = {}) {
300
303
  const raw = String(spec ?? '').trim();
301
304
  if (!raw) return '';
302
305
 
@@ -324,12 +327,16 @@ async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRe
324
327
 
325
328
  if (!(await pathExists(dir))) {
326
329
  await mkdir(dirname(dir), { recursive: true });
330
+ const spin = progress?.spinner?.(`Cloning ${label} PR repo (${pr.owner}/${pr.repo}#${pr.number})`);
327
331
  await runCapture('git', ['clone', '--quiet', repoUrl, dir], { cwd: dirname(dir), env: gitNonInteractiveEnv() });
332
+ spin?.succeed?.(`Cloned ${label} PR repo (${pr.owner}/${pr.repo}#${pr.number})`);
328
333
  }
329
334
 
330
335
  const prRef = `refs/pull/${pr.number}/head`;
336
+ const spinFetch = progress?.spinner?.(`Fetching ${label} PR head (${prRef})`);
331
337
  await runCapture('git', ['fetch', '--quiet', 'origin', prRef], { cwd: dir, env: gitNonInteractiveEnv() });
332
338
  await runCapture('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dir, env: gitNonInteractiveEnv() });
339
+ spinFetch?.succeed?.(`Checked out ${label} PR head`);
333
340
  return dir;
334
341
  }
335
342
 
@@ -338,7 +345,9 @@ async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRe
338
345
  const dir = join(scratch, `${label}-${key}`);
339
346
  if (!(await pathExists(dir))) {
340
347
  await mkdir(dirname(dir), { recursive: true });
348
+ const spin = progress?.spinner?.(`Cloning ${label} source repo`);
341
349
  await runCapture('git', ['clone', '--quiet', raw, dir], { cwd: dirname(dir), env: gitNonInteractiveEnv() });
350
+ spin?.succeed?.(`Cloned ${label} source repo`);
342
351
  }
343
352
 
344
353
  // Best-effort: ensure the desired ref exists (if provided).
@@ -346,7 +355,9 @@ async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRe
346
355
  if (ref) {
347
356
  const ok = await gitOk(dir, ['rev-parse', '--verify', '--quiet', ref]);
348
357
  if (!ok) {
358
+ const spin = progress?.spinner?.(`Fetching ${label} ref (${ref})`);
349
359
  await git(dir, ['fetch', '--quiet', 'origin', ref]).catch(() => {});
360
+ spin?.succeed?.(`Fetched ${label} ref (${ref})`);
350
361
  }
351
362
  }
352
363
  return dir;
@@ -358,6 +369,105 @@ async function resolveGitPath(repoRoot, relPath) {
358
369
  return rel.startsWith('/') ? rel : join(repoRoot, rel);
359
370
  }
360
371
 
372
+ function isTestTty() {
373
+ return String(process.env.HAPPY_STACKS_TEST_TTY ?? '').trim() === '1';
374
+ }
375
+
376
+ function shouldShowProgress({ json, silent = false } = {}) {
377
+ if (silent) return false;
378
+ if (json) return false;
379
+ return true;
380
+ }
381
+
382
+ function createProgressReporter({ enabled, label = '[monorepo]' } = {}) {
383
+ const on = Boolean(enabled);
384
+ const canSpin = on && isTty() && !isTestTty();
385
+ const frames = ['|', '/', '-', '\\'];
386
+
387
+ const line = (s) => {
388
+ // eslint-disable-next-line no-console
389
+ console.log(s);
390
+ };
391
+
392
+ const spinner = (text) => {
393
+ const msg = String(text ?? '').trim();
394
+ if (!on) {
395
+ return {
396
+ update: () => {},
397
+ succeed: () => {},
398
+ fail: () => {},
399
+ };
400
+ }
401
+
402
+ if (!canSpin) {
403
+ line(`${dim(label)} ${msg}`);
404
+ return {
405
+ update: () => {},
406
+ succeed: (doneText) => {
407
+ const done = String(doneText ?? '').trim();
408
+ if (done) line(`${green('✓')} ${done}`);
409
+ },
410
+ fail: (failText) => {
411
+ const fail = String(failText ?? '').trim();
412
+ if (fail) line(`${yellow('!')} ${fail}`);
413
+ },
414
+ };
415
+ }
416
+
417
+ let idx = 0;
418
+ let current = msg;
419
+ let active = true;
420
+
421
+ const render = () => {
422
+ if (!active) return;
423
+ const f = frames[idx % frames.length];
424
+ idx += 1;
425
+ try {
426
+ process.stdout.write(`\r${dim(label)} ${current} ${dim(f)} `);
427
+ } catch {
428
+ // ignore
429
+ }
430
+ };
431
+
432
+ // Initial render + keepalive.
433
+ render();
434
+ const t = setInterval(render, 120);
435
+
436
+ const stop = () => {
437
+ active = false;
438
+ try {
439
+ clearInterval(t);
440
+ } catch {
441
+ // ignore
442
+ }
443
+ try {
444
+ process.stdout.write('\r' + ' '.repeat(Math.min(140, current.length + String(label).length + 16)) + '\r');
445
+ } catch {
446
+ // ignore
447
+ }
448
+ };
449
+
450
+ return {
451
+ update: (nextText) => {
452
+ current = String(nextText ?? '').trim() || current;
453
+ render();
454
+ },
455
+ succeed: (doneText) => {
456
+ stop();
457
+ const done = String(doneText ?? '').trim();
458
+ if (done) line(`${green('✓')} ${done}`);
459
+ },
460
+ fail: (failText) => {
461
+ stop();
462
+ const fail = String(failText ?? '').trim();
463
+ if (fail) line(`${yellow('!')} ${fail}`);
464
+ },
465
+ };
466
+ };
467
+
468
+ return { spinner, line };
469
+ }
470
+
361
471
  function section(title) {
362
472
  // eslint-disable-next-line no-console
363
473
  console.log('');
@@ -452,6 +562,28 @@ async function listConflictedFiles(repoRoot) {
452
562
  return Array.from(new Set(files)).sort();
453
563
  }
454
564
 
565
+ function hasConflictMarkers(text) {
566
+ const s = String(text ?? '');
567
+ // Typical git conflict markers at the start of a line.
568
+ return /^(<<<<<<< |>>>>>>> |\|\|\|\|\|\|\| )/m.test(s);
569
+ }
570
+
571
+ async function listFilesWithConflictMarkers(repoRoot, files) {
572
+ const fs = Array.isArray(files) ? files : [];
573
+ const hits = [];
574
+ for (const f of fs) {
575
+ const p = join(repoRoot, f);
576
+ try {
577
+ // eslint-disable-next-line no-await-in-loop
578
+ const raw = await readFile(p, 'utf-8');
579
+ if (hasConflictMarkers(raw)) hits.push(f);
580
+ } catch {
581
+ // ignore unreadable files
582
+ }
583
+ }
584
+ return hits;
585
+ }
586
+
455
587
  async function readGitAmStatus(targetRepoRoot) {
456
588
  const inProgress = await isGitAmInProgress(targetRepoRoot);
457
589
  const conflictedFiles = await listConflictedFiles(targetRepoRoot);
@@ -492,8 +624,13 @@ async function readGitAmStatus(targetRepoRoot) {
492
624
  return { inProgress, currentPatch, conflictedFiles };
493
625
  }
494
626
 
495
- async function formatPatchesToDir({ sourceRepoRoot, base, head, outDir }) {
627
+ async function formatPatchesToDir({ sourceRepoRoot, base, head, outDir, progressLabel = '', progress } = {}) {
628
+ const range = `${base}..${head}`;
629
+ const spin = progress?.spinner?.(
630
+ `Formatting patches${progressLabel ? ` (${progressLabel})` : ''} ${dim(`(${range})`)}`
631
+ );
496
632
  await run('git', ['format-patch', '--quiet', '--output-directory', outDir, `${base}..${head}`], { cwd: sourceRepoRoot });
633
+ spin?.succeed?.(`Formatted patches${progressLabel ? ` (${progressLabel})` : ''}`);
497
634
  const entries = await readdir(outDir, { withFileTypes: true });
498
635
  const patches = entries
499
636
  .filter((e) => e.isFile() && e.name.endsWith('.patch'))
@@ -639,7 +776,7 @@ async function checkPureNewFilesAlreadyExistIdentically({ targetRepoRoot, direct
639
776
  return { ok: true, paths };
640
777
  }
641
778
 
642
- async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skipApplied, continueOnFailure, quietGit }) {
779
+ async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skipApplied, continueOnFailure, quietGit, progress } = {}) {
643
780
  if (!patches.length) {
644
781
  return { applied: [], skippedAlreadyApplied: [], skippedAlreadyExistsIdentical: [], failed: [] };
645
782
  }
@@ -648,8 +785,21 @@ async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skip
648
785
  const skippedAlreadyApplied = [];
649
786
  const skippedAlreadyExistsIdentical = [];
650
787
  const failed = [];
651
-
652
- for (const patch of patches) {
788
+ const total = patches.length;
789
+ const targetLabel = directory ? `${directory}/` : '.';
790
+ const spin = progress?.spinner?.(`Applying patches into ${targetLabel} ${dim(`(0/${total})`)}`);
791
+ let lastUpdateAt = 0;
792
+
793
+ for (let i = 0; i < patches.length; i++) {
794
+ const patch = patches[i];
795
+ if (spin?.update) {
796
+ const now = Date.now();
797
+ // Avoid hammering the terminal. Update at most ~5x/sec.
798
+ if (now - lastUpdateAt > 200) {
799
+ lastUpdateAt = now;
800
+ spin.update(`Applying patches into ${targetLabel} ${dim(`(${i + 1}/${total})`)}`);
801
+ }
802
+ }
653
803
  const patchFile = basename(patch);
654
804
  // eslint-disable-next-line no-await-in-loop
655
805
  const patchText = await readFile(patch, 'utf-8');
@@ -763,6 +913,9 @@ async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skip
763
913
  }
764
914
  }
765
915
 
916
+ spin?.succeed?.(
917
+ `Applied patches into ${targetLabel} ${dim(`(applied=${applied.length} skipped=${skippedAlreadyApplied.length + skippedAlreadyExistsIdentical.length} failed=${failed.length})`)}`
918
+ );
766
919
  return {
767
920
  applied,
768
921
  skippedAlreadyApplied,
@@ -783,6 +936,7 @@ async function portOne({
783
936
  skipApplied,
784
937
  continueOnFailure,
785
938
  quietGit,
939
+ progress,
786
940
  }) {
787
941
  const sourceRepoRoot = await resolveGitRoot(sourcePath);
788
942
  if (!sourceRepoRoot) {
@@ -807,7 +961,14 @@ async function portOne({
807
961
 
808
962
  const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-port-'));
809
963
  try {
810
- const patches = await formatPatchesToDir({ sourceRepoRoot, base, head, outDir: tmp });
964
+ const patches = await formatPatchesToDir({
965
+ sourceRepoRoot,
966
+ base,
967
+ head,
968
+ outDir: tmp,
969
+ progressLabel: label,
970
+ progress,
971
+ });
811
972
  if (dryRun) {
812
973
  return {
813
974
  label,
@@ -829,6 +990,7 @@ async function portOne({
829
990
  skipApplied,
830
991
  continueOnFailure,
831
992
  quietGit,
993
+ progress,
832
994
  });
833
995
  return {
834
996
  label,
@@ -853,7 +1015,8 @@ async function portOne({
853
1015
  async function cmdPortRun({ argv, flags, kv, json, silent = false }) {
854
1016
  const targetArg = (kv.get('--target') ?? '').trim();
855
1017
  const targetHint = targetArg || process.cwd();
856
- const targetRepoRoot = await resolveOrCloneTargetRepoRoot({ targetInput: targetHint, targetArg, flags, kv });
1018
+ const progress = createProgressReporter({ enabled: shouldShowProgress({ json, silent }) });
1019
+ const targetRepoRoot = await resolveOrCloneTargetRepoRoot({ targetInput: targetHint, targetArg, flags, kv, progress });
857
1020
 
858
1021
  // Prefer a clearer error message if the user is in the middle of conflict resolution.
859
1022
  // (A git am session often makes the worktree dirty, which would otherwise trigger a generic "not clean" error.)
@@ -930,7 +1093,13 @@ async function cmdPortRun({ argv, flags, kv, json, silent = false }) {
930
1093
  for (const s of sources) {
931
1094
  // Allow sources to be local paths OR URL/PR specs (cloned into target/.git scratch).
932
1095
  // eslint-disable-next-line no-await-in-loop
933
- const resolvedPath = await ensureRepoSpecCheckedOut({ targetRepoRoot, label: s.label, spec: s.path, desiredRef: s.ref });
1096
+ const resolvedPath = await ensureRepoSpecCheckedOut({
1097
+ targetRepoRoot,
1098
+ label: s.label,
1099
+ spec: s.path,
1100
+ desiredRef: s.ref,
1101
+ progress,
1102
+ });
934
1103
  // eslint-disable-next-line no-await-in-loop
935
1104
  const r = await portOne({
936
1105
  label: s.label,
@@ -944,6 +1113,7 @@ async function cmdPortRun({ argv, flags, kv, json, silent = false }) {
944
1113
  skipApplied,
945
1114
  continueOnFailure,
946
1115
  quietGit,
1116
+ progress,
947
1117
  });
948
1118
  results.push(r);
949
1119
  }
@@ -1041,20 +1211,22 @@ async function cmdPortStatus({ kv, json }) {
1041
1211
  }
1042
1212
 
1043
1213
  function buildPortLlmPromptText({ targetRepoRoot }) {
1214
+ const hs = buildHappyStacksRunnerShellSnippet();
1044
1215
  return [
1045
1216
  'You are an assistant helping the user port split-repo commits into the slopus/happy monorepo.',
1046
1217
  '',
1218
+ hs,
1047
1219
  `Target monorepo root: ${targetRepoRoot}`,
1048
1220
  '',
1049
1221
  'How to run the port:',
1050
- `- guided (recommended): happys monorepo port guide --target=${targetRepoRoot}`,
1051
- `- machine-readable report: happys monorepo port --target=${targetRepoRoot} --json`,
1222
+ `- guided (recommended): hs monorepo port guide --target=${targetRepoRoot}`,
1223
+ `- machine-readable report: hs monorepo port --target=${targetRepoRoot} --json`,
1052
1224
  '',
1053
1225
  'If a conflict happens (git am in progress):',
1054
- `- inspect state (JSON): happys monorepo port status --target=${targetRepoRoot} --json`,
1055
- `- inspect state (text): happys monorepo port status --target=${targetRepoRoot}`,
1226
+ `- inspect state (JSON): hs monorepo port status --target=${targetRepoRoot} --json`,
1227
+ `- inspect state (text): hs monorepo port status --target=${targetRepoRoot}`,
1056
1228
  `- after fixing files: git -C ${targetRepoRoot} am --continue`,
1057
- `- or via wrapper: happys monorepo port continue --target=${targetRepoRoot}`,
1229
+ `- or via wrapper: hs monorepo port continue --target=${targetRepoRoot}`,
1058
1230
  `- to skip current patch: git -C ${targetRepoRoot} am --skip`,
1059
1231
  `- to abort: git -C ${targetRepoRoot} am --abort`,
1060
1232
  '',
@@ -1069,10 +1241,11 @@ function buildPortLlmPromptText({ targetRepoRoot }) {
1069
1241
 
1070
1242
  function buildPortGuideLlmPromptText({ targetRepoRoot, initialCommandArgs }) {
1071
1243
  const parts = Array.isArray(initialCommandArgs) ? initialCommandArgs : [];
1072
- const cmd = ['happys', 'monorepo', ...parts.map((p) => String(p))].join(' ');
1244
+ const cmd = ['hs', 'monorepo', ...parts.map((p) => String(p))].join(' ');
1073
1245
  return [
1074
1246
  'You are an assistant helping the user port split-repo commits into the slopus/happy monorepo.',
1075
1247
  '',
1248
+ buildHappyStacksRunnerShellSnippet(),
1076
1249
  `Target monorepo root: ${targetRepoRoot}`,
1077
1250
  '',
1078
1251
  'Goal:',
@@ -1087,10 +1260,10 @@ function buildPortGuideLlmPromptText({ targetRepoRoot, initialCommandArgs }) {
1087
1260
  cmd,
1088
1261
  '',
1089
1262
  'If it stops with conflicts:',
1090
- `- Inspect status (JSON): happys monorepo port status --target=${targetRepoRoot} --json`,
1263
+ `- Inspect status (JSON): hs monorepo port status --target=${targetRepoRoot} --json`,
1091
1264
  `- Resolve conflicted files`,
1092
1265
  `- Stage: git -C ${targetRepoRoot} add <files>`,
1093
- `- Continue: happys monorepo port continue --target=${targetRepoRoot}`,
1266
+ `- Continue: hs monorepo port continue --target=${targetRepoRoot}`,
1094
1267
  '',
1095
1268
  'Notes:',
1096
1269
  '- Conflicts are resolved one patch at a time (git am stops at the first conflict).',
@@ -1100,11 +1273,78 @@ function buildPortGuideLlmPromptText({ targetRepoRoot, initialCommandArgs }) {
1100
1273
  ].join('\n');
1101
1274
  }
1102
1275
 
1103
- async function cmdPortContinue({ kv, json }) {
1276
+ async function cmdPortContinue({ kv, flags, json }) {
1104
1277
  const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
1105
1278
  const runAmContinue = async () => {
1106
1279
  const inProgressBefore = await isGitAmInProgress(targetRepoRoot);
1107
1280
  if (!inProgressBefore) return { ok: true, didRun: false };
1281
+ const stageWanted = flags?.has?.('--stage') === true || flags?.has?.('--stage-conflicts') === true;
1282
+ const { conflictedFiles, currentPatch } = await readGitAmStatus(targetRepoRoot);
1283
+
1284
+ const stageCandidates = conflictedFiles.length
1285
+ ? conflictedFiles
1286
+ : Array.isArray(currentPatch?.files)
1287
+ ? currentPatch.files.filter(Boolean)
1288
+ : [];
1289
+
1290
+ if (conflictedFiles.length) {
1291
+ if (!stageWanted) {
1292
+ const hint = [
1293
+ `${yellow('[monorepo]')} continue blocked: ${bold('files still need staging')}`,
1294
+ `[monorepo] git reports unmerged files (e.g. ${dim('UU')}). This usually means you resolved them in an editor but forgot ${bold('git add')}.`,
1295
+ `[monorepo] conflicted files: ${conflictedFiles.join(', ')}`,
1296
+ `[monorepo] next: git -C ${targetRepoRoot} add ${conflictedFiles.map((f) => JSON.stringify(f)).join(' ')}`,
1297
+ `[monorepo] then re-run: happys monorepo port continue --target=${targetRepoRoot}`,
1298
+ `[monorepo] tip: you can also run: happys monorepo port continue --target=${targetRepoRoot} --stage`,
1299
+ ].join('\n');
1300
+ printResult({
1301
+ json,
1302
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, needsStage: true, currentPatch },
1303
+ text: json ? '' : hint,
1304
+ });
1305
+ process.exitCode = 1;
1306
+ return { ok: false, didRun: false };
1307
+ }
1308
+
1309
+ const markerHits = await listFilesWithConflictMarkers(targetRepoRoot, stageCandidates);
1310
+ if (markerHits.length) {
1311
+ const hint = [
1312
+ `${yellow('[monorepo]')} refusing to auto-stage: conflict markers still present`,
1313
+ `[monorepo] files: ${markerHits.join(', ')}`,
1314
+ `[monorepo] next: open the file(s), remove ${dim('<<<<<<< / ======= / >>>>>>>')} markers, then run:`,
1315
+ ` git -C ${targetRepoRoot} add ${markerHits.map((f) => JSON.stringify(f)).join(' ')}`,
1316
+ ` happys monorepo port continue --target=${targetRepoRoot}`,
1317
+ ].join('\n');
1318
+ printResult({
1319
+ json,
1320
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, conflictMarkers: markerHits },
1321
+ text: json ? '' : hint,
1322
+ });
1323
+ process.exitCode = 1;
1324
+ return { ok: false, didRun: false };
1325
+ }
1326
+
1327
+ await runCapture('git', ['add', '-A', '--', ...stageCandidates], { cwd: targetRepoRoot });
1328
+ } else if (stageWanted && stageCandidates.length) {
1329
+ const markerHits = await listFilesWithConflictMarkers(targetRepoRoot, stageCandidates);
1330
+ if (markerHits.length) {
1331
+ const hint = [
1332
+ `${yellow('[monorepo]')} refusing to auto-stage: conflict markers still present`,
1333
+ `[monorepo] files: ${markerHits.join(', ')}`,
1334
+ `[monorepo] next: open the file(s), remove ${dim('<<<<<<< / ======= / >>>>>>>')} markers, then run:`,
1335
+ ` git -C ${targetRepoRoot} add ${markerHits.map((f) => JSON.stringify(f)).join(' ')}`,
1336
+ ` happys monorepo port continue --target=${targetRepoRoot}`,
1337
+ ].join('\n');
1338
+ printResult({
1339
+ json,
1340
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, conflictMarkers: markerHits },
1341
+ text: json ? '' : hint,
1342
+ });
1343
+ process.exitCode = 1;
1344
+ return { ok: false, didRun: false };
1345
+ }
1346
+ await runCapture('git', ['add', '-A', '--', ...stageCandidates], { cwd: targetRepoRoot });
1347
+ }
1108
1348
  try {
1109
1349
  await runCapture('git', ['am', '--continue'], { cwd: targetRepoRoot });
1110
1350
  return { ok: true, didRun: true };
@@ -1115,7 +1355,7 @@ async function cmdPortContinue({ kv, json }) {
1115
1355
  `${red('[monorepo]')} continue failed (still conflicted).`,
1116
1356
  conflictedFiles.length ? `[monorepo] conflicted files: ${conflictedFiles.join(', ')}` : '',
1117
1357
  stderr ? `[monorepo] git:\n${stderr}` : '',
1118
- `[monorepo] next: resolve, then re-run: happys monorepo port continue --target=${targetRepoRoot}`,
1358
+ `[monorepo] next: resolve, stage (${bold('git add')}), then re-run: happys monorepo port continue --target=${targetRepoRoot}`,
1119
1359
  ]
1120
1360
  .filter(Boolean)
1121
1361
  .join('\n');
@@ -1301,7 +1541,9 @@ async function cmdPortGuide({ kv, flags, json }) {
1301
1541
  let targetRepoRoot = await resolveGitRoot(targetInput);
1302
1542
  if (!targetRepoRoot) {
1303
1543
  const wantsClone = flags?.has?.('--clone-target') || flags?.has?.('--clone');
1304
- if (!wantsClone) {
1544
+ // If the target doesn't exist yet, default to cloning without asking (best UX for `--target=...`).
1545
+ const targetExists = await pathExists(targetInput);
1546
+ if (targetExists && !wantsClone) {
1305
1547
  const shouldClone =
1306
1548
  (await promptSelect(rl, {
1307
1549
  title:
@@ -1319,12 +1561,148 @@ async function cmdPortGuide({ kv, flags, json }) {
1319
1561
  }
1320
1562
  }
1321
1563
  const repoUrl = (kv.get('--target-repo') ?? '').trim() || 'https://github.com/slopus/happy.git';
1564
+ // eslint-disable-next-line no-console
1565
+ console.log(dim(`[monorepo] cloning target monorepo -> ${targetInput}`));
1322
1566
  await ensureClonedHappyMonorepo({ targetPath: targetInput, repoUrl });
1323
1567
  targetRepoRoot = await resolveGitRoot(targetInput);
1324
1568
  }
1325
1569
  if (!targetRepoRoot || !isHappyMonorepoRoot(targetRepoRoot)) {
1326
1570
  throw new Error(`[monorepo] invalid target (expected slopus/happy monorepo root): ${targetInput}`);
1327
1571
  }
1572
+ const existingPlan = await readPortPlan(targetRepoRoot);
1573
+ if (existingPlan?.plan?.resumeArgv && Array.isArray(existingPlan.plan.resumeArgv)) {
1574
+ // Resume mode:
1575
+ // - do NOT require a clean worktree
1576
+ // - do NOT reject if git am is in progress
1577
+ // - do NOT re-prompt for options; use the stored plan
1578
+ // eslint-disable-next-line no-console
1579
+ console.log('');
1580
+ // eslint-disable-next-line no-console
1581
+ console.log(`${bold('[monorepo]')} guide: resuming existing port plan`);
1582
+
1583
+ const plan = existingPlan.plan;
1584
+ const resumeArgv = [...plan.resumeArgv];
1585
+ const initialArgv = Array.isArray(plan.initialArgv) ? [...plan.initialArgv] : null;
1586
+
1587
+ // Ensure we are on the intended branch if we can.
1588
+ try {
1589
+ const intended = String(plan.branch ?? '').trim();
1590
+ if (intended && intended !== 'HEAD') {
1591
+ const cur = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
1592
+ if (cur !== intended) {
1593
+ const exists = await gitOk(targetRepoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${intended}`]);
1594
+ if (exists) {
1595
+ await git(targetRepoRoot, ['checkout', '--quiet', intended]);
1596
+ }
1597
+ }
1598
+ }
1599
+ } catch {
1600
+ // ignore; we'll still rely on status/continue instructions
1601
+ }
1602
+
1603
+ const attemptArgv = initialArgv && !(await gitOk(targetRepoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${String(plan.branch ?? '').trim()}`]))
1604
+ ? initialArgv
1605
+ : resumeArgv;
1606
+
1607
+ // If git am is already in progress, jump directly to the conflict loop.
1608
+ const inProgress = await isGitAmInProgress(targetRepoRoot);
1609
+ const { flags: attemptFlags, kv: attemptKv } = parseArgs(resumeArgv);
1610
+ const allowAutoLlm = String(process.env.HAPPY_STACKS_DISABLE_LLM_AUTOEXEC ?? '').trim() !== '1';
1611
+ const canAutoLaunchLlm = allowAutoLlm && (await detectInstalledLlmTools({ onlyAutoExec: true })).length > 0;
1612
+ const preferredConflictMode = String(plan.preferredConflictMode ?? '').trim() || 'guided';
1613
+
1614
+ if (inProgress) {
1615
+ // eslint-disable-next-line no-console
1616
+ console.log('');
1617
+ // eslint-disable-next-line no-console
1618
+ console.log(`${yellow('[monorepo]')} guide: conflict detected`);
1619
+ // eslint-disable-next-line no-console
1620
+ console.log(dim('[monorepo] guide: waiting for conflict resolution'));
1621
+ // Reuse the same action loop as the main guide path.
1622
+ // eslint-disable-next-line no-await-in-loop
1623
+ while (await isGitAmInProgress(targetRepoRoot)) {
1624
+ await cmdPortStatus({ kv: attemptKv, json: false });
1625
+ const action = await promptSelect(rl, {
1626
+ title: bold('Resolve conflicts, then choose an action:'),
1627
+ options: [
1628
+ { label: `${green('continue')} (git am --continue)`, value: 'continue' },
1629
+ { label: `${green('stage + continue')} ${dim('(git add conflicted files, then continue)')}`, value: 'stage-continue' },
1630
+ { label: `${cyan('show status again')}`, value: 'status' },
1631
+ ...(canAutoLaunchLlm
1632
+ ? [{ label: `${green('launch LLM now')} ${dim('(recommended)')}`, value: 'llm-launch' }]
1633
+ : []),
1634
+ { label: `${cyan('llm prompt')} ${dim('(copy/paste)')}`, value: 'llm' },
1635
+ { label: `${yellow('skip current patch')} (git am --skip)`, value: 'skip' },
1636
+ { label: `${red('abort')} (git am --abort)`, value: 'abort' },
1637
+ { label: `${dim('quit guide (leave state as-is)')}`, value: 'quit' },
1638
+ ],
1639
+ defaultIndex: 0,
1640
+ });
1641
+ if (action === 'status') continue;
1642
+ if (action === 'llm-launch') {
1643
+ const promptText = buildPortLlmPromptText({ targetRepoRoot });
1644
+ // eslint-disable-next-line no-console
1645
+ console.log('');
1646
+ // eslint-disable-next-line no-console
1647
+ console.log(bold('[monorepo] launching LLM...'));
1648
+ const res = await launchLlmAssistant({
1649
+ rl,
1650
+ title: 'Happy Stacks port conflict',
1651
+ subtitle: 'Resolve current git am conflict',
1652
+ promptText,
1653
+ cwd: targetRepoRoot,
1654
+ });
1655
+ if (!res.ok) {
1656
+ // eslint-disable-next-line no-console
1657
+ console.log(`${yellow('!')} Could not auto-launch an LLM (${res.reason || 'unknown'}).`);
1658
+ }
1659
+ continue;
1660
+ }
1661
+ if (action === 'llm') {
1662
+ const llmFlags = new Set([...(attemptFlags ?? []), '--copy']);
1663
+ await cmdPortLlm({ kv: attemptKv, flags: llmFlags, json: false });
1664
+ continue;
1665
+ }
1666
+ if (action === 'abort') {
1667
+ await runCapture('git', ['am', '--abort'], { cwd: targetRepoRoot });
1668
+ await deletePortPlan(targetRepoRoot);
1669
+ throw new Error('[monorepo] guide aborted (git am --abort)');
1670
+ }
1671
+ if (action === 'skip') {
1672
+ await runCapture('git', ['am', '--skip'], { cwd: targetRepoRoot });
1673
+ continue;
1674
+ }
1675
+ if (action === 'quit') {
1676
+ throw new Error('[monorepo] guide stopped (git am still in progress). Run `happys monorepo port status` / `... continue` to proceed.');
1677
+ }
1678
+ if (action === 'stage-continue') {
1679
+ const stageFlags = new Set([...(attemptFlags ?? []), '--stage']);
1680
+ await cmdPortContinue({ kv: attemptKv, flags: stageFlags, json: false });
1681
+ continue;
1682
+ }
1683
+ await cmdPortContinue({ kv: attemptKv, flags: attemptFlags, json: false });
1684
+ }
1685
+ // If am completed, fall through to resume remaining patches below.
1686
+ }
1687
+
1688
+ // If we're not in a conflict, just resume applying the remaining patches onto the current branch.
1689
+ try {
1690
+ const { flags: rFlags, kv: rKv } = parseArgs(attemptArgv);
1691
+ const jsonWanted = wantsJson(attemptArgv, { flags: rFlags });
1692
+ await cmdPortRun({ argv: attemptArgv, flags: rFlags, kv: rKv, json: jsonWanted });
1693
+ } catch (e) {
1694
+ const inProgressAfter = await isGitAmInProgress(targetRepoRoot);
1695
+ if (!inProgressAfter) throw e;
1696
+ // Once the port is paused on conflicts, the conflict loop above will handle it on the next rerun.
1697
+ throw e;
1698
+ }
1699
+
1700
+ await deletePortPlan(targetRepoRoot);
1701
+ // eslint-disable-next-line no-console
1702
+ console.log(`${green('[monorepo]')} guide complete`);
1703
+ return;
1704
+ }
1705
+
1328
1706
  await ensureCleanGitWorktree(targetRepoRoot);
1329
1707
  await ensureNoGitAmInProgress(targetRepoRoot);
1330
1708
 
@@ -1533,12 +1911,15 @@ async function cmdPortGuide({ kv, flags, json }) {
1533
1911
  ];
1534
1912
 
1535
1913
  await writePortPlan(targetRepoRoot, {
1536
- version: 1,
1914
+ version: 2,
1537
1915
  createdAt: new Date().toISOString(),
1538
1916
  targetRepoRoot,
1539
1917
  base,
1540
1918
  branch,
1541
1919
  use3way,
1920
+ preferredConflictMode,
1921
+ sources,
1922
+ initialArgv,
1542
1923
  resumeArgv,
1543
1924
  });
1544
1925
 
@@ -1586,6 +1967,7 @@ async function cmdPortGuide({ kv, flags, json }) {
1586
1967
  console.log(bold('[monorepo] launching LLM to resolve conflicts...'));
1587
1968
  // eslint-disable-next-line no-await-in-loop
1588
1969
  const res = await launchLlmAssistant({
1970
+ rl,
1589
1971
  title: 'Happy Stacks monorepo port',
1590
1972
  subtitle: 'Resolve conflicts and complete the port',
1591
1973
  promptText,
@@ -1620,6 +2002,7 @@ async function cmdPortGuide({ kv, flags, json }) {
1620
2002
  title: bold('Resolve conflicts, then choose an action:'),
1621
2003
  options: [
1622
2004
  { label: `${green('continue')} (git am --continue)`, value: 'continue' },
2005
+ { label: `${green('stage + continue')} ${dim('(git add conflicted files, then continue)')}`, value: 'stage-continue' },
1623
2006
  { label: `${cyan('show status again')}`, value: 'status' },
1624
2007
  ...(canAutoLaunchLlm ? [{ label: `${green('launch LLM now')} ${dim('(recommended)')}`, value: 'llm-launch' }] : []),
1625
2008
  { label: `${cyan('llm prompt')} ${dim('(copy/paste)')}`, value: 'llm' },
@@ -1641,6 +2024,7 @@ async function cmdPortGuide({ kv, flags, json }) {
1641
2024
  console.log(bold('[monorepo] launching LLM...'));
1642
2025
  // eslint-disable-next-line no-await-in-loop
1643
2026
  const res = await launchLlmAssistant({
2027
+ rl,
1644
2028
  title: 'Happy Stacks port conflict',
1645
2029
  subtitle: 'Resolve current git am conflict',
1646
2030
  promptText,
@@ -1671,9 +2055,16 @@ async function cmdPortGuide({ kv, flags, json }) {
1671
2055
  throw new Error('[monorepo] guide stopped (git am still in progress). Run `happys monorepo port status` / `... continue` to proceed.');
1672
2056
  }
1673
2057
 
2058
+ if (action === 'stage-continue') {
2059
+ const stageFlags = new Set([...(attemptFlags ?? []), '--stage']);
2060
+ // eslint-disable-next-line no-await-in-loop
2061
+ await cmdPortContinue({ kv: attemptKv, flags: stageFlags, json: false });
2062
+ continue;
2063
+ }
2064
+
1674
2065
  // continue
1675
2066
  // eslint-disable-next-line no-await-in-loop
1676
- await cmdPortContinue({ kv: attemptKv, json: false });
2067
+ await cmdPortContinue({ kv: attemptKv, flags: attemptFlags, json: false });
1677
2068
  }
1678
2069
  }
1679
2070
  }
@@ -1762,7 +2153,7 @@ async function main() {
1762
2153
  return;
1763
2154
  }
1764
2155
  if (sub === 'continue') {
1765
- await cmdPortContinue({ kv, json });
2156
+ await cmdPortContinue({ kv, flags, json });
1766
2157
  return;
1767
2158
  }
1768
2159
  if (sub === 'preflight') {