@dunnewold-labs/mr-manager 0.4.50 → 0.4.52

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 (2) hide show
  1. package/dist/index.mjs +899 -412
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -185,7 +185,7 @@ import { fileURLToPath } from "url";
185
185
  // cli/package.json
186
186
  var package_default = {
187
187
  name: "@dunnewold-labs/mr-manager",
188
- version: "0.4.50",
188
+ version: "0.4.52",
189
189
  description: "Mr. Manager - Task and project management CLI",
190
190
  bin: {
191
191
  mr: "./dist/index.mjs"
@@ -561,7 +561,7 @@ import { join as join5 } from "path";
561
561
 
562
562
  // lib/git-worktree.ts
563
563
  import { execSync as execSync2 } from "child_process";
564
- import { copyFileSync, existsSync as existsSync4 } from "fs";
564
+ import { copyFileSync, existsSync as existsSync4, rmSync } from "fs";
565
565
  import { join as join4 } from "path";
566
566
  function tryExec(command, cwd) {
567
567
  try {
@@ -619,15 +619,21 @@ function findExistingWorktreeForBranch(repoDir, branch) {
619
619
  function createWorktree(repoDir, branch, worktreeName, options = {}) {
620
620
  const wtPath = join4(repoDir, ".mr-worktrees", worktreeName);
621
621
  if (existsSync4(wtPath)) {
622
- execSync2(`git checkout "${branch}"`, { cwd: wtPath, stdio: "pipe" });
623
- if (options.syncRemoteBranch) {
624
- syncBranchWithRemote(repoDir, wtPath, branch);
622
+ const branchResolves = tryExec(`git rev-parse --verify "refs/heads/${branch}"`, repoDir);
623
+ if (branchResolves) {
624
+ execSync2(`git checkout "${branch}"`, { cwd: wtPath, stdio: "pipe" });
625
+ if (options.syncRemoteBranch) {
626
+ syncBranchWithRemote(repoDir, wtPath, branch);
627
+ }
628
+ return {
629
+ path: wtPath,
630
+ created: false,
631
+ reusedBranchWorktree: false
632
+ };
625
633
  }
626
- return {
627
- path: wtPath,
628
- created: false,
629
- reusedBranchWorktree: false
630
- };
634
+ removeWorktree(repoDir, wtPath);
635
+ rmSync(wtPath, { recursive: true, force: true });
636
+ tryExec(`git worktree prune`, repoDir);
631
637
  }
632
638
  const existingBranchWorktree = findExistingWorktreeForBranch(repoDir, branch);
633
639
  if (existingBranchWorktree) {
@@ -2457,6 +2463,7 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2457
2463
  const referenceImagePaths = options.referenceImagePaths ?? [];
2458
2464
  const hasReferences = referenceImagePaths.length > 0;
2459
2465
  const exemplars = pickExemplars(proto.id);
2466
+ const isLowFidelity = proto.fidelity === "low";
2460
2467
  const variantSteps = [];
2461
2468
  for (let i = 0; i < variantsToProduce; i++) {
2462
2469
  const idx = startIndex + i;
@@ -2464,7 +2471,7 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2464
2471
  variantSteps.push(
2465
2472
  `### Variant ${idx}: ${filename}`,
2466
2473
  `1. ${varianceInfo.perVariantDirective}`,
2467
- `2. Spend a moment composing a real, opinionated design before you start typing HTML. ${hasReferences ? "Imagine the screen sitting alongside the reference designs the user attached \u2014 it should look like it belongs in the same set." : `Imagine the screen on Dribbble, holding its own next to ${exemplars}.`}`,
2474
+ isLowFidelity ? `2. Compose a layout focusing on structure and information flow. Keep it strictly grayscale, sketchy, and low-fidelity.` : `2. Spend a moment composing a real, opinionated design before you start typing HTML. ${hasReferences ? "Imagine the screen sitting alongside the reference designs the user attached \u2014 it should look like it belongs in the same set." : `Imagine the screen on Dribbble, holding its own next to ${exemplars}.`}`,
2468
2475
  `3. Write the complete self-contained HTML to \`${repoDir}/${filename}\` using the Write tool.`,
2469
2476
  `4. Verify the file was created by reading the first few lines of \`${repoDir}/${filename}\`.`,
2470
2477
  ``
@@ -2474,6 +2481,38 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2474
2481
  { length: variantsToProduce },
2475
2482
  (_, i) => `prototype-${startIndex + i}.html`
2476
2483
  );
2484
+ const wireframeQualityBar = [
2485
+ `## Wireframe & Sketch Guidelines (Balsamiq-Style)`,
2486
+ ``,
2487
+ `This is an intentionally LOW-FIDELITY wireframe. The goal is to focus on structure, layout, and user flow, NOT visual polish. Follow these strict styling rules:`,
2488
+ ``,
2489
+ `**Grayscale Aesthetic Only**`,
2490
+ `- Use ONLY black, white, and shades of gray.`,
2491
+ `- Absolutely NO color palette exploration, no accent colors, no gradients, and no color highlights.`,
2492
+ ``,
2493
+ `**Linework & Borders**`,
2494
+ `- Borders must feel sketchy or hand-drawn. Use thick, solid borders (e.g., 2px solid black or dark gray) for elements.`,
2495
+ `- Keep shapes simple and flat. Avoid rounded corners unless they represent a device frame or button.`,
2496
+ `- Use flat gray boxes with diagonal lines or an "X" representing media/image placeholders.`,
2497
+ ``,
2498
+ `**Typography (Default Font)**`,
2499
+ `- You MUST load the Shantell Sans font from Google Fonts via:`,
2500
+ ` <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Shantell+Sans:wght@300..800&display=swap">`,
2501
+ `- Set the default typeface of the entire body to: 'Shantell Sans', 'Comic Sans MS', 'Comic Neue', cursive, sans-serif.`,
2502
+ `- Text should feel hand-drawn but readable, giving the app a sketch feeling.`,
2503
+ ``,
2504
+ `**Placeholders & Annotations**`,
2505
+ `- Media (images, videos, illustrations) must render as obvious sketchy placeholders \u2014 never use real photos, Unsplash images, or polished vector illustrations. Use a rectangle with "X" drawn from corner to corner.`,
2506
+ `- Icons should be extremely simple/sketchy or represented as simple text placeholders (e.g. "[x]", "[+]", "[menu]"). Do NOT use polished icon packs if they look too clean. Standard text brackets or extremely basic SVG paths are preferred.`,
2507
+ `- Copy should be terse, greeked, or simple descriptive label annotations (e.g. "[Brand Logo]", "[Search Results: 10 items]", "[User Name]"). Avoid polished marketing copy or detailed UI prose.`,
2508
+ ``,
2509
+ `**No Shadows, Gradients, or Motion**`,
2510
+ `- Absolutely NO box-shadows, text-shadows, gradients, backdrop-blur, or glassmorphism.`,
2511
+ `- Absolutely NO delight-oriented motion, fade-ins, slide-ups, marquee effects, or animated gradients. Everything must be static and flat.`,
2512
+ ``,
2513
+ `**Device Framing (Sketch Form)**`,
2514
+ `${prototypeType === "mobile_app" ? `- Draw a sketchy mobile frame container (centered, e.g. 375x812px) using a 2px solid black border. Render a sketchy top status bar (e.g., "[9:41 AM Battery 100%]") and sketchy dynamic island/notch. Center the frame on a flat, light-gray canvas.` : prototypeType === "desktop_app" ? `- Draw a sketchy desktop window frame (e.g. 1024x768px or scaled container) with a 2px solid black border. Show a simple top bar with sketchy window controls (three tiny circles or text like "[o][o][o]"). Center the frame on a flat, light-gray canvas.` : `- For a web app, present the layout as a browser screen (with a sketchy address bar like "[https://app.com/]") styled with a 2px solid black border.`}`
2515
+ ];
2477
2516
  const sharedQualityBar = [
2478
2517
  `## Craft & Polish Bar (this is the most important section \u2014 read carefully)`,
2479
2518
  ``,
@@ -2583,8 +2622,14 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2583
2622
  logo: "Logo"
2584
2623
  };
2585
2624
  const agentNote = options.agentLabel ? `You are one of several models collaborating on this prototype set. You are the "${options.agentLabel}" track, responsible for variants ${startIndex} through ${startIndex + variantsToProduce - 1}. Bring YOUR creative perspective \u2014 don't try to be a generic averaged designer.` : `You are the sole designer on this prototype set \u2014 bring a strong, opinionated point of view.`;
2586
- const creativeAnchor = hasReferences ? `The user attached reference designs for this prototype \u2014 they are the single most important input. Treat them as the definitive brief for look and feel (details in the Reference Designs section below).` : `Aim for the craft level of ${exemplars} \u2014 but interpret this brief on its own terms, don't imitate any one product.`;
2587
- const creativeDirection = [
2625
+ const creativeAnchor = isLowFidelity ? hasReferences ? `The user attached reference designs for this prototype. Use them ONLY to guide structure, layout, and content hierarchy. Do NOT copy their visual style, colors, shadows, fonts, or polish. Keep the output strictly as a low-fidelity wireframe.` : `Generate a low-fidelity wireframe representing the layout, hierarchy, and flow described in the prompt.` : hasReferences ? `The user attached reference designs for this prototype \u2014 they are the single most important input. Treat them as the definitive brief for look and feel (details in the Reference Designs section below).` : `Aim for the craft level of ${exemplars} \u2014 but interpret this brief on its own terms, don't imitate any one product.`;
2626
+ const creativeDirection = isLowFidelity ? [
2627
+ `## Wireframe Direction`,
2628
+ `- **Focus on Hierarchy**: Make sure the most important elements have the largest text or thickest boundaries.`,
2629
+ `- **Vary the Layout Structure**: Across variants, use different ways of organizing information (e.g. one variant could be a sidebar layout, another a grid layout, another a single-column layout).`,
2630
+ `- **Use Shantell Sans**: Ensure the font successfully loads and applies globally to keep the Balsamiq sketch aesthetic consistent.`,
2631
+ `- **Terse Annotations**: Ensure copy is terse, annotated, or greeked.`
2632
+ ] : [
2588
2633
  `## Creative Direction & Polish`,
2589
2634
  `- **Be Opinionated**: Do not design generic "safe" UIs. Imagine you are designing for a product that wants to win "Site of the Day" on Awwwards or be featured in a "Best of Linear-style design" gallery.`,
2590
2635
  `- **Micro-interactions**: Use CSS keyframes for delightful details. A progress bar that has a shimmering gradient, a button that has a subtle "glint" effect, or a card that has a "magnetic" hover feel.`,
@@ -2592,6 +2637,15 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2592
2637
  `- **Typography as Hero**: In at least one variant, let typography drive the entire layout. Use large, bold display faces with tight tracking and intentional overlapping.`,
2593
2638
  `- **Negative Space**: Do not be afraid of "wasting" space. Generous margins and padding often signal luxury and quality.`
2594
2639
  ];
2640
+ const aestheticGuardrails = isLowFidelity ? [
2641
+ `- Absolutely NO colors besides black, white, and grays. No gradients, no shadows, no smooth animations.`,
2642
+ `- Do NOT use polished icon libraries (like Lucide CDN/images) if they look too crisp. Use simple text markers like "[+]" or standard hand-drawn style SVGs.`,
2643
+ `- Ensure the body uses the 'Shantell Sans' font stack for a sketchy comic feel.`
2644
+ ] : [
2645
+ `- Do NOT default to a terminal / hacker / CRT / monospace-green-on-black aesthetic unless the user's prompt explicitly asks for it. This is a common failure mode \u2014 avoid it.`,
2646
+ `- Do NOT force any single "style label" (retro, futuristic, brutalist, etc.) onto the variants unless the user asked for it. Let the prompt drive the aesthetic.`,
2647
+ `- Choose aesthetics that fit the product and audience described in the user's prompt, not a generic developer-tool look.`
2648
+ ];
2595
2649
  return [
2596
2650
  `${config.role}`,
2597
2651
  ``,
@@ -2614,7 +2668,7 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2614
2668
  ``,
2615
2669
  `Generate exactly ${variantsToProduce} HTML file(s) covering variants ${startIndex} through ${startIndex + variantsToProduce - 1}: ${variantList.join(", ")}. Follow the steps below IN ORDER. Do NOT collapse or skip variants. Each file must be completely self-contained (inline all CSS and JS, plus any Google Fonts \`<link>\` you reference). Tailwind CDN is acceptable.`,
2616
2670
  ``,
2617
- ...sharedQualityBar,
2671
+ ...isLowFidelity ? wireframeQualityBar : sharedQualityBar,
2618
2672
  ``,
2619
2673
  ...creativeDirection,
2620
2674
  ``,
@@ -2631,9 +2685,7 @@ function buildPrototypePrompt(proto, repoDir, options = {}) {
2631
2685
  ``,
2632
2686
  `## Aesthetic Guardrails`,
2633
2687
  ``,
2634
- `- Do NOT default to a terminal / hacker / CRT / monospace-green-on-black aesthetic unless the user's prompt explicitly asks for it. This is a common failure mode \u2014 avoid it.`,
2635
- `- Do NOT force any single "style label" (retro, futuristic, brutalist, etc.) onto the variants unless the user asked for it. Let the prompt drive the aesthetic.`,
2636
- `- Choose aesthetics that fit the product and audience described in the user's prompt, not a generic developer-tool look.`,
2688
+ ...aestheticGuardrails,
2637
2689
  ``,
2638
2690
  ...variantSteps,
2639
2691
  `### Final verification`,
@@ -2655,6 +2707,8 @@ function buildRefinementPrompt(proto, parentFiles, repoDir, options = {}) {
2655
2707
  const startIndex = options.variantStartIndex ?? 1;
2656
2708
  const variance = typeof proto.designVariance === "number" ? proto.designVariance : 70;
2657
2709
  const varianceInfo = describeVariance(variance);
2710
+ const isLowFidelity = proto.fidelity === "low";
2711
+ const isPromotion = proto.fidelity === "high" && options.parentFidelity === "low";
2658
2712
  const variantSteps = [];
2659
2713
  for (let i = 0; i < variantsToProduce; i++) {
2660
2714
  const idx = startIndex + i;
@@ -2662,7 +2716,7 @@ function buildRefinementPrompt(proto, parentFiles, repoDir, options = {}) {
2662
2716
  variantSteps.push(
2663
2717
  `### Variant ${idx}: ${filename}`,
2664
2718
  `1. Redesign variant ${idx} based on the feedback below, while keeping the core concept that worked.`,
2665
- `2. Apply the same craft & polish bar as the original generation \u2014 portfolio quality, real microcopy, intentional typography, no Lorem ipsum, no broken images.`,
2719
+ isLowFidelity ? `2. Keep the low-fidelity Balsamiq-style sketch aesthetic (grayscale, Shantell Sans font, sketchy borders, flat placeholders, no shadows/motion).` : isPromotion ? `2. Keep the structure and elements from the parent wireframe, but completely upgrade it with professional colors, typography, gradients, shadows, polished images, and real copy according to the high-fidelity craft guidelines.` : `2. Apply the same craft & polish bar as the original generation \u2014 portfolio quality, real microcopy, intentional typography, no Lorem ipsum, no broken images.`,
2666
2720
  `3. Write the complete self-contained HTML to \`${repoDir}/${filename}\` using the Write tool.`,
2667
2721
  `4. Verify the file was created by reading the first few lines of \`${repoDir}/${filename}\`.`,
2668
2722
  ``
@@ -2702,6 +2756,34 @@ function buildRefinementPrompt(proto, parentFiles, repoDir, options = {}) {
2702
2756
  desktop_app: "Desktop App",
2703
2757
  logo: "Logo"
2704
2758
  };
2759
+ const wireframeGuidelines = isLowFidelity ? [
2760
+ `## Wireframe & Sketch Guidelines (Balsamiq-Style)`,
2761
+ `This is a low-fidelity wireframe refinement. Maintain these constraints:`,
2762
+ `- Grayscale only: Use ONLY black, white, and gray colors. No accents, gradients, or color fills.`,
2763
+ `- Shantell Sans font: Load 'Shantell Sans' from Google Fonts and set it globally as the default typeface.`,
2764
+ `- Borders/shapes: Use sketchy/thick solid black borders (e.g. 2px). Flat rectangles with an "X" for image placeholders.`,
2765
+ `- Annotations/terse copy: Avoid polished marketing copy; use brief labels, greeked text, or annotations.`,
2766
+ `- No polish: No shadows, no gradients, no glassmorphism, no transitions or motion.`
2767
+ ] : isPromotion ? [
2768
+ `## High-Fidelity Promotion Guidelines`,
2769
+ `You are promoting this prototype from a low-fidelity wireframe to a high-fidelity design:`,
2770
+ `- Elevate the visual design using professional color palettes, modern typography (Google Fonts display/text pairings), layout depth (subtle shadows, borders), and realistic mock content/media.`,
2771
+ `- Do NOT copy the grayscale wireframe styling, comic-style font, or diagonal-line placeholders.`
2772
+ ] : [
2773
+ `## High-Fidelity Craft & Polish Guidelines`,
2774
+ `- Composition: real grid, intentional spacing, distinct visual hierarchy.`,
2775
+ `- Typography: Google Fonts display + text face pairing (e.g. Inter, Geist, etc.).`,
2776
+ `- Colors & depth: deliberate color palette, soft shadows, inner glows, subtle motion/transitions.`,
2777
+ `- Real microcopy: no Lorem Ipsum, realistic data, realistic dates and names.`
2778
+ ];
2779
+ const aestheticGuardrails = isLowFidelity ? [
2780
+ `- Absolutely NO colors besides black, white, and grays. No gradients, no shadows, no smooth animations.`,
2781
+ `- Do NOT use polished icon libraries (like Lucide CDN/images) if they look too crisp. Use simple text markers like "[+]" or standard hand-drawn style SVGs.`,
2782
+ `- Ensure the body uses the 'Shantell Sans' font stack for a sketchy comic feel.`
2783
+ ] : [
2784
+ `- Do NOT default to a terminal / hacker / CRT / monospace-green-on-black aesthetic unless the user's prompt explicitly asks for it.`,
2785
+ `- Do NOT force any single "style label" onto the variants unless the user asked for it.`
2786
+ ];
2705
2787
  return [
2706
2788
  `${typeRoleMap[prototypeType] ?? typeRoleMap.web_app}`,
2707
2789
  ``,
@@ -2724,32 +2806,33 @@ function buildRefinementPrompt(proto, parentFiles, repoDir, options = {}) {
2724
2806
  existingVariants,
2725
2807
  `## Instructions`,
2726
2808
  ``,
2727
- `You MUST generate exactly ${proto.variantCount} REFINED HTML files that incorporate the user's feedback above. Follow the steps below IN ORDER.`,
2809
+ `You MUST generate exactly ${variantsToProduce} REFINED HTML file(s) that incorporate the user's feedback above. Follow the steps below IN ORDER.`,
2728
2810
  ``,
2729
2811
  `Study the previous variants carefully, then apply the user's feedback to improve them. Keep what works, change what the user asked to change.`,
2730
2812
  ``,
2813
+ ...wireframeGuidelines,
2814
+ ``,
2731
2815
  `## Design Variance: ${varianceInfo.label} (${variance}/100)`,
2732
2816
  ``,
2733
2817
  varianceInfo.summary,
2734
2818
  ``,
2735
2819
  `## Aesthetic Guardrails`,
2736
2820
  ``,
2737
- `- Do NOT default to a terminal / hacker / CRT / monospace-green-on-black aesthetic unless the user's prompt explicitly asks for it.`,
2738
- `- Do NOT force any single "style label" onto the variants unless the user asked for it.`,
2821
+ ...aestheticGuardrails,
2739
2822
  ``,
2740
2823
  `Each file must be completely self-contained (inline all CSS and JS \u2014 no external dependencies). Tailwind CDN is acceptable.`,
2741
2824
  ``,
2742
2825
  ...variantSteps,
2743
2826
  `### Final verification`,
2744
- `After generating ALL ${proto.variantCount} variants, list the files in ${repoDir} and confirm that all ${proto.variantCount} files exist: ${variantList.join(", ")}. If any are missing, go back and generate the missing ones.`,
2827
+ `After generating ALL ${variantsToProduce} variant(s), list the files in ${repoDir} and confirm that all ${variantsToProduce} file(s) exist: ${variantList.join(", ")}. If any are missing, go back and generate the missing ones.`,
2745
2828
  ``,
2746
2829
  `IMPORTANT RULES:`,
2747
- `- You MUST produce exactly ${proto.variantCount} files: ${variantList.join(", ")}`,
2830
+ `- You MUST produce exactly these file(s): ${variantList.join(", ")}`,
2748
2831
  `- Generate them ONE AT A TIME \u2014 design each variant, write the file, then move to the next.`,
2749
2832
  `- Respect the Design Variance level above.`,
2750
2833
  `- Each file must be a complete, functional page \u2014 pure HTML/CSS/JS, no external libraries (Tailwind CDN is acceptable).`,
2751
2834
  `- Do NOT upload or POST the files anywhere. The watch handler will upload them automatically after you exit.`,
2752
- `- Do NOT exit until ALL ${proto.variantCount} files have been written and verified.`
2835
+ `- Do NOT exit until ALL ${variantsToProduce} file(s) have been written and verified.`
2753
2836
  ].join("\n");
2754
2837
  }
2755
2838
  function buildAgentArgs(agent, prompt2, mode, sessionId, name, resumeSession = false, systemPrompt, maxTurns, claudeModel) {
@@ -2953,12 +3036,12 @@ var watchCommand = new Command9("watch").description(
2953
3036
  agentAvailability.set(candidate, available);
2954
3037
  return available;
2955
3038
  }
2956
- async function resolveAgentChain(preferred) {
3039
+ async function resolveAgentChain(preferred2) {
2957
3040
  const availability = {};
2958
3041
  for (const candidate of ["claude", "codex", "antigravity"]) {
2959
3042
  availability[candidate] = await isAgentAvailable(candidate);
2960
3043
  }
2961
- return getAvailableAgentFallbackChain(preferred, availability);
3044
+ return getAvailableAgentFallbackChain(preferred2, availability);
2962
3045
  }
2963
3046
  async function moveTaskToError(task, prefix, reason) {
2964
3047
  try {
@@ -3375,6 +3458,15 @@ var watchCommand = new Command9("watch").description(
3375
3458
  };
3376
3459
  await launchAttempt(attemptOrder[attemptIndex]);
3377
3460
  }
3461
+ function dispatchTaskSafely(task, repoDir) {
3462
+ void dispatchTask(task, repoDir).catch(async (err) => {
3463
+ const prefix = taskTag(shortId(task.id));
3464
+ const reason = `Task setup failed: ${err.message}`;
3465
+ logError(prefix, reason);
3466
+ queued.delete(task.id);
3467
+ await moveTaskToError(task, prefix, reason);
3468
+ });
3469
+ }
3378
3470
  async function dispatchPlanModeTask(task, repoDir) {
3379
3471
  const sid = shortId(task.id);
3380
3472
  const prefix = taskTag(sid);
@@ -3547,10 +3639,12 @@ var watchCommand = new Command9("watch").description(
3547
3639
  const dedupedRequested = Array.from(new Set(requested));
3548
3640
  const key = `proto-${proto.id}`;
3549
3641
  let parentFiles = [];
3642
+ let parentFidelity;
3550
3643
  if (proto.parentId && proto.refinementFeedback) {
3551
3644
  try {
3552
3645
  const parent = await api.get(`/api/prototypes/${proto.parentId}`);
3553
3646
  parentFiles = parent.files ?? [];
3647
+ parentFidelity = parent.fidelity;
3554
3648
  } catch (err) {
3555
3649
  logError(prefix, `Failed to fetch parent prototype: ${err.message}`);
3556
3650
  }
@@ -3561,7 +3655,8 @@ var watchCommand = new Command9("watch").description(
3561
3655
  variantStartIndex: startIndex,
3562
3656
  variantsToProduce,
3563
3657
  agentLabel,
3564
- referenceImagePaths: refImagePaths
3658
+ referenceImagePaths: refImagePaths,
3659
+ parentFidelity
3565
3660
  });
3566
3661
  }
3567
3662
  return buildPrototypePrompt(proto, sliceDir, {
@@ -3571,254 +3666,191 @@ var watchCommand = new Command9("watch").description(
3571
3666
  referenceImagePaths: refImagePaths
3572
3667
  });
3573
3668
  };
3574
- if (dedupedRequested.length <= 1) {
3575
- const preferred = dedupedRequested[0] ?? agent;
3576
- const attemptOrder = dedupedRequested.length === 1 ? [preferred] : await resolveAgentChain(preferred);
3577
- if (attemptOrder.length === 0) {
3578
- logError(prefix, `No available agents found for ${preferred}`);
3669
+ const totalVariants = Math.max(1, proto.variantCount);
3670
+ let agentChainForVariant;
3671
+ if (dedupedRequested.length >= 1) {
3672
+ agentChainForVariant = (variantIndex) => [
3673
+ dedupedRequested[(variantIndex - 1) % dedupedRequested.length]
3674
+ ];
3675
+ logDispatch(
3676
+ prefix,
3677
+ `streaming ${totalVariants} variant(s) across ${dedupedRequested.join(", ")}`
3678
+ );
3679
+ } else {
3680
+ const chain = await resolveAgentChain(agent);
3681
+ if (chain.length === 0) {
3682
+ logError(prefix, `No available agents found for ${agent}`);
3579
3683
  await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" }).catch(() => {
3580
3684
  });
3581
3685
  cleanupRefDir(refImageDir);
3582
3686
  queued.delete(key);
3583
3687
  return;
3584
3688
  }
3585
- const prompt2 = buildPromptForSlice(1, proto.variantCount, void 0, repoDir);
3586
- const activeEntry2 = {
3587
- process: void 0,
3588
- title: proto.title,
3589
- repoDir,
3590
- startedAt: Date.now(),
3591
- lastActivityAt: Date.now()
3592
- };
3593
- let attemptIndex = 0;
3594
- const launchAttempt = async (attemptAgent) => {
3595
- let spawnFailureReason = null;
3596
- const child = spawnAgent(
3597
- attemptAgent,
3598
- repoDir,
3599
- prompt2,
3600
- prefix,
3601
- void 0,
3602
- void 0,
3603
- proto.title,
3604
- false,
3605
- (err) => {
3606
- spawnFailureReason = err.message;
3607
- }
3608
- );
3609
- activeEntry2.process = child;
3610
- activeEntry2.currentAgent = attemptAgent;
3611
- active.set(key, activeEntry2);
3612
- child.on("exit", async (code) => {
3613
- if (active.get(key)?.process === child) {
3614
- active.delete(key);
3615
- }
3616
- const failedAttempt = code !== 0 || spawnFailureReason !== null;
3617
- if (failedAttempt && !activeEntry2.terminatedForError) {
3618
- const nextAgent = attemptOrder[attemptIndex + 1];
3619
- if (nextAgent) {
3620
- const failureDetail = spawnFailureReason ?? `exit code ${code}`;
3621
- logWarn(prefix, `${attemptAgent} failed (${failureDetail}) \u2014 retrying prototype generation with ${nextAgent}`);
3622
- attemptIndex += 1;
3623
- await launchAttempt(nextAgent);
3624
- return;
3625
- }
3626
- }
3627
- finishing.add(key);
3628
- try {
3629
- if (code === 0) {
3630
- try {
3631
- const protoPattern = /^prototype-\d+\.html$/;
3632
- const found = readdirSync(repoDir).filter((f) => protoPattern.test(f)).sort();
3633
- const files = found.map((f) => ({
3634
- name: f,
3635
- content: readFileSync5(resolve2(repoDir, f), "utf-8"),
3636
- agent: attemptAgent
3637
- }));
3638
- if (files.length === 0) {
3639
- logError(prefix, `No prototype HTML files found in ${repoDir}`);
3640
- await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" });
3641
- return;
3642
- }
3643
- const variantModels = files.map((f) => f.agent ?? null);
3644
- await api.patch(`/api/prototypes/${proto.id}`, { status: "completed", files, variantModels });
3645
- logSuccess(prefix, `"${paint("bold", proto.title)}" uploaded ${files.length} file(s)`);
3646
- for (const file of files) {
3647
- try {
3648
- unlinkSync(resolve2(repoDir, file.name));
3649
- } catch {
3650
- }
3651
- }
3652
- } catch (err) {
3653
- logError(prefix, `Failed to upload prototype: ${err.message}`);
3654
- try {
3655
- await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" });
3656
- } catch {
3657
- }
3658
- }
3659
- } else {
3660
- const failureDetail = spawnFailureReason ?? `exit code ${code}`;
3661
- logError(prefix, `"${paint("bold", proto.title)}" prototype failed via ${attemptAgent} (${failureDetail})`);
3662
- try {
3663
- await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" });
3664
- } catch {
3665
- }
3666
- }
3667
- } finally {
3668
- cleanupRefDir(refImageDir);
3669
- queued.delete(key);
3670
- finishing.delete(key);
3671
- }
3672
- });
3673
- };
3674
- await launchAttempt(attemptOrder[attemptIndex]);
3675
- return;
3676
- }
3677
- const totalVariants = proto.variantCount;
3678
- const slices = [];
3679
- const baseShare = Math.floor(totalVariants / dedupedRequested.length);
3680
- const remainder = totalVariants % dedupedRequested.length;
3681
- let cursor = 1;
3682
- for (let i = 0; i < dedupedRequested.length; i++) {
3683
- const a = dedupedRequested[i];
3684
- const count = baseShare + (i < remainder ? 1 : 0);
3685
- if (count <= 0) continue;
3686
- const dir = resolve2(repoDir, `.mr-proto-${a}`);
3687
- try {
3688
- mkdirSync3(dir, { recursive: true });
3689
- } catch {
3690
- }
3691
- for (const f of readdirSync(dir).filter((f2) => stalePattern.test(f2))) {
3692
- try {
3693
- unlinkSync(resolve2(dir, f));
3694
- } catch {
3695
- }
3696
- }
3697
- slices.push({ agentLabel: a, startIndex: cursor, count, dir });
3698
- cursor += count;
3689
+ agentChainForVariant = () => chain;
3690
+ logDispatch(prefix, `streaming ${totalVariants} variant(s) via ${chain[0]}`);
3699
3691
  }
3700
- if (slices.length === 0) {
3701
- logError(prefix, `Could not assign any variants to selected agents`);
3702
- await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" }).catch(() => {
3703
- });
3704
- cleanupRefDir(refImageDir);
3705
- queued.delete(key);
3706
- return;
3707
- }
3708
- logDispatch(
3709
- prefix,
3710
- `multi-agent dispatch: ${slices.map((s) => `${s.agentLabel}\xD7${s.count}`).join(", ")}`
3711
- );
3712
3692
  const activeEntry = {
3713
3693
  process: void 0,
3714
3694
  title: proto.title,
3715
3695
  repoDir,
3716
3696
  startedAt: Date.now(),
3717
3697
  lastActivityAt: Date.now(),
3718
- outputBytes: 0
3698
+ outputBytes: 0,
3699
+ children: /* @__PURE__ */ new Set()
3719
3700
  };
3720
3701
  active.set(key, activeEntry);
3721
- const sliceResults = await Promise.all(
3722
- slices.map((slice) => {
3723
- return new Promise((res) => {
3724
- const slicePrompt = buildPromptForSlice(slice.startIndex, slice.count, slice.agentLabel, slice.dir);
3702
+ const protoPattern = /^prototype-\d+\.html$/;
3703
+ const runVariant = (variantIndex) => {
3704
+ const chain = agentChainForVariant(variantIndex);
3705
+ const sliceDir = resolve2(repoDir, `.mr-proto-v${variantIndex}`);
3706
+ try {
3707
+ mkdirSync3(sliceDir, { recursive: true });
3708
+ } catch {
3709
+ }
3710
+ for (const f of readdirSync(sliceDir).filter((f2) => stalePattern.test(f2))) {
3711
+ try {
3712
+ unlinkSync(resolve2(sliceDir, f));
3713
+ } catch {
3714
+ }
3715
+ }
3716
+ const prompt2 = buildPromptForSlice(variantIndex, 1, chain[0], sliceDir);
3717
+ return new Promise((res) => {
3718
+ let attemptIndex = 0;
3719
+ const finishSlice = (ok) => {
3720
+ try {
3721
+ for (const f of readdirSync(sliceDir)) {
3722
+ try {
3723
+ unlinkSync(resolve2(sliceDir, f));
3724
+ } catch {
3725
+ }
3726
+ }
3727
+ rmdirSync(sliceDir);
3728
+ } catch {
3729
+ }
3730
+ res(ok);
3731
+ };
3732
+ const launch = (attemptAgent) => {
3725
3733
  let spawnFailureReason = null;
3726
3734
  const child = spawnAgent(
3727
- slice.agentLabel,
3728
- slice.dir,
3729
- slicePrompt,
3735
+ attemptAgent,
3736
+ sliceDir,
3737
+ prompt2,
3730
3738
  prefix,
3739
+ () => {
3740
+ activeEntry.lastActivityAt = Date.now();
3741
+ },
3731
3742
  void 0,
3732
- void 0,
3733
- `${proto.title} [${slice.agentLabel}]`,
3743
+ `${proto.title} [variant ${variantIndex}/${totalVariants}]`,
3734
3744
  false,
3735
3745
  (err) => {
3736
3746
  spawnFailureReason = err.message;
3737
3747
  }
3738
3748
  );
3739
3749
  activeEntry.process = child;
3740
- activeEntry.currentAgent = slice.agentLabel;
3741
- child.on("exit", (code) => {
3742
- const ok = code === 0 && spawnFailureReason === null;
3743
- if (!ok) {
3744
- const failureDetail = spawnFailureReason ?? `exit code ${code}`;
3745
- logWarn(prefix, `${slice.agentLabel} slice failed (${failureDetail})`);
3750
+ activeEntry.currentAgent = attemptAgent;
3751
+ activeEntry.children?.add(child);
3752
+ child.on("exit", async (code) => {
3753
+ activeEntry.children?.delete(child);
3754
+ const failedAttempt = code !== 0 || spawnFailureReason !== null;
3755
+ if (failedAttempt && !activeEntry.terminatedForError) {
3756
+ const nextAgent = chain[attemptIndex + 1];
3757
+ if (nextAgent) {
3758
+ const detail = spawnFailureReason ?? `exit code ${code}`;
3759
+ logWarn(prefix, `variant ${variantIndex}: ${attemptAgent} failed (${detail}) \u2014 retrying with ${nextAgent}`);
3760
+ attemptIndex += 1;
3761
+ launch(nextAgent);
3762
+ return;
3763
+ }
3764
+ }
3765
+ if (activeEntry.terminatedForError) {
3766
+ finishSlice(false);
3767
+ return;
3768
+ }
3769
+ if (code !== 0) {
3770
+ const detail = spawnFailureReason ?? `exit code ${code}`;
3771
+ logError(prefix, `variant ${variantIndex} failed via ${attemptAgent} (${detail})`);
3772
+ finishSlice(false);
3773
+ return;
3774
+ }
3775
+ try {
3776
+ const found = readdirSync(sliceDir).filter((f) => protoPattern.test(f)).sort();
3777
+ if (found.length === 0) {
3778
+ logError(prefix, `variant ${variantIndex}: no HTML file produced`);
3779
+ finishSlice(false);
3780
+ return;
3781
+ }
3782
+ const content = readFileSync5(resolve2(sliceDir, found[0]), "utf-8");
3783
+ const file = {
3784
+ name: `prototype-${variantIndex}.html`,
3785
+ content,
3786
+ agent: attemptAgent
3787
+ };
3788
+ await api.post(`/api/prototypes/${proto.id}/variants`, { file });
3789
+ logSuccess(prefix, `variant ${variantIndex}/${totalVariants} ready ${paint("gray", `(${attemptAgent})`)}`);
3790
+ finishSlice(true);
3791
+ } catch (err) {
3792
+ logError(prefix, `variant ${variantIndex}: failed to upload (${err.message})`);
3793
+ finishSlice(false);
3746
3794
  }
3747
- res({ agent: slice.agentLabel, ok, reason: spawnFailureReason });
3748
3795
  });
3749
- });
3750
- })
3751
- );
3796
+ };
3797
+ launch(chain[attemptIndex]);
3798
+ });
3799
+ };
3800
+ const variantIndices = Array.from({ length: totalVariants }, (_, i) => i + 1);
3801
+ const VARIANT_CONCURRENCY = Math.min(3, totalVariants);
3802
+ let nextVariant = 0;
3803
+ let okCount = 0;
3804
+ const workers = Array.from({ length: VARIANT_CONCURRENCY }, async () => {
3805
+ while (true) {
3806
+ if (activeEntry.terminatedForError) return;
3807
+ const idx = nextVariant++;
3808
+ if (idx >= variantIndices.length) return;
3809
+ const ok = await runVariant(variantIndices[idx]);
3810
+ if (ok) okCount++;
3811
+ }
3812
+ });
3813
+ await Promise.all(workers);
3752
3814
  if (active.get(key) === activeEntry) {
3753
3815
  active.delete(key);
3754
3816
  }
3817
+ if (activeEntry.terminatedForError) {
3818
+ cleanupRefDir(refImageDir);
3819
+ queued.delete(key);
3820
+ return;
3821
+ }
3755
3822
  finishing.add(key);
3756
3823
  try {
3757
- const protoPattern = /^prototype-\d+\.html$/;
3758
- const collected = [];
3759
- for (const slice of slices) {
3760
- let found = [];
3761
- try {
3762
- found = readdirSync(slice.dir).filter((f) => protoPattern.test(f)).sort();
3763
- } catch {
3764
- }
3765
- for (const f of found) {
3766
- try {
3767
- const content = readFileSync5(resolve2(slice.dir, f), "utf-8");
3768
- collected.push({ name: f, content, agent: slice.agentLabel });
3769
- } catch (err) {
3770
- logError(prefix, `Failed reading ${f} from ${slice.dir}: ${err.message}`);
3771
- }
3772
- }
3773
- }
3774
- collected.sort((a, b) => {
3775
- const na = parseInt(a.name.match(/\d+/)?.[0] ?? "0", 10);
3776
- const nb = parseInt(b.name.match(/\d+/)?.[0] ?? "0", 10);
3777
- return na - nb;
3778
- });
3779
- const seen = /* @__PURE__ */ new Set();
3780
- const finalFiles = [];
3781
- let renumberCursor = 1;
3782
- for (const f of collected) {
3783
- let name = f.name;
3784
- if (seen.has(name)) {
3785
- while (seen.has(`prototype-${renumberCursor}.html`)) renumberCursor++;
3786
- name = `prototype-${renumberCursor}.html`;
3787
- }
3788
- seen.add(name);
3789
- finalFiles.push({ name, content: f.content, agent: f.agent });
3790
- }
3791
- const allOk = sliceResults.every((r) => r.ok);
3792
- if (finalFiles.length === 0) {
3793
- logError(prefix, `No prototype HTML files produced by any agent`);
3824
+ if (okCount === 0) {
3825
+ logError(prefix, `No prototype variants were produced`);
3794
3826
  await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" });
3795
3827
  } else {
3796
- await api.patch(`/api/prototypes/${proto.id}`, {
3828
+ let finalFiles = [];
3829
+ try {
3830
+ const current = await api.get(`/api/prototypes/${proto.id}`);
3831
+ finalFiles = (current.files ?? []).slice();
3832
+ } catch (err) {
3833
+ logWarn(prefix, `Could not re-read files before completing: ${err.message}`);
3834
+ }
3835
+ finalFiles.sort((a, b) => {
3836
+ const na = parseInt(a.name.match(/\d+/)?.[0] ?? "0", 10);
3837
+ const nb = parseInt(b.name.match(/\d+/)?.[0] ?? "0", 10);
3838
+ return na - nb;
3839
+ });
3840
+ const patch = {
3797
3841
  status: "completed",
3798
- files: finalFiles,
3799
3842
  variantModels: finalFiles.map((f) => f.agent ?? null)
3800
- });
3801
- if (allOk) {
3802
- logSuccess(prefix, `"${paint("bold", proto.title)}" uploaded ${finalFiles.length} file(s) from ${slices.length} agent(s)`);
3843
+ };
3844
+ if (finalFiles.length > 0) patch.files = finalFiles;
3845
+ await api.patch(`/api/prototypes/${proto.id}`, patch);
3846
+ if (okCount === totalVariants) {
3847
+ logSuccess(prefix, `"${paint("bold", proto.title)}" completed \u2014 ${okCount} variant(s) streamed in`);
3803
3848
  } else {
3804
- const failed2 = sliceResults.filter((r) => !r.ok).map((r) => r.agent).join(", ");
3805
- logSuccess(prefix, `"${paint("bold", proto.title)}" uploaded ${finalFiles.length} file(s) (partial \u2014 ${failed2} failed)`);
3806
- }
3807
- }
3808
- for (const slice of slices) {
3809
- try {
3810
- for (const f of readdirSync(slice.dir)) {
3811
- try {
3812
- unlinkSync(resolve2(slice.dir, f));
3813
- } catch {
3814
- }
3815
- }
3816
- rmdirSync(slice.dir);
3817
- } catch {
3849
+ logSuccess(prefix, `"${paint("bold", proto.title)}" completed \u2014 ${okCount}/${totalVariants} variant(s) (partial)`);
3818
3850
  }
3819
3851
  }
3820
3852
  } catch (err) {
3821
- logError(prefix, `Failed to upload multi-agent prototype: ${err.message}`);
3853
+ logError(prefix, `Failed to finalize prototype: ${err.message}`);
3822
3854
  try {
3823
3855
  await api.patch(`/api/prototypes/${proto.id}`, { status: "failed" });
3824
3856
  } catch {
@@ -4052,7 +4084,7 @@ ${divider}`);
4052
4084
  `${paint("cyan", "?")} Approve task "${paint("bold", task.title)}"? [y/N] `
4053
4085
  );
4054
4086
  if (approved) {
4055
- dispatchTask(task, repoDir);
4087
+ dispatchTaskSafely(task, repoDir);
4056
4088
  } else {
4057
4089
  logWarn(prefix, `"${paint("bold", task.title)}" denied \u2014 will not retry`);
4058
4090
  failed.set(task.id, "denied by user");
@@ -4215,7 +4247,7 @@ ${divider}`);
4215
4247
  approvalQueue.push({ task, repoDir });
4216
4248
  processApprovalQueue();
4217
4249
  } else {
4218
- dispatchTask(task, repoDir);
4250
+ dispatchTaskSafely(task, repoDir);
4219
4251
  }
4220
4252
  }
4221
4253
  let prototypes = [];
@@ -4229,7 +4261,11 @@ ${divider}`);
4229
4261
  if (key.startsWith("proto-") && !inProgressProtoKeys.has(key)) {
4230
4262
  logWarn(watchTag(), `Prototype ${paint("yellow", key)} no longer in_progress, terminating\u2026`);
4231
4263
  entry.terminatedForError = true;
4232
- entry.process.kill("SIGTERM");
4264
+ if (entry.children && entry.children.size > 0) {
4265
+ for (const child of entry.children) child.kill("SIGTERM");
4266
+ } else {
4267
+ entry.process.kill("SIGTERM");
4268
+ }
4233
4269
  active.delete(key);
4234
4270
  queued.delete(key);
4235
4271
  }
@@ -4878,23 +4914,35 @@ var prototypeCommand = new Command15("prototype").description("Manage prototypes
4878
4914
  for (const p of prototypes) {
4879
4915
  const date = new Date(p.createdAt).toLocaleDateString();
4880
4916
  const typeLabel2 = typeLabels[p.prototypeType] ?? p.prototypeType ?? "web";
4917
+ const fidelityLabel = p.fidelity === "low" ? "wireframe" : "hi-fi";
4881
4918
  console.log(
4882
- ` ${paint4("bold", p.title)} ${statusBadge(p.status)} ${paint4("blue", `[${typeLabel2}]`)} ${paint4("gray", p.id.slice(0, 8))} ${paint4("dim", date)}`
4919
+ ` ${paint4("bold", p.title)} ${statusBadge(p.status)} ${paint4("blue", `[${typeLabel2}/${fidelityLabel}]`)} ${paint4("gray", p.id.slice(0, 8))} ${paint4("dim", date)}`
4883
4920
  );
4884
4921
  console.log(` ${paint4("dim", p.prompt.slice(0, 80) + (p.prompt.length > 80 ? "\u2026" : ""))}`);
4885
4922
  console.log();
4886
4923
  }
4887
4924
  })
4888
4925
  ).addCommand(
4889
- new Command15("create").description("Create a new prototype").argument("<title>", "Title of the prototype").requiredOption("--prompt <prompt>", "Design description / prompt").option("--project <projectId>", "Project ID (defaults to linked project, when available)").option("--variants <count>", "Number of variants to generate (1-50)", "5").option("--type <type>", "Prototype type: web_app, mobile_app, desktop_app, logo (default: web_app)", "web_app").action(async (title, opts) => {
4926
+ new Command15("create").description("Create a new prototype").argument("<title>", "Title of the prototype").requiredOption("--prompt <prompt>", "Design description / prompt").option("--project <projectId>", "Project ID (defaults to linked project, when available)").option("--variants <count>", "Number of variants to generate (1-50)", "5").option("--type <type>", "Prototype type: web_app, mobile_app, desktop_app, logo (default: web_app)", "web_app").option("--fidelity <fidelity>", "Fidelity level: low, high (default: high)", "high").action(async (title, opts) => {
4890
4927
  const projectId = opts.project ?? getLinkedProjectId();
4891
4928
  const variantCount = Math.max(1, Math.min(50, parseInt(opts.variants, 10) || 5));
4892
4929
  const validTypes = ["web_app", "mobile_app", "desktop_app", "logo"];
4893
4930
  const prototypeType = validTypes.includes(opts.type) ? opts.type : "web_app";
4931
+ const validFidelities = ["low", "high"];
4932
+ if (opts.fidelity && !validFidelities.includes(opts.fidelity)) {
4933
+ console.error(`Invalid fidelity value: ${opts.fidelity}. Supported values: ${validFidelities.join(", ")}`);
4934
+ process.exit(1);
4935
+ }
4936
+ const fidelity = opts.fidelity || "high";
4937
+ if (prototypeType === "logo" && fidelity === "low") {
4938
+ console.error("Logo prototypes do not support low fidelity.");
4939
+ process.exit(1);
4940
+ }
4894
4941
  const prototype = await api.post("/api/prototypes", {
4895
4942
  title,
4896
4943
  prompt: opts.prompt,
4897
4944
  prototypeType,
4945
+ fidelity,
4898
4946
  variantCount,
4899
4947
  projectId: projectId ?? null
4900
4948
  });
@@ -4908,6 +4956,7 @@ var prototypeCommand = new Command15("prototype").description("Manage prototypes
4908
4956
  console.log(` ${paint4("green", "\u2713")} Created prototype: ${paint4("bold", prototype.title)}`);
4909
4957
  console.log(` ${paint4("gray", "ID:")} ${prototype.id}`);
4910
4958
  console.log(` ${paint4("gray", "Type:")} ${typeLabels[prototype.prototypeType] ?? prototype.prototypeType}`);
4959
+ console.log(` ${paint4("gray", "Fidelity:")} ${prototype.fidelity === "low" ? "Low (wireframe)" : "High (hi-fi)"}`);
4911
4960
  if (!prototype.projectId) {
4912
4961
  console.log(` ${paint4("gray", "Project:")} none (will generate in the active watch directory)`);
4913
4962
  }
@@ -5208,7 +5257,7 @@ function printResults(checks) {
5208
5257
  return allOk;
5209
5258
  }
5210
5259
  async function autoFix(checks, agent) {
5211
- const { spawn: spawn10 } = await import("child_process");
5260
+ const { spawn: spawn9 } = await import("child_process");
5212
5261
  const ghInstalled = checks.find((c14) => c14.name === "GitHub CLI (gh)").ok;
5213
5262
  const ghAuthed = checks.find((c14) => c14.name === "GitHub CLI auth").ok;
5214
5263
  const mrAuthed = checks.find((c14) => c14.name === "Mr. Manager CLI auth").ok;
@@ -5217,7 +5266,7 @@ async function autoFix(checks, agent) {
5217
5266
  console.log(paint5("cyan", " Installing Claude Code..."));
5218
5267
  console.log(paint5("dim", " Running: curl -fsSL https://claude.ai/install.sh | bash"));
5219
5268
  await new Promise((resolve9) => {
5220
- const child = spawn10("bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"], { stdio: "inherit" });
5269
+ const child = spawn9("bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"], { stdio: "inherit" });
5221
5270
  child.on("exit", () => resolve9());
5222
5271
  });
5223
5272
  console.log("");
@@ -5225,7 +5274,7 @@ async function autoFix(checks, agent) {
5225
5274
  if (ghInstalled && !ghAuthed) {
5226
5275
  console.log(paint5("cyan", " Running gh auth login..."));
5227
5276
  await new Promise((resolve9) => {
5228
- const child = spawn10("gh", ["auth", "login"], { stdio: "inherit" });
5277
+ const child = spawn9("gh", ["auth", "login"], { stdio: "inherit" });
5229
5278
  child.on("exit", () => resolve9());
5230
5279
  });
5231
5280
  console.log("");
@@ -5234,7 +5283,7 @@ async function autoFix(checks, agent) {
5234
5283
  console.log(paint5("cyan", " Running mr login..."));
5235
5284
  const entry = process.argv[1];
5236
5285
  await new Promise((resolve9) => {
5237
- const child = spawn10(process.execPath, [entry, "login"], { stdio: "inherit" });
5286
+ const child = spawn9(process.execPath, [entry, "login"], { stdio: "inherit" });
5238
5287
  child.on("exit", () => resolve9());
5239
5288
  });
5240
5289
  console.log("");
@@ -6020,13 +6069,92 @@ var noMrCommand = new Command24("no-mr").description("Signal that a task does no
6020
6069
 
6021
6070
  // cli/commands/review.ts
6022
6071
  import { Command as Command26 } from "commander";
6023
- import { spawn as spawn8, execSync as execSync6 } from "child_process";
6072
+ import { execSync as execSync6 } from "child_process";
6024
6073
  import { existsSync as existsSync14, statSync as statSync2 } from "fs";
6025
6074
 
6026
6075
  // cli/commands/review-apply.ts
6027
6076
  import { Command as Command25 } from "commander";
6028
- import { spawn as spawn7, execSync as execSync5 } from "child_process";
6077
+ import { execSync as execSync5 } from "child_process";
6029
6078
  import { existsSync as existsSync13 } from "fs";
6079
+
6080
+ // lib/review/agent.ts
6081
+ import { spawn as spawn7, exec as exec3 } from "child_process";
6082
+ var AGENT_BINARIES2 = {
6083
+ claude: "claude",
6084
+ codex: "codex",
6085
+ antigravity: "agy"
6086
+ };
6087
+ function commandExists3(cmd) {
6088
+ return new Promise((resolve9) => {
6089
+ exec3(`command -v ${cmd}`, (err) => resolve9(!err));
6090
+ });
6091
+ }
6092
+ async function resolveReviewAgentChain(preferred2 = "claude") {
6093
+ const availability = {};
6094
+ for (const candidate of ["claude", "codex", "antigravity"]) {
6095
+ availability[candidate] = await commandExists3(AGENT_BINARIES2[candidate]);
6096
+ }
6097
+ return getAvailableAgentFallbackChain(preferred2, availability);
6098
+ }
6099
+ function buildArgs(agent, prompt2) {
6100
+ if (agent === "codex") {
6101
+ return ["-a", "never", "exec", "-s", "danger-full-access", prompt2];
6102
+ }
6103
+ if (agent === "antigravity") {
6104
+ return ["-p", prompt2, "--dangerously-skip-permissions"];
6105
+ }
6106
+ return ["-p", "--dangerously-skip-permissions", prompt2];
6107
+ }
6108
+ function runOnce(agent, prompt2, opts) {
6109
+ return new Promise((resolve9) => {
6110
+ const child = spawn7(AGENT_BINARIES2[agent], buildArgs(agent, prompt2), {
6111
+ cwd: opts.cwd,
6112
+ stdio: opts.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"]
6113
+ });
6114
+ let output = "";
6115
+ if (opts.capture) {
6116
+ child.stdout?.on("data", (d) => {
6117
+ output += d.toString();
6118
+ });
6119
+ child.stderr?.on("data", () => {
6120
+ });
6121
+ }
6122
+ child.on("error", () => {
6123
+ resolve9({ ok: false, output, spawnError: true });
6124
+ });
6125
+ child.on("exit", (code) => {
6126
+ resolve9({ ok: code === 0, output: output.trim(), spawnError: false });
6127
+ });
6128
+ });
6129
+ }
6130
+ async function runAgentCaptured(prompt2, opts) {
6131
+ if (opts.chain.length === 0) {
6132
+ throw new Error("No available agents (need one of: claude, codex, agy)");
6133
+ }
6134
+ let lastError = "";
6135
+ for (const agent of opts.chain) {
6136
+ const result = await runOnce(agent, prompt2, { cwd: opts.cwd, capture: true });
6137
+ if (result.ok && result.output) {
6138
+ return { output: result.output, agent };
6139
+ }
6140
+ lastError = result.spawnError ? `${agent} could not be spawned` : `${agent} exited without usable output`;
6141
+ }
6142
+ throw new Error(`All agents failed (${lastError})`);
6143
+ }
6144
+ async function runAgentInteractive(prompt2, opts) {
6145
+ if (opts.chain.length === 0) {
6146
+ throw new Error("No available agents (need one of: claude, codex, agy)");
6147
+ }
6148
+ let lastError = "";
6149
+ for (const agent of opts.chain) {
6150
+ const result = await runOnce(agent, prompt2, { cwd: opts.cwd, capture: false });
6151
+ if (result.ok) return { agent };
6152
+ lastError = result.spawnError ? `${agent} could not be spawned` : `${agent} exited non-zero`;
6153
+ }
6154
+ throw new Error(`All agents failed (${lastError})`);
6155
+ }
6156
+
6157
+ // cli/commands/review-apply.ts
6030
6158
  var c8 = {
6031
6159
  reset: "\x1B[0m",
6032
6160
  dim: "\x1B[2m",
@@ -6056,7 +6184,7 @@ function logOk(msg) {
6056
6184
  function logErr(msg) {
6057
6185
  console.error(`${timestamp2()} ${tag()} ${paint8("red", "\u2717")} ${msg}`);
6058
6186
  }
6059
- var reviewApplyCommand = new Command25("apply").description("Apply review comments and findings using the Claude agent").argument("<id>", "Code review ID (the one shown in the Reviews UI)").option("--no-push", "Skip pushing the fix commit to the remote").option("--no-commit", "Apply changes but don't commit (for dry-run review)").action(async (id, opts) => {
6187
+ var reviewApplyCommand = new Command25("apply").description("Apply review comments and findings using the Claude agent").argument("<id>", "Code review ID (the one shown in the Reviews UI)").option("--no-push", "Skip pushing the fix commit to the remote").option("--no-commit", "Apply changes but don't commit (for dry-run review)").option("--in-place", "Apply edits directly in the linked checkout instead of an isolated worktree (legacy, unsafe)").action(async (id, opts) => {
6060
6188
  const config = loadConfig();
6061
6189
  if (!config.apiKey) {
6062
6190
  logErr('Not authenticated. Run "mr login" first.');
@@ -6077,9 +6205,12 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6077
6205
  process.exit(1);
6078
6206
  }
6079
6207
  const actionableComments = comments.filter((c14) => !excluded.has(c14.file));
6080
- const actionableFindings = findings.filter((f) => !excluded.has(f.file));
6208
+ const dismissedCount = findings.filter((f) => f.status === "dismissed").length;
6209
+ const actionableFindings = findings.filter(
6210
+ (f) => !excluded.has(f.file) && f.status !== "dismissed"
6211
+ );
6081
6212
  if (actionableComments.length === 0 && actionableFindings.length === 0) {
6082
- logErr("All files with comments/findings are excluded \u2014 nothing to do.");
6213
+ logErr("No selected comments or findings to act on (all excluded or dismissed).");
6083
6214
  process.exit(1);
6084
6215
  }
6085
6216
  let project;
@@ -6104,19 +6235,59 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6104
6235
  logErr(`Set the project's localPath or run from inside the repo.`);
6105
6236
  process.exit(1);
6106
6237
  }
6107
- try {
6108
- execSync5(`git fetch origin ${review.branch}`, { cwd: projectPath, stdio: "ignore" });
6109
- } catch {
6110
- }
6111
- try {
6112
- execSync5(`git checkout ${review.branch}`, { cwd: projectPath, stdio: "pipe" });
6113
- } catch (err) {
6114
- logErr(`Could not checkout branch ${review.branch}: ${err.message}`);
6238
+ const chain = await resolveReviewAgentChain("claude");
6239
+ if (chain.length === 0) {
6240
+ logErr("No available agent found (need one of: claude, codex, agy)");
6115
6241
  process.exit(1);
6116
6242
  }
6243
+ const touchedFiles = Array.from(
6244
+ new Set([
6245
+ ...actionableComments.map((c14) => c14.file),
6246
+ ...actionableFindings.map((f) => f.file)
6247
+ ].filter(Boolean))
6248
+ );
6249
+ let workDir = projectPath;
6250
+ let cleanup = () => {
6251
+ };
6252
+ if (opts.inPlace) {
6253
+ try {
6254
+ execSync5(`git fetch origin ${review.branch}`, { cwd: projectPath, stdio: "ignore" });
6255
+ } catch {
6256
+ }
6257
+ try {
6258
+ execSync5(`git checkout ${review.branch}`, { cwd: projectPath, stdio: "pipe" });
6259
+ } catch (err) {
6260
+ logErr(`Could not checkout branch ${review.branch}: ${err.message}`);
6261
+ process.exit(1);
6262
+ }
6263
+ } else {
6264
+ try {
6265
+ const wt = createWorktree(projectPath, review.branch, `review-apply-${id}`, {
6266
+ syncRemoteBranch: true
6267
+ });
6268
+ workDir = wt.path;
6269
+ } catch (err) {
6270
+ logErr(`Could not create worktree for ${review.branch}: ${err.message}`);
6271
+ process.exit(1);
6272
+ }
6273
+ cleanup = () => removeWorktree(projectPath, workDir);
6274
+ }
6275
+ const failAndExit = async (message, extra = {}) => {
6276
+ logErr(message);
6277
+ try {
6278
+ await api.patch(`/api/reviews/${id}`, { fixStatus: "failed", fixErrorMessage: message, ...extra });
6279
+ } catch {
6280
+ }
6281
+ cleanup();
6282
+ process.exit(1);
6283
+ };
6117
6284
  log(`Project: ${paint8("cyan", project.name)}`);
6118
6285
  log(`Branch: ${paint8("cyan", review.branch)}`);
6286
+ log(`Workdir: ${paint8("dim", opts.inPlace ? `${workDir} (in-place)` : workDir)}`);
6119
6287
  log(`Comments: ${paint8("yellow", String(actionableComments.length))}, findings: ${paint8("yellow", String(actionableFindings.length))}`);
6288
+ if (dismissedCount > 0) {
6289
+ log(`Dismissed findings skipped: ${paint8("dim", String(dismissedCount))}`);
6290
+ }
6120
6291
  if (excluded.size > 0) {
6121
6292
  log(`Excluded files: ${paint8("dim", [...excluded].join(", "))}`);
6122
6293
  }
@@ -6131,19 +6302,14 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6131
6302
  findings: actionableFindings,
6132
6303
  excluded: [...excluded]
6133
6304
  });
6134
- log("Asking Claude to apply the changes\u2026");
6305
+ log(`Asking ${paint8("cyan", chain[0])} to apply the changes\u2026`);
6135
6306
  try {
6136
- await runClaudeInteractive(prompt2, projectPath);
6307
+ const { agent } = await runAgentInteractive(prompt2, { cwd: workDir, chain });
6308
+ if (agent !== chain[0]) log(`Applied with fallback agent ${paint8("cyan", agent)}`);
6137
6309
  } catch (err) {
6138
- const message = err.message || "Unknown error";
6139
- logErr(`Agent fix failed: ${message}`);
6140
- try {
6141
- await api.patch(`/api/reviews/${id}`, { fixStatus: "failed", fixErrorMessage: message });
6142
- } catch {
6143
- }
6144
- process.exit(1);
6310
+ await failAndExit(`Agent fix failed: ${err.message || "Unknown error"}`);
6145
6311
  }
6146
- const dirty = execSync5("git status --porcelain", { cwd: projectPath, encoding: "utf-8" }).trim();
6312
+ const dirty = execSync5("git status --porcelain", { cwd: workDir, encoding: "utf-8" }).trim();
6147
6313
  if (!dirty) {
6148
6314
  log(paint8("yellow", "Agent didn't change any files."));
6149
6315
  try {
@@ -6153,10 +6319,11 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6153
6319
  });
6154
6320
  } catch {
6155
6321
  }
6322
+ cleanup();
6156
6323
  return;
6157
6324
  }
6158
6325
  if (opts.commit === false) {
6159
- logOk("Changes left unstaged for review (--no-commit).");
6326
+ logOk(`Changes left unstaged for review (--no-commit) in ${paint8("dim", workDir)}.`);
6160
6327
  try {
6161
6328
  await api.patch(`/api/reviews/${id}`, { fixStatus: "completed" });
6162
6329
  } catch {
@@ -6168,35 +6335,27 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6168
6335
  findingsCount: actionableFindings.length
6169
6336
  });
6170
6337
  try {
6171
- execSync5("git add -A", { cwd: projectPath });
6172
- execSync5(`git commit -m ${JSON.stringify(commitMessage)}`, { cwd: projectPath, stdio: "pipe" });
6173
- } catch (err) {
6174
- const message = err.message || "Unknown error";
6175
- logErr(`Commit failed: ${message}`);
6176
- try {
6177
- await api.patch(`/api/reviews/${id}`, { fixStatus: "failed", fixErrorMessage: message });
6178
- } catch {
6338
+ if (touchedFiles.length > 0) {
6339
+ const args = touchedFiles.map((f) => JSON.stringify(f)).join(" ");
6340
+ execSync5(`git add -- ${args}`, { cwd: workDir });
6341
+ } else {
6342
+ execSync5("git add -A", { cwd: workDir });
6179
6343
  }
6180
- process.exit(1);
6344
+ execSync5(`git commit -m ${JSON.stringify(commitMessage)}`, { cwd: workDir, stdio: "pipe" });
6345
+ } catch (err) {
6346
+ await failAndExit(`Commit failed: ${err.message || "Unknown error"}`);
6181
6347
  }
6182
- const sha = execSync5("git rev-parse HEAD", { cwd: projectPath, encoding: "utf-8" }).trim();
6348
+ const sha = execSync5("git rev-parse HEAD", { cwd: workDir, encoding: "utf-8" }).trim();
6183
6349
  logOk(`Committed ${paint8("yellow", sha.slice(0, 10))}`);
6184
6350
  if (opts.push !== false) {
6185
6351
  try {
6186
- execSync5(`git push origin ${review.branch}`, { cwd: projectPath, stdio: "pipe" });
6352
+ execSync5(`git push origin ${review.branch}`, { cwd: workDir, stdio: "pipe" });
6187
6353
  logOk(`Pushed to origin/${review.branch}`);
6188
6354
  } catch (err) {
6189
- const message = err.message || "Unknown error";
6190
- logErr(`Push failed: ${message}`);
6191
- try {
6192
- await api.patch(`/api/reviews/${id}`, {
6193
- fixStatus: "failed",
6194
- fixErrorMessage: `Committed ${sha.slice(0, 10)} but push failed: ${message}`,
6195
- fixCommitSha: sha
6196
- });
6197
- } catch {
6198
- }
6199
- process.exit(1);
6355
+ await failAndExit(
6356
+ `Committed ${sha.slice(0, 10)} but push failed: ${err.message || "Unknown error"}`,
6357
+ { fixCommitSha: sha }
6358
+ );
6200
6359
  }
6201
6360
  }
6202
6361
  try {
@@ -6207,6 +6366,7 @@ var reviewApplyCommand = new Command25("apply").description("Apply review commen
6207
6366
  });
6208
6367
  } catch {
6209
6368
  }
6369
+ cleanup();
6210
6370
  logOk("Done.");
6211
6371
  });
6212
6372
  function buildApplyPrompt(args) {
@@ -6250,17 +6410,418 @@ function buildCommitMessage(args) {
6250
6410
 
6251
6411
  Generated by mr review apply.`;
6252
6412
  }
6253
- function runClaudeInteractive(prompt2, cwd) {
6254
- return new Promise((resolve9, reject) => {
6255
- const child = spawn7("claude", ["-p", "--dangerously-skip-permissions", prompt2], {
6256
- cwd,
6257
- stdio: ["ignore", "inherit", "inherit"]
6258
- });
6259
- child.on("exit", (code) => {
6260
- if (code === 0) resolve9();
6261
- else reject(new Error(`claude exited with code ${code}`));
6413
+
6414
+ // lib/review/dimensions.ts
6415
+ var REVIEW_DIMENSIONS = [
6416
+ {
6417
+ key: "security",
6418
+ title: "Security",
6419
+ allowedTypes: ["security", "bug"],
6420
+ focus: [
6421
+ "Hunt exclusively for security problems:",
6422
+ "- Injection (SQL/NoSQL/command/path), XSS, SSRF, and unsafe deserialization",
6423
+ "- AuthN/AuthZ gaps: missing ownership checks, privilege escalation, IDOR",
6424
+ "- Secrets or credentials committed, logged, or returned to clients",
6425
+ "- Unsafe handling of user input, missing validation, or unescaped output",
6426
+ "- Insecure crypto, weak randomness, or Edge-runtime incompatible Node APIs",
6427
+ "Do NOT report style, performance, or generic refactor ideas here."
6428
+ ].join("\n")
6429
+ },
6430
+ {
6431
+ key: "correctness",
6432
+ title: "Correctness",
6433
+ allowedTypes: ["bug"],
6434
+ focus: [
6435
+ "Hunt exclusively for correctness bugs and logical errors:",
6436
+ "- Off-by-one, null/undefined dereferences, incorrect conditionals",
6437
+ "- Broken contracts: a changed function signature whose other call sites are now wrong",
6438
+ "- Unhandled error paths, swallowed exceptions, race conditions",
6439
+ "- State that can desync, missing await, or incorrect async ordering",
6440
+ "Open neighboring files to confirm a change doesn't break an un-diffed caller.",
6441
+ "Do NOT report style, naming, or performance micro-optimizations here."
6442
+ ].join("\n")
6443
+ },
6444
+ {
6445
+ key: "performance",
6446
+ title: "Performance",
6447
+ allowedTypes: ["performance", "bug"],
6448
+ focus: [
6449
+ "Hunt exclusively for performance problems:",
6450
+ "- N+1 queries, missing indexes, redundant network/database round-trips",
6451
+ "- Unnecessary re-renders, missing memoization, work done in hot loops",
6452
+ "- Large synchronous work on a request path, unbounded memory growth",
6453
+ "Only flag issues with a realistic, material impact. Do NOT report style or naming."
6454
+ ].join("\n")
6455
+ },
6456
+ {
6457
+ key: "style",
6458
+ title: "Style & Maintainability",
6459
+ allowedTypes: ["style", "suggestion", "nitpick"],
6460
+ focus: [
6461
+ "Look for style, readability and maintainability improvements:",
6462
+ "- Naming, dead code, duplication, and inconsistent patterns",
6463
+ "- Missing types, unclear abstractions, or violations of repo conventions",
6464
+ "- Small suggestions and nitpicks (keep these low severity)",
6465
+ "Do NOT duplicate security/correctness/performance findings \u2014 stay in your lane."
6466
+ ].join("\n")
6467
+ }
6468
+ ];
6469
+
6470
+ // lib/review/parse.ts
6471
+ function extractJsonObjects(text) {
6472
+ const objects = [];
6473
+ let depth = 0;
6474
+ let start = -1;
6475
+ let inString = false;
6476
+ let escaped = false;
6477
+ for (let i = 0; i < text.length; i++) {
6478
+ const ch = text[i];
6479
+ if (inString) {
6480
+ if (escaped) {
6481
+ escaped = false;
6482
+ } else if (ch === "\\") {
6483
+ escaped = true;
6484
+ } else if (ch === '"') {
6485
+ inString = false;
6486
+ }
6487
+ continue;
6488
+ }
6489
+ if (ch === '"') {
6490
+ inString = true;
6491
+ } else if (ch === "{") {
6492
+ if (depth === 0) start = i;
6493
+ depth++;
6494
+ } else if (ch === "}") {
6495
+ if (depth > 0) {
6496
+ depth--;
6497
+ if (depth === 0 && start !== -1) {
6498
+ objects.push(text.slice(start, i + 1));
6499
+ start = -1;
6500
+ }
6501
+ }
6502
+ }
6503
+ }
6504
+ return objects;
6505
+ }
6506
+ function parseReviewOutput(output) {
6507
+ const cleaned = output.replace(/```(?:json)?/gi, "").replace(/```/g, "").trim();
6508
+ const candidates = [cleaned, ...extractJsonObjects(cleaned)].sort(
6509
+ (a, b) => b.length - a.length
6510
+ );
6511
+ for (const candidate of candidates) {
6512
+ try {
6513
+ const parsed = JSON.parse(candidate);
6514
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.findings)) {
6515
+ return {
6516
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
6517
+ findings: parsed.findings,
6518
+ parseFailed: false
6519
+ };
6520
+ }
6521
+ } catch {
6522
+ }
6523
+ }
6524
+ return { summary: "", findings: [], parseFailed: true };
6525
+ }
6526
+
6527
+ // lib/review/merge.ts
6528
+ var SEVERITY_RANK = {
6529
+ critical: 4,
6530
+ high: 3,
6531
+ medium: 2,
6532
+ low: 1
6533
+ };
6534
+ function tokenize(text) {
6535
+ return new Set(
6536
+ (text || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean)
6537
+ );
6538
+ }
6539
+ function titleSimilarity(a, b) {
6540
+ const ta = tokenize(a);
6541
+ const tb = tokenize(b);
6542
+ if (ta.size === 0 && tb.size === 0) return 1;
6543
+ if (ta.size === 0 || tb.size === 0) return 0;
6544
+ let intersection = 0;
6545
+ for (const t of ta) if (tb.has(t)) intersection++;
6546
+ const union = ta.size + tb.size - intersection;
6547
+ return intersection / union;
6548
+ }
6549
+ function severityRank(sev) {
6550
+ return SEVERITY_RANK[sev] ?? 0;
6551
+ }
6552
+ function isDuplicate(a, b) {
6553
+ if (a.file !== b.file) return false;
6554
+ const aLine = a.line;
6555
+ const bLine = b.line;
6556
+ if (aLine != null && bLine != null) {
6557
+ if (Math.abs(aLine - bLine) > 3) return false;
6558
+ } else if (aLine != null || bLine != null) {
6559
+ return titleSimilarity(a.title, b.title) >= 0.8;
6560
+ }
6561
+ return titleSimilarity(a.title, b.title) >= 0.5;
6562
+ }
6563
+ function preferred(a, b) {
6564
+ const aScore = severityRank(a.severity) * 10 + (a.confidence ?? 0);
6565
+ const bScore = severityRank(b.severity) * 10 + (b.confidence ?? 0);
6566
+ const winner = bScore > aScore ? b : a;
6567
+ const loser = winner === a ? b : a;
6568
+ const dims = [winner.dimension, loser.dimension].filter(Boolean);
6569
+ return {
6570
+ ...winner,
6571
+ description: (loser.description?.length ?? 0) > (winner.description?.length ?? 0) ? loser.description : winner.description,
6572
+ suggestion: winner.suggestion || loser.suggestion,
6573
+ dimension: Array.from(new Set(dims)).join("+") || winner.dimension,
6574
+ confidence: Math.max(winner.confidence ?? 0, loser.confidence ?? 0) || void 0
6575
+ };
6576
+ }
6577
+ function mergeFindings(all) {
6578
+ const merged = [];
6579
+ for (const finding of all) {
6580
+ const existingIdx = merged.findIndex((m) => isDuplicate(m, finding));
6581
+ if (existingIdx === -1) {
6582
+ merged.push(finding);
6583
+ } else {
6584
+ merged[existingIdx] = preferred(merged[existingIdx], finding);
6585
+ }
6586
+ }
6587
+ merged.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
6588
+ return merged.map((f, i) => ({ ...f, id: `f${i + 1}` }));
6589
+ }
6590
+
6591
+ // lib/review/prompt.ts
6592
+ var JSON_CONTRACT = `Return ONLY a JSON object (no markdown fences, no prose before or after) with this exact shape:
6593
+ {
6594
+ "summary": "1-2 sentence assessment for this concern",
6595
+ "findings": [
6596
+ {
6597
+ "id": "f1",
6598
+ "type": "bug",
6599
+ "severity": "high",
6600
+ "title": "Brief one-line title",
6601
+ "description": "Detailed explanation",
6602
+ "file": "path/to/file.ts",
6603
+ "line": 42,
6604
+ "suggestion": "Concrete fix, with code when possible",
6605
+ "confidence": 0.0
6606
+ }
6607
+ ]
6608
+ }
6609
+ "confidence" is your own 0-1 estimate that this is a real, actionable issue.
6610
+ If there are no issues for this concern, return an empty findings array with a positive summary.`;
6611
+ function buildDimensionPrompt(args) {
6612
+ const lines = [];
6613
+ lines.push(
6614
+ `You are a senior code reviewer focused exclusively on ${args.dimension.title.toUpperCase()}.`,
6615
+ `Review the git diff for branch "${args.branch}" compared to "${args.baseBranch}".`,
6616
+ "",
6617
+ args.dimension.focus,
6618
+ "",
6619
+ `Only emit findings whose "type" is one of: ${args.dimension.allowedTypes.join(", ")}.`,
6620
+ ""
6621
+ );
6622
+ if (args.canReadFiles) {
6623
+ lines.push(
6624
+ "You are running inside a checkout of this repository. You MAY open the changed files",
6625
+ "and their neighbors (callers, callees, related modules) to verify a finding before",
6626
+ "reporting it \u2014 this catches issues that are invisible from the diff alone, like a",
6627
+ "renamed function's other call sites. Prefer confirmed findings over speculation.",
6628
+ ""
6629
+ );
6630
+ }
6631
+ if (args.fileScope && args.fileScope.length > 0) {
6632
+ lines.push(
6633
+ "Concentrate on these files (other diff context is provided only for reference):",
6634
+ ...args.fileScope.map((f) => ` - ${f}`),
6635
+ ""
6636
+ );
6637
+ }
6638
+ lines.push(JSON_CONTRACT, "", "Here is the diff to review:", "", "```diff", args.diff, "```");
6639
+ return lines.join("\n");
6640
+ }
6641
+ function buildVerificationPrompt(args) {
6642
+ const compact = args.findings.map((f) => ({
6643
+ id: f.id,
6644
+ type: f.type,
6645
+ severity: f.severity,
6646
+ title: f.title,
6647
+ description: f.description,
6648
+ file: f.file,
6649
+ line: f.line
6650
+ }));
6651
+ const lines = [];
6652
+ lines.push(
6653
+ `You are an adversarial reviewer verifying findings from an automated review of branch "${args.branch}" vs "${args.baseBranch}".`,
6654
+ "For EACH finding below, try to refute it. A finding should be kept only if it is a real,",
6655
+ "actionable issue grounded in the actual diff. Reject hallucinations, findings that",
6656
+ "reference code that isn't there, and pure speculation.",
6657
+ ""
6658
+ );
6659
+ if (args.canReadFiles) {
6660
+ lines.push(
6661
+ "You are in a checkout of the repo \u2014 open files to confirm or refute each claim.",
6662
+ ""
6663
+ );
6664
+ }
6665
+ lines.push(
6666
+ "Return ONLY a JSON object (no fences, no prose) of this shape:",
6667
+ `{ "verdicts": [ { "id": "f1", "keep": true, "confidence": 0.0, "reason": "why" } ] }`,
6668
+ "confidence is 0-1. Set keep=false when you cannot substantiate the finding.",
6669
+ "",
6670
+ "FINDINGS:",
6671
+ JSON.stringify(compact, null, 2),
6672
+ "",
6673
+ "DIFF:",
6674
+ "```diff",
6675
+ args.diff,
6676
+ "```"
6677
+ );
6678
+ return lines.join("\n");
6679
+ }
6680
+
6681
+ // lib/review/orchestrate.ts
6682
+ function splitDiffByFile(diff) {
6683
+ const sections = diff.split(/(?=^diff --git )/m).filter((s) => s.trim());
6684
+ return sections.map((section) => {
6685
+ const m = section.match(/^diff --git a\/(.+?) b\//m);
6686
+ return { file: m?.[1] ?? "unknown", section };
6687
+ });
6688
+ }
6689
+ async function pool(items, limit) {
6690
+ const results = new Array(items.length);
6691
+ let cursor = 0;
6692
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
6693
+ while (cursor < items.length) {
6694
+ const idx = cursor++;
6695
+ results[idx] = await items[idx]();
6696
+ }
6697
+ });
6698
+ await Promise.all(workers);
6699
+ return results;
6700
+ }
6701
+ function buildJobs(opts) {
6702
+ const files = splitDiffByFile(opts.diff);
6703
+ const shardThreshold = opts.shardThreshold ?? 12;
6704
+ if (files.length >= shardThreshold) {
6705
+ const shardCount = Math.min(4, Math.ceil(files.length / 6));
6706
+ const shards = Array.from(
6707
+ { length: shardCount },
6708
+ () => ({ diff: "", files: [] })
6709
+ );
6710
+ files.forEach((f, i) => {
6711
+ const s = shards[i % shardCount];
6712
+ s.diff += f.section;
6713
+ s.files.push(f.file);
6262
6714
  });
6715
+ const jobs = [];
6716
+ for (const dim of REVIEW_DIMENSIONS) {
6717
+ for (const shard of shards) {
6718
+ jobs.push({ dimension: dim, diff: shard.diff, fileScope: shard.files });
6719
+ }
6720
+ }
6721
+ return jobs;
6722
+ }
6723
+ return REVIEW_DIMENSIONS.map((dimension) => ({ dimension, diff: opts.diff }));
6724
+ }
6725
+ async function orchestrateReview(opts) {
6726
+ const log4 = opts.log ?? (() => {
6727
+ });
6728
+ const concurrency = opts.concurrency ?? 4;
6729
+ const canReadFiles = Boolean(opts.cwd);
6730
+ const runOpts = { cwd: opts.cwd, chain: opts.chain };
6731
+ const jobs = buildJobs(opts);
6732
+ log4(`Fanning out ${jobs.length} review subagent(s) across ${REVIEW_DIMENSIONS.length} dimensions`);
6733
+ const summaries = [];
6734
+ const collected = [];
6735
+ const jobResults = await pool(
6736
+ jobs.map((job) => async () => {
6737
+ const prompt2 = buildDimensionPrompt({
6738
+ dimension: job.dimension,
6739
+ branch: opts.branch,
6740
+ baseBranch: opts.baseBranch,
6741
+ diff: job.diff,
6742
+ canReadFiles,
6743
+ fileScope: job.fileScope
6744
+ });
6745
+ try {
6746
+ const { output } = await runAgentCaptured(prompt2, runOpts);
6747
+ const parsed = parseReviewOutput(output);
6748
+ return { job, parsed };
6749
+ } catch (err) {
6750
+ log4(` ${job.dimension.key} subagent failed: ${err.message}`);
6751
+ return { job, parsed: { summary: "", findings: [], parseFailed: true } };
6752
+ }
6753
+ }),
6754
+ concurrency
6755
+ );
6756
+ const dimensionsRun = /* @__PURE__ */ new Set();
6757
+ for (const { job, parsed } of jobResults) {
6758
+ if (parsed.summary) summaries.push(`${job.dimension.title}: ${parsed.summary}`);
6759
+ for (const f of parsed.findings) {
6760
+ dimensionsRun.add(job.dimension.key);
6761
+ collected.push({
6762
+ id: f.id || "f",
6763
+ type: f.type || job.dimension.allowedTypes[0],
6764
+ severity: f.severity || "medium",
6765
+ title: f.title || "Untitled finding",
6766
+ description: f.description || "",
6767
+ file: f.file || "unknown",
6768
+ line: typeof f.line === "number" ? f.line : void 0,
6769
+ endLine: typeof f.endLine === "number" ? f.endLine : void 0,
6770
+ suggestion: f.suggestion,
6771
+ status: "new",
6772
+ dimension: job.dimension.key,
6773
+ confidence: typeof f.confidence === "number" ? f.confidence : void 0
6774
+ });
6775
+ }
6776
+ }
6777
+ const rawCount = collected.length;
6778
+ let findings = mergeFindings(collected);
6779
+ log4(`Merged ${rawCount} raw findings into ${findings.length} after dedup`);
6780
+ if (opts.verify && findings.length > 0) {
6781
+ findings = await verifyFindings(findings, opts, runOpts, log4);
6782
+ }
6783
+ return {
6784
+ summary: summaries.join(" "),
6785
+ findings,
6786
+ dimensionsRun: [...dimensionsRun],
6787
+ rawCount
6788
+ };
6789
+ }
6790
+ async function verifyFindings(findings, opts, runOpts, log4) {
6791
+ const minConfidence = opts.minConfidence ?? 0.4;
6792
+ const prompt2 = buildVerificationPrompt({
6793
+ findings,
6794
+ branch: opts.branch,
6795
+ baseBranch: opts.baseBranch,
6796
+ diff: opts.diff,
6797
+ canReadFiles: Boolean(opts.cwd)
6263
6798
  });
6799
+ let verdicts = [];
6800
+ try {
6801
+ const { output } = await runAgentCaptured(prompt2, runOpts);
6802
+ const cleaned = output.replace(/```(?:json)?/gi, "").replace(/```/g, "").trim();
6803
+ const match = cleaned.match(/\{[\s\S]*\}/);
6804
+ if (match) {
6805
+ const parsed = JSON.parse(match[0]);
6806
+ verdicts = parsed.verdicts ?? [];
6807
+ }
6808
+ } catch (err) {
6809
+ log4(`Verification stage failed, keeping all findings: ${err.message}`);
6810
+ return findings;
6811
+ }
6812
+ const byId = new Map(verdicts.map((v) => [v.id, v]));
6813
+ const kept = findings.filter((f) => {
6814
+ const v = byId.get(f.id);
6815
+ if (!v) return true;
6816
+ if (v.keep === false) return false;
6817
+ if (typeof v.confidence === "number") {
6818
+ f.confidence = v.confidence;
6819
+ return v.confidence >= minConfidence;
6820
+ }
6821
+ return true;
6822
+ });
6823
+ log4(`Verification kept ${kept.length}/${findings.length} findings`);
6824
+ return kept.map((f, i) => ({ ...f, id: `f${i + 1}` }));
6264
6825
  }
6265
6826
 
6266
6827
  // cli/commands/review.ts
@@ -6330,6 +6891,7 @@ var reviewCommand = new Command26("review").description("Run an automated code r
6330
6891
  const canUseRemote = remote !== null && (prNumber || remote.number) !== void 0;
6331
6892
  let diff;
6332
6893
  let branch = opts.branch;
6894
+ let reviewCwd;
6333
6895
  if (canUseRemote && remote) {
6334
6896
  const num = prNumber ?? remote.number;
6335
6897
  if (!num) {
@@ -6379,6 +6941,7 @@ var reviewCommand = new Command26("review").description("Run an automated code r
6379
6941
  process.exit(1);
6380
6942
  }
6381
6943
  log2(`Using project path: ${paint9("dim", projectPath)}`);
6944
+ reviewCwd = projectPath;
6382
6945
  if (!branch) {
6383
6946
  try {
6384
6947
  branch = execSync6("git rev-parse --abbrev-ref HEAD", {
@@ -6457,17 +7020,39 @@ var reviewCommand = new Command26("review").description("Run an automated code r
6457
7020
  } catch {
6458
7021
  }
6459
7022
  const startTime = Date.now();
6460
- const MAX_DIFF_CHARS = 8e4;
6461
- let truncatedDiff = diff;
7023
+ const chain = await resolveReviewAgentChain("claude");
7024
+ if (chain.length === 0) {
7025
+ const message = "No available agent found (need one of: claude, codex, agy)";
7026
+ logErr2(message);
7027
+ try {
7028
+ await api.patch(`/api/reviews/${reportId}`, { status: "failed", errorMessage: message });
7029
+ } catch {
7030
+ }
7031
+ process.exit(1);
7032
+ }
7033
+ log2(`Agent chain: ${paint9("cyan", chain.join(" \u2192 "))}`);
7034
+ const MAX_DIFF_CHARS = 4e5;
7035
+ const diffCharsTotal = diff.length;
7036
+ let reviewDiff = diff;
7037
+ let diffTruncated = false;
6462
7038
  if (diff.length > MAX_DIFF_CHARS) {
6463
- truncatedDiff = diff.slice(0, MAX_DIFF_CHARS) + "\n\n... (diff truncated, review covers first " + MAX_DIFF_CHARS.toLocaleString() + " characters)";
6464
- log2(paint9("yellow", `Diff truncated to ${MAX_DIFF_CHARS.toLocaleString()} chars for review`));
7039
+ reviewDiff = diff.slice(0, MAX_DIFF_CHARS);
7040
+ diffTruncated = true;
7041
+ log2(paint9("yellow", `Diff truncated to ${MAX_DIFF_CHARS.toLocaleString()} of ${diffCharsTotal.toLocaleString()} chars`));
6465
7042
  }
6466
7043
  try {
6467
- log2("Running code review with Claude...");
6468
- const prompt2 = buildReviewPrompt(branch, baseBranch, truncatedDiff);
6469
- const output = await runClaude(prompt2);
6470
- const result = parseReviewOutput(output);
7044
+ log2("Running fan-out code review...");
7045
+ const orchestrated = await orchestrateReview({
7046
+ branch,
7047
+ baseBranch,
7048
+ diff: reviewDiff,
7049
+ chain,
7050
+ cwd: reviewCwd,
7051
+ concurrency: 4,
7052
+ verify: true,
7053
+ log: (m) => log2(m)
7054
+ });
7055
+ const result = { summary: orchestrated.summary, findings: orchestrated.findings };
6471
7056
  const duration = Date.now() - startTime;
6472
7057
  let wasCancelled = false;
6473
7058
  try {
@@ -6484,7 +7069,9 @@ var reviewCommand = new Command26("review").description("Run an automated code r
6484
7069
  summary: result.summary,
6485
7070
  findings: result.findings,
6486
7071
  filesReviewed: filesChanged,
6487
- reviewDurationMs: duration
7072
+ reviewDurationMs: duration,
7073
+ diffTruncated,
7074
+ diffCharsTotal
6488
7075
  });
6489
7076
  logOk2(`Review completed in ${paint9("cyan", formatDuration(duration))}`);
6490
7077
  logOk2(`Found ${paint9("yellow", String(result.findings.length))} findings`);
@@ -6573,112 +7160,12 @@ function formatDuration(ms) {
6573
7160
  if (s < 60) return `${s}s`;
6574
7161
  return `${Math.floor(s / 60)}m ${s % 60}s`;
6575
7162
  }
6576
- function buildReviewPrompt(branch, baseBranch, diff) {
6577
- return `You are a senior code reviewer. Review the following git diff for branch "${branch}" compared to "${baseBranch}".
6578
-
6579
- Analyze the code changes and produce a JSON response with your review findings.
6580
-
6581
- Focus on:
6582
- - Bugs and logical errors
6583
- - Security vulnerabilities (XSS, injection, auth issues, secrets exposure)
6584
- - Performance issues (N+1 queries, missing indexes, unnecessary re-renders)
6585
- - Code style and best practices violations
6586
- - Suggestions for improvement
6587
- - Nitpicks (minor style/naming issues)
6588
-
6589
- For each finding, provide:
6590
- - A unique ID (e.g. "f1", "f2", etc.)
6591
- - Type: "bug", "security", "performance", "style", "suggestion", or "nitpick"
6592
- - Severity: "critical", "high", "medium", or "low"
6593
- - Title: a brief one-line summary
6594
- - Description: detailed explanation of the issue
6595
- - File: the file path where the issue was found
6596
- - Line: the approximate line number in the new code (optional)
6597
- - Suggestion: suggested fix or improvement (optional, include actual code when possible)
6598
-
6599
- Return ONLY a JSON object with this structure (no markdown, no explanation before/after):
6600
- {
6601
- "summary": "Brief overall assessment of the code changes (2-3 sentences)",
6602
- "findings": [
6603
- {
6604
- "id": "f1",
6605
- "type": "bug",
6606
- "severity": "high",
6607
- "title": "Brief title",
6608
- "description": "Detailed description",
6609
- "file": "path/to/file.ts",
6610
- "line": 42,
6611
- "suggestion": "Suggested fix code"
6612
- }
6613
- ]
6614
- }
6615
-
6616
- If the code looks good with no issues, return an empty findings array with a positive summary.
6617
-
6618
- Here is the diff to review:
6619
-
6620
- \`\`\`diff
6621
- ${diff}
6622
- \`\`\``;
6623
- }
6624
- function runClaude(prompt2) {
6625
- return new Promise((resolve9, reject) => {
6626
- const child = spawn8("claude", ["-p", "--dangerously-skip-permissions", prompt2], {
6627
- stdio: ["ignore", "pipe", "pipe"]
6628
- });
6629
- let output = "";
6630
- let errOutput = "";
6631
- child.stdout?.on("data", (d) => {
6632
- output += d.toString();
6633
- });
6634
- child.stderr?.on("data", (d) => {
6635
- errOutput += d.toString();
6636
- });
6637
- child.on("exit", (code) => {
6638
- if (code === 0) resolve9(output.trim());
6639
- else reject(new Error(`claude exited with code ${code}
6640
- ${errOutput.trim()}`));
6641
- });
6642
- });
6643
- }
6644
- function parseReviewOutput(output) {
6645
- const jsonMatch = output.match(/\{[\s\S]*\}/);
6646
- if (!jsonMatch) {
6647
- return {
6648
- summary: "Failed to parse review output",
6649
- findings: []
6650
- };
6651
- }
6652
- try {
6653
- const parsed = JSON.parse(jsonMatch[0]);
6654
- return {
6655
- summary: parsed.summary || "",
6656
- findings: (parsed.findings || []).map((f) => ({
6657
- id: f.id || `f${Math.random().toString(36).slice(2, 8)}`,
6658
- type: f.type || "suggestion",
6659
- severity: f.severity || "medium",
6660
- title: f.title || "Untitled finding",
6661
- description: f.description || "",
6662
- file: f.file || "unknown",
6663
- line: f.line,
6664
- endLine: f.endLine,
6665
- suggestion: f.suggestion,
6666
- status: "new"
6667
- }))
6668
- };
6669
- } catch {
6670
- return {
6671
- summary: "Failed to parse review JSON",
6672
- findings: []
6673
- };
6674
- }
6675
- }
6676
7163
 
6677
7164
  // cli/commands/scan.ts
6678
7165
  import { Command as Command27 } from "commander";
6679
7166
 
6680
7167
  // lib/scanner/index.ts
6681
- import { spawn as spawn9 } from "child_process";
7168
+ import { spawn as spawn8 } from "child_process";
6682
7169
 
6683
7170
  // lib/scanner/config.ts
6684
7171
  import { readFileSync as readFileSync10, existsSync as existsSync15 } from "fs";
@@ -7296,7 +7783,7 @@ async function runScanPipeline(opts) {
7296
7783
  context.priorFindings,
7297
7784
  opts.customPrompt
7298
7785
  );
7299
- const synthesisResult = await runClaude2(prompt2);
7786
+ const synthesisResult = await runClaude(prompt2);
7300
7787
  const parsed = parseSynthesisOutput(synthesisResult);
7301
7788
  const scanDurationMs = Date.now() - startTime;
7302
7789
  opts.onLog(`Scan complete in ${Math.round(scanDurationMs / 1e3)}s \u2014 ${parsed.findings.length} findings`);
@@ -7357,9 +7844,9 @@ async function fetchScanContext(opts) {
7357
7844
  priorFindings
7358
7845
  };
7359
7846
  }
7360
- function runClaude2(prompt2) {
7847
+ function runClaude(prompt2) {
7361
7848
  return new Promise((resolve9, reject) => {
7362
- const child = spawn9("claude", ["-p", "--dangerously-skip-permissions", prompt2], {
7849
+ const child = spawn8("claude", ["-p", "--dangerously-skip-permissions", prompt2], {
7363
7850
  stdio: ["ignore", "pipe", "pipe"]
7364
7851
  });
7365
7852
  let output = "";