ralphctl 0.8.0 → 0.8.1

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.
package/dist/cli.mjs CHANGED
@@ -457,14 +457,32 @@ var CodexFlowRowSchema = z.object({
457
457
  effort: CodexEffortSchema.optional()
458
458
  });
459
459
  var FlowRowSchema = z.discriminatedUnion("provider", [ClaudeFlowRowSchema, CopilotFlowRowSchema, CodexFlowRowSchema]);
460
- var AiSettingsSchema = z.object({
461
- effort: GlobalEffortSchema.optional(),
462
- refine: FlowRowSchema,
463
- plan: FlowRowSchema,
464
- implement: FlowRowSchema,
465
- readiness: FlowRowSchema,
466
- ideate: FlowRowSchema
460
+ var AiImplementSchema = z.object({
461
+ generator: FlowRowSchema,
462
+ evaluator: FlowRowSchema
467
463
  });
464
+ var promoteLegacyImplementRow = (ai) => {
465
+ if (typeof ai !== "object" || ai === null) return ai;
466
+ const aiObj = ai;
467
+ const implement = aiObj["implement"];
468
+ if (typeof implement !== "object" || implement === null) return ai;
469
+ const implObj = implement;
470
+ if ("generator" in implObj || "evaluator" in implObj) return ai;
471
+ if (!("provider" in implObj)) return ai;
472
+ const promoted = { generator: implObj, evaluator: implObj };
473
+ return { ...aiObj, implement: promoted };
474
+ };
475
+ var AiSettingsSchema = z.preprocess(
476
+ promoteLegacyImplementRow,
477
+ z.object({
478
+ effort: GlobalEffortSchema.optional(),
479
+ refine: FlowRowSchema,
480
+ plan: FlowRowSchema,
481
+ implement: AiImplementSchema,
482
+ readiness: FlowRowSchema,
483
+ ideate: FlowRowSchema
484
+ })
485
+ );
468
486
  var SettingsSchema = z.object({
469
487
  /**
470
488
  * On-disk format version. Omitted in the very first format (treated as v1 by the load path
@@ -487,7 +505,22 @@ var SettingsSchema = z.object({
487
505
  * (score improvement / commit-message change / critique-prose shift) that can soften or
488
506
  * skip the plateau even when the threshold is met.
489
507
  */
490
- plateauThreshold: z.number().int().min(2).max(5).default(2)
508
+ plateauThreshold: z.number().int().min(2).max(5).default(2),
509
+ /**
510
+ * When the gen-eval loop exits on a plateau, escalate the generator's model one rung up
511
+ * the ladder defined by {@link escalationMap} (merged with the built-in
512
+ * `DEFAULT_ESCALATION_MAP`) and reissue the attempt instead of transitioning the task
513
+ * straight to `blocked`. Defaults `false` — the runtime wiring lands in a follow-up task.
514
+ */
515
+ escalateOnPlateau: z.boolean().default(false),
516
+ /**
517
+ * User overrides for the built-in `DEFAULT_ESCALATION_MAP` (in
518
+ * `business/task/escalation-map.ts`). Keys are the current model id, values the model id
519
+ * to escalate to. Empty by default; merged at read time with user keys winning on
520
+ * conflict and extending the default ladder. Non-string entries fail schema validation
521
+ * with a typed Zod error naming the offending field.
522
+ */
523
+ escalationMap: z.record(z.string(), z.string()).default({})
491
524
  }),
492
525
  logging: z.object({
493
526
  level: LogLevelSchema
@@ -522,6 +555,10 @@ var SettingsSchema = z.object({
522
555
  showEvaluatorFailureUI: z.boolean().default(false)
523
556
  }).default({ showEvaluatorFailureUI: false })
524
557
  });
558
+ var primaryFlowRow = (ai, flow) => {
559
+ if (flow === "implement") return ai.implement.generator;
560
+ return ai[flow];
561
+ };
525
562
 
526
563
  // src/business/settings/defaults.ts
527
564
  var DEFAULT_MODELS_BY_PROVIDER = {
@@ -549,18 +586,32 @@ var DEFAULT_MODELS_BY_PROVIDER = {
549
586
  };
550
587
  var defaultAiSettingsForProvider = (provider) => {
551
588
  const models = DEFAULT_MODELS_BY_PROVIDER[provider];
589
+ const implementRow = { provider, model: models.implement };
552
590
  return {
553
591
  refine: { provider, model: models.refine },
554
592
  plan: { provider, model: models.plan },
555
- implement: { provider, model: models.implement },
593
+ implement: { generator: implementRow, evaluator: implementRow },
556
594
  readiness: { provider, model: models.readiness },
557
595
  ideate: { provider, model: models.ideate }
558
596
  };
559
597
  };
560
598
  var DEFAULT_SETTINGS = {
561
599
  schemaVersion: CURRENT_SCHEMA_VERSION,
562
- ai: defaultAiSettingsForProvider("claude-code"),
563
- harness: { maxTurns: 5, maxAttempts: 3, rateLimitRetries: 3, plateauThreshold: 2 },
600
+ ai: {
601
+ ...defaultAiSettingsForProvider("claude-code"),
602
+ implement: {
603
+ generator: { provider: "claude-code", model: "claude-opus-4-7" },
604
+ evaluator: { provider: "openai-codex", model: "gpt-5.5" }
605
+ }
606
+ },
607
+ harness: {
608
+ maxTurns: 5,
609
+ maxAttempts: 3,
610
+ rateLimitRetries: 3,
611
+ plateauThreshold: 2,
612
+ escalateOnPlateau: false,
613
+ escalationMap: {}
614
+ },
564
615
  logging: { level: "info" },
565
616
  concurrency: { maxParallelTasks: 1 },
566
617
  ui: { notifications: { enabled: true } },
@@ -793,9 +844,20 @@ var createShellScriptRunner = (deps = {}) => {
793
844
  // `node_modules/` (lockfile / store mismatch). The same setting is also exposed
794
845
  // as `.npmrc:confirm-modules-purge=false` and `--config.confirm-modules-purge=false`.
795
846
  //
847
+ // NO_COLOR=1 — the well-defined cross-tool convention (https://no-color.org)
848
+ // suppresses ANSI colour codes in tool output. The harness persists script
849
+ // output verbatim to `<sprintDir>/logs/{setup,verify}/...` plain-text files;
850
+ // without this default the logs fill with `^[[1m^[[30m…` escape sequences that
851
+ // render as garbage in editors. Honoured by Node / Python / Rust / Go / Ruby
852
+ // and modern CLI tools; tools that don't recognise it ignore it harmlessly.
853
+ // Exception: JVM tools (Maven / Gradle / sbt) do NOT respect `NO_COLOR` — those
854
+ // are handled at script-authoring time by the `detect-scripts` prompt suggesting
855
+ // `mvn -B`, `gradle --console=plain`, `sbt -no-colors`.
856
+ //
796
857
  // Add more entries here as we hit narrow per-tool prompts; do NOT reach for `CI=true`.
797
- // Caller-supplied `opts.env` and the user's own env still take precedence.
798
- env: { npm_config_confirm_modules_purge: "false", ...process.env, ...opts.env }
858
+ // Defaults sit BEFORE `...process.env` so a user who exports `NO_COLOR=` (empty) or
859
+ // `FORCE_COLOR=1` can override; caller-supplied `opts.env` wins last.
860
+ env: { npm_config_confirm_modules_purge: "false", NO_COLOR: "1", ...process.env, ...opts.env }
799
861
  });
800
862
  } catch (cause) {
801
863
  resolve(
@@ -1411,7 +1473,14 @@ var SprintExecutionSchema = z7.object({
1411
1473
  sprintId: SprintIdSchema,
1412
1474
  branch: z7.union([z7.string(), z7.null()]),
1413
1475
  pullRequestUrl: z7.union([HttpUrlSchema, z7.null()]),
1414
- setupRanAt: z7.array(SetupRunSchema).readonly()
1476
+ setupRanAt: z7.array(SetupRunSchema).readonly(),
1477
+ /**
1478
+ * Optional. Stamped `'proceed'` by `pre-task-verify` when the operator opts to continue on a
1479
+ * red baseline; cleared back to undefined on the next green pre-verify. Files written by
1480
+ * ralphctl ≤ 0.7.0 simply lack the field — Zod accepts the absence as `undefined`, no
1481
+ * `schemaVersion` bump or migration step needed.
1482
+ */
1483
+ baselineBrokenPolicy: z7.literal("proceed").optional()
1415
1484
  });
1416
1485
  var fromJsonSprintExecution = (input, filePath = "execution.json") => runMigrations(
1417
1486
  input,
@@ -1829,7 +1898,9 @@ var TaskBaseShape = {
1829
1898
  attempts: z13.array(AttemptSchema).readonly(),
1830
1899
  maxAttempts: z13.number().optional(),
1831
1900
  extraDimensions: z13.array(z13.string()).readonly().optional(),
1832
- externalRefs: z13.array(z13.string()).readonly().optional()
1901
+ externalRefs: z13.array(z13.string()).readonly().optional(),
1902
+ escalatedFromModel: z13.string().optional(),
1903
+ escalatedToModel: z13.string().optional()
1833
1904
  };
1834
1905
  var TodoTaskSchema = z13.object({ ...TaskBaseShape, status: z13.literal("todo") });
1835
1906
  var InProgressTaskSchema = z13.object({ ...TaskBaseShape, status: z13.literal("in_progress") });
@@ -2398,6 +2469,83 @@ var contextWindowFor = (model) => {
2398
2469
  return CONTEXT_WINDOW[model];
2399
2470
  };
2400
2471
 
2472
+ // src/domain/value/error/abort-error.ts
2473
+ var AbortError = class extends Error {
2474
+ code = ErrorCode.Aborted;
2475
+ elementName;
2476
+ reason;
2477
+ constructor(opts) {
2478
+ super(opts.reason ?? `operation aborted at step '${opts.elementName}'`);
2479
+ this.name = "AbortError";
2480
+ this.elementName = opts.elementName;
2481
+ if (opts.reason !== void 0) {
2482
+ this.reason = opts.reason;
2483
+ }
2484
+ }
2485
+ };
2486
+
2487
+ // src/integration/ai/providers/_engine/classify-spawn-exit.ts
2488
+ var classifySpawnExit = async (input) => {
2489
+ const { session, exit, stderr, rateLimitRe, capturedSessionId, providerName, eventBus, watchdogBannerId, onSuccess } = input;
2490
+ if (session.abortSignal?.aborted === true) {
2491
+ return {
2492
+ kind: "error",
2493
+ error: new AbortError({
2494
+ elementName: providerName,
2495
+ reason: `${providerName}: aborted by caller`
2496
+ })
2497
+ };
2498
+ }
2499
+ if (exit.code === 0) {
2500
+ return await onSuccess();
2501
+ }
2502
+ if (rateLimitRe.test(stderr)) {
2503
+ return {
2504
+ kind: "rate-limit",
2505
+ error: new RateLimitError({
2506
+ subCode: "spawn-stderr",
2507
+ message: `${providerName}: rate-limit detected in stderr (exit ${String(exit.code)})`,
2508
+ ...capturedSessionId !== void 0 ? { sessionId: capturedSessionId } : {}
2509
+ })
2510
+ };
2511
+ }
2512
+ const exists = await pathExists(String(session.signalsFile));
2513
+ if (exists.ok && exists.value) {
2514
+ eventBus.publish({
2515
+ type: "log",
2516
+ level: "warn",
2517
+ message: `${providerName}: non-zero exit (code=${String(exit.code)}, signal=${String(exit.signal ?? "null")}) but signals.json captured \u2014 preserving work`,
2518
+ meta: { code: exit.code, signal: exit.signal, providerName },
2519
+ at: IsoTimestamp.now()
2520
+ });
2521
+ eventBus.publish({
2522
+ type: "banner-clear",
2523
+ id: watchdogBannerId,
2524
+ at: IsoTimestamp.now()
2525
+ });
2526
+ const outcome = await onSuccess();
2527
+ if (outcome.kind === "success") {
2528
+ return {
2529
+ kind: "success",
2530
+ output: {
2531
+ ...outcome.output,
2532
+ recoveredFromExit: { code: exit.code, signal: exit.signal }
2533
+ }
2534
+ };
2535
+ }
2536
+ return outcome;
2537
+ }
2538
+ return {
2539
+ kind: "error",
2540
+ error: new InvalidStateError({
2541
+ entity: providerName,
2542
+ currentState: `exit-${String(exit.code ?? "null")}`,
2543
+ attemptedAction: "complete generation",
2544
+ message: `${providerName}: process exited with code ${String(exit.code)}${exit.signal !== null ? ` (signal=${exit.signal})` : ""}: ${stderr.trim() || "<empty stderr>"}`
2545
+ })
2546
+ };
2547
+ };
2548
+
2401
2549
  // src/integration/ai/providers/claude/headless.ts
2402
2550
  var RATE_LIMIT_RE = /rate.?limit/i;
2403
2551
  var TOOL_EDIT = ["Edit", "MultiEdit", "NotebookEdit"];
@@ -2517,6 +2665,7 @@ var spawnAttempt = async (input) => {
2517
2665
  const onLine = (line) => {
2518
2666
  parser.ingest(line);
2519
2667
  };
2668
+ const watchdogBannerId = `watchdog-claude-${String(child.pid ?? "unknown")}`;
2520
2669
  const { code, signal } = await runHeadlessSpawn({
2521
2670
  child,
2522
2671
  onStdout: (chunk) => parser.feed(chunk, onLine),
@@ -2538,7 +2687,7 @@ var spawnAttempt = async (input) => {
2538
2687
  });
2539
2688
  deps.eventBus.publish({
2540
2689
  type: "banner-show",
2541
- id: `watchdog-claude-${String(child.pid ?? "unknown")}`,
2690
+ id: watchdogBannerId,
2542
2691
  tier: "warn",
2543
2692
  message: `Watchdog killed stuck claude process${idleMs !== void 0 ? ` (${String(Math.round(idleMs / 1e3))}s idle)` : ""}`,
2544
2693
  at: IsoTimestamp.now()
@@ -2546,19 +2695,8 @@ var spawnAttempt = async (input) => {
2546
2695
  }
2547
2696
  });
2548
2697
  parser.flush(onLine);
2549
- if (signal === "SIGTERM") {
2550
- return {
2551
- kind: "error",
2552
- error: new InvalidStateError({
2553
- entity: "claude-provider",
2554
- currentState: "terminated",
2555
- attemptedAction: "complete generation",
2556
- message: "claude-provider: process terminated via SIGTERM"
2557
- })
2558
- };
2559
- }
2560
2698
  const envelope = parser.snapshot();
2561
- if (code === 0) {
2699
+ const onSuccess = async () => {
2562
2700
  if (envelope.sessionId !== void 0) {
2563
2701
  deps.eventBus.publish({
2564
2702
  type: "log",
@@ -2578,6 +2716,7 @@ var spawnAttempt = async (input) => {
2578
2716
  ...envelope.usage.cacheReadTokens !== void 0 ? { cacheReadTokens: envelope.usage.cacheReadTokens } : {},
2579
2717
  ...envelope.usage.cacheCreationTokens !== void 0 ? { cacheCreationTokens: envelope.usage.cacheCreationTokens } : {},
2580
2718
  ...window !== void 0 ? { contextWindow: window } : {},
2719
+ ...session.role !== void 0 ? { role: session.role } : {},
2581
2720
  at: IsoTimestamp.now()
2582
2721
  });
2583
2722
  }
@@ -2607,30 +2746,22 @@ var spawnAttempt = async (input) => {
2607
2746
  kind: "success",
2608
2747
  output: {
2609
2748
  signalsFile: session.signalsFile,
2610
- exitCode: code,
2749
+ exitCode: code ?? 0,
2611
2750
  ...envelope.sessionId !== void 0 ? { sessionId: envelope.sessionId } : {}
2612
2751
  }
2613
2752
  };
2614
- }
2615
- if (RATE_LIMIT_RE.test(stderrBuf)) {
2616
- return {
2617
- kind: "rate-limit",
2618
- error: new RateLimitError({
2619
- subCode: "spawn-stderr",
2620
- message: `claude-provider: rate-limit detected in stderr (exit ${String(code)})`,
2621
- ...envelope.sessionId !== void 0 ? { sessionId: envelope.sessionId } : {}
2622
- })
2623
- };
2624
- }
2625
- return {
2626
- kind: "error",
2627
- error: new InvalidStateError({
2628
- entity: "claude-provider",
2629
- currentState: `exit-${String(code)}`,
2630
- attemptedAction: "complete generation",
2631
- message: `claude-provider: process exited with code ${String(code)}: ${stderrBuf.trim() || "<empty stderr>"}`
2632
- })
2633
2753
  };
2754
+ return classifySpawnExit({
2755
+ session,
2756
+ exit: { code, signal },
2757
+ stderr: stderrBuf,
2758
+ rateLimitRe: RATE_LIMIT_RE,
2759
+ ...envelope.sessionId !== void 0 ? { capturedSessionId: envelope.sessionId } : {},
2760
+ providerName: "claude-provider",
2761
+ eventBus: deps.eventBus,
2762
+ watchdogBannerId,
2763
+ onSuccess
2764
+ });
2634
2765
  };
2635
2766
  var defaultSpawn3 = (command, args, options) => nodeSpawn3(command, [...args], {
2636
2767
  stdio: [...options.stdio],
@@ -2811,6 +2942,7 @@ var spawnAttempt2 = async (input) => {
2811
2942
  let stderrBuf = "";
2812
2943
  let sessionId2;
2813
2944
  let stdoutLineBuf = "";
2945
+ const watchdogBannerId = `watchdog-codex-${String(child.pid ?? "unknown")}`;
2814
2946
  let model;
2815
2947
  let inputTokens;
2816
2948
  let outputTokens;
@@ -2853,25 +2985,14 @@ var spawnAttempt2 = async (input) => {
2853
2985
  });
2854
2986
  deps.eventBus.publish({
2855
2987
  type: "banner-show",
2856
- id: `watchdog-codex-${String(child.pid ?? "unknown")}`,
2988
+ id: watchdogBannerId,
2857
2989
  tier: "warn",
2858
2990
  message: `Watchdog killed stuck codex process${idleMs !== void 0 ? ` (${String(Math.round(idleMs / 1e3))}s idle)` : ""}`,
2859
2991
  at: IsoTimestamp.now()
2860
2992
  });
2861
2993
  }
2862
2994
  });
2863
- if (signal === "SIGTERM") {
2864
- return {
2865
- kind: "error",
2866
- error: new InvalidStateError({
2867
- entity: "codex-provider",
2868
- currentState: "terminated",
2869
- attemptedAction: "complete generation",
2870
- message: "codex-provider: process terminated via SIGTERM"
2871
- })
2872
- };
2873
- }
2874
- if (code === 0) {
2995
+ const onSuccess = async () => {
2875
2996
  let body;
2876
2997
  try {
2877
2998
  body = await readFile2(outputFile);
@@ -2918,6 +3039,7 @@ var spawnAttempt2 = async (input) => {
2918
3039
  ...inputTokens !== void 0 ? { inputTokens } : {},
2919
3040
  ...outputTokens !== void 0 ? { outputTokens } : {},
2920
3041
  ...window !== void 0 ? { contextWindow: window } : {},
3042
+ ...session.role !== void 0 ? { role: session.role } : {},
2921
3043
  at: IsoTimestamp.now()
2922
3044
  });
2923
3045
  }
@@ -2925,30 +3047,22 @@ var spawnAttempt2 = async (input) => {
2925
3047
  kind: "success",
2926
3048
  output: {
2927
3049
  signalsFile: session.signalsFile,
2928
- exitCode: code,
3050
+ exitCode: code ?? 0,
2929
3051
  ...sessionId2 !== void 0 ? { sessionId: sessionId2 } : {}
2930
3052
  }
2931
3053
  };
2932
- }
2933
- if (RATE_LIMIT_RE2.test(stderrBuf)) {
2934
- return {
2935
- kind: "rate-limit",
2936
- error: new RateLimitError({
2937
- subCode: "spawn-stderr",
2938
- message: `codex-provider: rate-limit detected in stderr (exit ${String(code)})`,
2939
- ...sessionId2 !== void 0 ? { sessionId: sessionId2 } : {}
2940
- })
2941
- };
2942
- }
2943
- return {
2944
- kind: "error",
2945
- error: new InvalidStateError({
2946
- entity: "codex-provider",
2947
- currentState: `exit-${String(code)}`,
2948
- attemptedAction: "complete generation",
2949
- message: `codex-provider: process exited with code ${String(code)}: ${stderrBuf.trim() || "<empty stderr>"}`
2950
- })
2951
3054
  };
3055
+ return classifySpawnExit({
3056
+ session,
3057
+ exit: { code, signal },
3058
+ stderr: stderrBuf,
3059
+ rateLimitRe: RATE_LIMIT_RE2,
3060
+ ...sessionId2 !== void 0 ? { capturedSessionId: sessionId2 } : {},
3061
+ providerName: "codex-provider",
3062
+ eventBus: deps.eventBus,
3063
+ watchdogBannerId,
3064
+ onSuccess
3065
+ });
2952
3066
  };
2953
3067
  var defaultSpawn4 = (command, args, options) => nodeSpawn4(command, [...args], {
2954
3068
  stdio: [...options.stdio],
@@ -3177,6 +3291,7 @@ var spawnAttempt3 = async (input) => {
3177
3291
  let model;
3178
3292
  let usage = {};
3179
3293
  let stderrBuf = "";
3294
+ const watchdogBannerId = `watchdog-copilot-${String(child.pid ?? "unknown")}`;
3180
3295
  const onLine = (line) => {
3181
3296
  if (line.json !== void 0) {
3182
3297
  if (line.sessionId !== void 0 && sessionId2 === void 0) {
@@ -3231,7 +3346,7 @@ var spawnAttempt3 = async (input) => {
3231
3346
  });
3232
3347
  deps.eventBus.publish({
3233
3348
  type: "banner-show",
3234
- id: `watchdog-copilot-${String(child.pid ?? "unknown")}`,
3349
+ id: watchdogBannerId,
3235
3350
  tier: "warn",
3236
3351
  message: `Watchdog killed stuck copilot process${idleMs !== void 0 ? ` (${String(Math.round(idleMs / 1e3))}s idle)` : ""}`,
3237
3352
  at: IsoTimestamp.now()
@@ -3239,18 +3354,7 @@ var spawnAttempt3 = async (input) => {
3239
3354
  }
3240
3355
  });
3241
3356
  parser.flush(onLine);
3242
- if (signal === "SIGTERM") {
3243
- return {
3244
- kind: "error",
3245
- error: new InvalidStateError({
3246
- entity: "copilot-provider",
3247
- currentState: "terminated",
3248
- attemptedAction: "complete generation",
3249
- message: "copilot-provider: process terminated via SIGTERM"
3250
- })
3251
- };
3252
- }
3253
- if (code === 0) {
3357
+ const onSuccess = async () => {
3254
3358
  const forensicBody = events.map((e) => e.text).join("\n");
3255
3359
  if (session.bodyFile !== void 0) {
3256
3360
  const bodyWrote = await writeTextAtomic(String(session.bodyFile), forensicBody);
@@ -3274,6 +3378,7 @@ var spawnAttempt3 = async (input) => {
3274
3378
  ...usage.inputTokens !== void 0 ? { inputTokens: usage.inputTokens } : {},
3275
3379
  ...usage.outputTokens !== void 0 ? { outputTokens: usage.outputTokens } : {},
3276
3380
  ...window !== void 0 ? { contextWindow: window } : {},
3381
+ ...session.role !== void 0 ? { role: session.role } : {},
3277
3382
  at: IsoTimestamp.now()
3278
3383
  });
3279
3384
  }
@@ -3291,30 +3396,22 @@ var spawnAttempt3 = async (input) => {
3291
3396
  kind: "success",
3292
3397
  output: {
3293
3398
  signalsFile: session.signalsFile,
3294
- exitCode: code,
3399
+ exitCode: code ?? 0,
3295
3400
  ...sessionId2 !== void 0 ? { sessionId: sessionId2 } : {}
3296
3401
  }
3297
3402
  };
3298
- }
3299
- if (RATE_LIMIT_RE3.test(stderrBuf)) {
3300
- return {
3301
- kind: "rate-limit",
3302
- error: new RateLimitError({
3303
- subCode: "spawn-stderr",
3304
- message: `copilot-provider: rate-limit detected in stderr (exit ${String(code)})`,
3305
- ...sessionId2 !== void 0 ? { sessionId: sessionId2 } : {}
3306
- })
3307
- };
3308
- }
3309
- return {
3310
- kind: "error",
3311
- error: new InvalidStateError({
3312
- entity: "copilot-provider",
3313
- currentState: `exit-${String(code)}`,
3314
- attemptedAction: "complete generation",
3315
- message: `copilot-provider: process exited with code ${String(code)}: ${stderrBuf.trim() || "<empty stderr>"}`
3316
- })
3317
3403
  };
3404
+ return classifySpawnExit({
3405
+ session,
3406
+ exit: { code, signal },
3407
+ stderr: stderrBuf,
3408
+ rateLimitRe: RATE_LIMIT_RE3,
3409
+ ...sessionId2 !== void 0 ? { capturedSessionId: sessionId2 } : {},
3410
+ providerName: "copilot-provider",
3411
+ eventBus: deps.eventBus,
3412
+ watchdogBannerId,
3413
+ onSuccess
3414
+ });
3318
3415
  };
3319
3416
  var defaultSpawn5 = (command, args, options) => nodeSpawn5(command, [...args], {
3320
3417
  stdio: [...options.stdio],
@@ -3322,8 +3419,12 @@ var defaultSpawn5 = (command, args, options) => nodeSpawn5(command, [...args], {
3322
3419
  });
3323
3420
 
3324
3421
  // src/application/bootstrap/provider-factory.ts
3422
+ var resolveRow = (deps) => {
3423
+ if ("row" in deps) return deps.row;
3424
+ return primaryFlowRow(deps.ai, deps.flow);
3425
+ };
3325
3426
  var createAiProvider = (deps) => {
3326
- const row = deps.ai[deps.flow];
3427
+ const row = resolveRow(deps);
3327
3428
  switch (row.provider) {
3328
3429
  case "claude-code":
3329
3430
  return createClaudeProvider({
@@ -3614,7 +3715,7 @@ var stringifyError6 = (cause) => cause instanceof Error ? cause.message : String
3614
3715
 
3615
3716
  // src/application/bootstrap/interactive-provider-factory.ts
3616
3717
  var createInteractiveAiProvider = (deps) => {
3617
- const row = deps.ai[deps.flow];
3718
+ const row = primaryFlowRow(deps.ai, deps.flow);
3618
3719
  switch (row.provider) {
3619
3720
  case "claude-code":
3620
3721
  return createInteractiveClaudeProvider({ eventBus: deps.eventBus });
@@ -4360,9 +4461,9 @@ var claudeProbe = {
4360
4461
  };
4361
4462
  var readHooks = async (settingsRefs) => {
4362
4463
  const hooks = [];
4363
- for (const ref2 of settingsRefs) {
4364
- if (ref2 === void 0) continue;
4365
- const text = await readFileSafely(ref2.path);
4464
+ for (const ref3 of settingsRefs) {
4465
+ if (ref3 === void 0) continue;
4466
+ const text = await readFileSafely(ref3.path);
4366
4467
  if (!text.ok) return Result.error(text.error);
4367
4468
  if (text.value === void 0) continue;
4368
4469
  let parsed;
@@ -4370,7 +4471,7 @@ var readHooks = async (settingsRefs) => {
4370
4471
  parsed = JSON.parse(text.value);
4371
4472
  } catch (cause) {
4372
4473
  return Result.error(
4373
- new ProbeError({ subCode: "malformed", message: `${ref2.path} is not valid JSON`, path: ref2.path, cause })
4474
+ new ProbeError({ subCode: "malformed", message: `${ref3.path} is not valid JSON`, path: ref3.path, cause })
4374
4475
  );
4375
4476
  }
4376
4477
  extractHooks(parsed, hooks);
@@ -4583,7 +4684,7 @@ var createNpmVersionChecker = (deps) => {
4583
4684
  // package.json
4584
4685
  var package_default = {
4585
4686
  name: "ralphctl",
4586
- version: "0.8.0",
4687
+ version: "0.8.1",
4587
4688
  description: "Agent harness for long-running AI coding tasks \u2014 orchestrates Claude Code, GitHub Copilot, and OpenAI Codex across repositories",
4588
4689
  homepage: "https://github.com/lukas-grigis/ralphctl",
4589
4690
  type: "module",
@@ -4682,6 +4783,27 @@ var CLI_METADATA = {
4682
4783
  currentVersion: package_default.version
4683
4784
  };
4684
4785
 
4786
+ // src/business/task/escalation-map.ts
4787
+ var DEFAULT_ESCALATION_MAP = {
4788
+ // Claude — Sonnet escalates to Opus; Haiku escalates to Sonnet.
4789
+ "claude-haiku-4-5": "claude-sonnet-4-6",
4790
+ "claude-sonnet-4-6": "claude-opus-4-7",
4791
+ // Copilot/Codex — mini variants step up to their full-tier frontier.
4792
+ "gpt-5-mini": "gpt-5.5",
4793
+ "gpt-5.4-mini": "gpt-5.5"
4794
+ };
4795
+ var mergeEscalationMap = (user) => ({
4796
+ ...DEFAULT_ESCALATION_MAP,
4797
+ ...user
4798
+ });
4799
+ var warnEscalationMapSelfLoops = (escalationMap, logger) => {
4800
+ for (const [from, to] of Object.entries(escalationMap)) {
4801
+ if (from === to) {
4802
+ logger.warn(`escalationMap: '${from}' maps to itself \u2014 entry has no effect`, { from, to });
4803
+ }
4804
+ }
4805
+ };
4806
+
4685
4807
  // src/integration/ai/skills/_engine/filesystem-skills-adapter.ts
4686
4808
  import { existsSync } from "fs";
4687
4809
  import { mkdir, rm, rmdir, writeFile } from "fs/promises";
@@ -4928,11 +5050,36 @@ var SkillFrontmatterSchema = z16.object({
4928
5050
 
4929
5051
  // src/integration/ai/skills/_engine/registry.ts
4930
5052
  var FLOW_SKILLS = {
4931
- refine: ["ralphctl-alignment", "ralphctl-iterative-review", "ralphctl-abstraction-first"],
4932
- plan: ["ralphctl-alignment", "ralphctl-iterative-review", "ralphctl-abstraction-first"],
4933
- implement: ["ralphctl-alignment", "ralphctl-iterative-review", "ralphctl-abstraction-first"],
4934
- readiness: ["ralphctl-alignment", "ralphctl-iterative-review", "ralphctl-abstraction-first"],
4935
- ideate: ["ralphctl-alignment", "ralphctl-iterative-review", "ralphctl-abstraction-first"]
5053
+ refine: [
5054
+ "ralphctl-alignment",
5055
+ "ralphctl-iterative-review",
5056
+ "ralphctl-abstraction-first",
5057
+ "ralphctl-minimal-scaffolding"
5058
+ ],
5059
+ plan: [
5060
+ "ralphctl-alignment",
5061
+ "ralphctl-iterative-review",
5062
+ "ralphctl-abstraction-first",
5063
+ "ralphctl-minimal-scaffolding"
5064
+ ],
5065
+ implement: [
5066
+ "ralphctl-alignment",
5067
+ "ralphctl-iterative-review",
5068
+ "ralphctl-abstraction-first",
5069
+ "ralphctl-minimal-scaffolding"
5070
+ ],
5071
+ readiness: [
5072
+ "ralphctl-alignment",
5073
+ "ralphctl-iterative-review",
5074
+ "ralphctl-abstraction-first",
5075
+ "ralphctl-minimal-scaffolding"
5076
+ ],
5077
+ ideate: [
5078
+ "ralphctl-alignment",
5079
+ "ralphctl-iterative-review",
5080
+ "ralphctl-abstraction-first",
5081
+ "ralphctl-minimal-scaffolding"
5082
+ ]
4936
5083
  };
4937
5084
  var skillsForFlow = (flowId) => FLOW_SKILLS[flowId];
4938
5085
 
@@ -5170,6 +5317,7 @@ var wire = (opts) => {
5170
5317
  const chainLogSink = debugTrace ? (launchDeps) => startFileLogSink({ ...launchDeps, appendFile }) : () => NOOP_CHAIN_LOG_SINK;
5171
5318
  const eventBus = createInMemoryEventBus();
5172
5319
  const logger = createEventBusLogger({ eventBus, clock: IsoTimestamp.now });
5320
+ warnEscalationMapSelfLoops(opts.settings.harness.escalationMap, logger);
5173
5321
  const notificationDispatcher = opts.notificationDispatcher ?? noopNotificationDispatcher;
5174
5322
  const fileLocker = createFileLocker({
5175
5323
  // Surface stale `.lock` files via the application logger. The locker is intentionally
@@ -5223,7 +5371,7 @@ var wire = (opts) => {
5223
5371
  // Wire-time seed — the per-launch launcher rebuilds skillsAdapter from the dispatched
5224
5372
  // flow's provider. Tests / one-shot CLI paths that read `app.skillsAdapter` before any
5225
5373
  // flow launches get the implement row's provider as the default.
5226
- skillsAdapter: createSkillsAdapter({ provider: opts.settings.ai.implement.provider, logger }),
5374
+ skillsAdapter: createSkillsAdapter({ provider: opts.settings.ai.implement.generator.provider, logger }),
5227
5375
  skillSource: createBundledSkillSource(),
5228
5376
  notificationDispatcher,
5229
5377
  chainLogSink
@@ -5340,7 +5488,9 @@ var createSessionManager = (opts) => {
5340
5488
  plannedLeaves,
5341
5489
  planLabelByName,
5342
5490
  terminalSubstepName,
5343
- taskRecovering
5491
+ taskRecovering,
5492
+ generatorModel,
5493
+ evaluatorModel
5344
5494
  }) {
5345
5495
  evict(clock());
5346
5496
  const descriptor = {
@@ -5355,7 +5505,9 @@ var createSessionManager = (opts) => {
5355
5505
  ...plannedLeaves !== void 0 ? { plannedLeaves } : {},
5356
5506
  ...planLabelByName !== void 0 ? { planLabelByName } : {},
5357
5507
  ...terminalSubstepName !== void 0 ? { terminalSubstepName } : {},
5358
- ...taskRecovering !== void 0 ? { taskRecovering } : {}
5508
+ ...taskRecovering !== void 0 ? { taskRecovering } : {},
5509
+ ...generatorModel !== void 0 ? { generatorModel } : {},
5510
+ ...evaluatorModel !== void 0 ? { evaluatorModel } : {}
5359
5511
  };
5360
5512
  const record = { descriptor, runner };
5361
5513
  records.set(runner.id, record);
@@ -5491,21 +5643,6 @@ var createPromptQueue = () => {
5491
5643
  };
5492
5644
  };
5493
5645
 
5494
- // src/domain/value/error/abort-error.ts
5495
- var AbortError = class extends Error {
5496
- code = ErrorCode.Aborted;
5497
- elementName;
5498
- reason;
5499
- constructor(opts) {
5500
- super(opts.reason ?? `operation aborted at step '${opts.elementName}'`);
5501
- this.name = "AbortError";
5502
- this.elementName = opts.elementName;
5503
- if (opts.reason !== void 0) {
5504
- this.reason = opts.reason;
5505
- }
5506
- }
5507
- };
5508
-
5509
5646
  // src/application/ui/tui/prompts/ink-interactive-prompt.ts
5510
5647
  var wrapError = (err, elementName) => new AbortError({ elementName, reason: err instanceof Error ? err.message : "prompt cancelled" });
5511
5648
  var createInkInteractivePrompt = (queue) => ({
@@ -5636,6 +5773,13 @@ var setRunInTerminal = (next) => {
5636
5773
  };
5637
5774
  var getRunInTerminal = () => (fn) => ref.current(fn);
5638
5775
 
5776
+ // src/application/ui/tui/runtime/implement-role-overrides.ts
5777
+ var ref2 = { current: void 0 };
5778
+ var setImplementRoleOverrides = (next) => {
5779
+ ref2.current = next;
5780
+ };
5781
+ var getImplementRoleOverrides = () => ref2.current;
5782
+
5639
5783
  // src/application/ui/tui/App.tsx
5640
5784
  import "react";
5641
5785
  import { Box as Box59 } from "ink";
@@ -6412,11 +6556,14 @@ var createDoctorFlow = (deps) => leaf("doctor", {
6412
6556
  );
6413
6557
  }
6414
6558
  const settings = await deps.settingsRepo.load();
6415
- const configuredProviders = settings.ok ? new Set(
6416
- ["refine", "plan", "implement", "readiness", "ideate"].map(
6417
- (f) => settings.value.ai[f].provider
6418
- )
6419
- ) : /* @__PURE__ */ new Set();
6559
+ const configuredProviders = settings.ok ? /* @__PURE__ */ new Set([
6560
+ settings.value.ai.refine.provider,
6561
+ settings.value.ai.plan.provider,
6562
+ settings.value.ai.implement.generator.provider,
6563
+ settings.value.ai.implement.evaluator.provider,
6564
+ settings.value.ai.readiness.provider,
6565
+ settings.value.ai.ideate.provider
6566
+ ]) : /* @__PURE__ */ new Set();
6420
6567
  let codexInstalled = false;
6421
6568
  for (const provider of Object.keys(PROVIDER_BINARY)) {
6422
6569
  const binary = PROVIDER_BINARY[provider];
@@ -6679,7 +6826,7 @@ var LogLevelProvider = ({
6679
6826
  import "react";
6680
6827
 
6681
6828
  // src/application/ui/tui/views/home-view.tsx
6682
- import { useCallback as useCallback8, useEffect as useEffect14, useMemo as useMemo8, useRef as useRef7, useState as useState23 } from "react";
6829
+ import { useCallback as useCallback8, useEffect as useEffect15, useMemo as useMemo8, useRef as useRef8, useState as useState23 } from "react";
6683
6830
  import { Box as Box23, Text as Text24, useInput as useInput12 } from "ink";
6684
6831
 
6685
6832
  // src/application/ui/tui/components/view-shell.tsx
@@ -7359,10 +7506,12 @@ var ScrollRegion = ({ children, disabled = false }) => {
7359
7506
  );
7360
7507
  useEffect8(() => {
7361
7508
  if (!isRawModeSupported || !stdin || !stdout || !stdout.isTTY) return void 0;
7509
+ if (disabled) return void 0;
7362
7510
  const enable = "\x1B[?1000h\x1B[?1006h";
7363
7511
  const disableSeq = "\x1B[?1006l\x1B[?1000l";
7364
7512
  stdout.write(enable);
7365
7513
  const onData = (chunk) => {
7514
+ if (disabled) return;
7366
7515
  const str = chunk.toString("utf8");
7367
7516
  const re = /\x1b\[<(\d+);\d+;\d+([Mm])/g;
7368
7517
  let match;
@@ -7381,7 +7530,7 @@ var ScrollRegion = ({ children, disabled = false }) => {
7381
7530
  stdin.off("data", onData);
7382
7531
  stdout.write(disableSeq);
7383
7532
  };
7384
- }, [stdin, stdout, isRawModeSupported]);
7533
+ }, [stdin, stdout, isRawModeSupported, disabled]);
7385
7534
  return (
7386
7535
  // Viewport: takes all remaining vertical space (flexGrow=1) AND clips overflow so an
7387
7536
  // oversized inner box can't push the status bar off-screen.
@@ -7824,8 +7973,33 @@ import { Box as Box13, Text as Text14, useInput as useInput7 } from "ink";
7824
7973
  import { jsx as jsx24, jsxs as jsxs13 } from "react/jsx-runtime";
7825
7974
  var VISIBLE_ROWS = 8;
7826
7975
  var clamp = (n, min, max) => Math.max(min, Math.min(max, n));
7827
- var SelectPrompt = ({ message, options, onSubmit, onCancel }) => {
7828
- const [cursor, setCursor] = useState16(0);
7976
+ var isEnabled = (opt) => opt !== void 0 && opt.disabled !== true;
7977
+ var nextEnabledIndex = (options, from, direction) => {
7978
+ for (let i = from + direction; i >= 0 && i < options.length; i += direction) {
7979
+ if (isEnabled(options[i])) return i;
7980
+ }
7981
+ return from;
7982
+ };
7983
+ var firstEnabledIndex = (options) => {
7984
+ for (let i = 0; i < options.length; i += 1) {
7985
+ if (isEnabled(options[i])) return i;
7986
+ }
7987
+ return 0;
7988
+ };
7989
+ var lastEnabledIndex = (options) => {
7990
+ for (let i = options.length - 1; i >= 0; i -= 1) {
7991
+ if (isEnabled(options[i])) return i;
7992
+ }
7993
+ return Math.max(0, options.length - 1);
7994
+ };
7995
+ var SelectPrompt = ({
7996
+ message,
7997
+ options,
7998
+ onSubmit,
7999
+ onCancel,
8000
+ footer
8001
+ }) => {
8002
+ const [cursor, setCursor] = useState16(() => firstEnabledIndex(options));
7829
8003
  useInput7((input, key) => {
7830
8004
  if (key.escape) {
7831
8005
  onCancel();
@@ -7833,13 +8007,14 @@ var SelectPrompt = ({ message, options, onSubmit, onCancel }) => {
7833
8007
  }
7834
8008
  if (key.return || input === " ") {
7835
8009
  const opt = options[cursor];
7836
- if (opt !== void 0) onSubmit(opt.value);
8010
+ if (opt !== void 0 && opt.disabled !== true) onSubmit(opt.value);
7837
8011
  return;
7838
8012
  }
7839
- if (key.upArrow || input === "k") setCursor((c) => clamp(c - 1, 0, options.length - 1));
7840
- else if (key.downArrow || input === "j") setCursor((c) => clamp(c + 1, 0, options.length - 1));
7841
- else if (input === "g") setCursor(0);
7842
- else if (input === "G") setCursor(options.length - 1);
8013
+ if (key.upArrow || input === "k") setCursor((c) => clamp(nextEnabledIndex(options, c, -1), 0, options.length - 1));
8014
+ else if (key.downArrow || input === "j")
8015
+ setCursor((c) => clamp(nextEnabledIndex(options, c, 1), 0, options.length - 1));
8016
+ else if (input === "g") setCursor(firstEnabledIndex(options));
8017
+ else if (input === "G") setCursor(lastEnabledIndex(options));
7843
8018
  });
7844
8019
  const half = Math.floor(VISIBLE_ROWS / 2);
7845
8020
  const start = clamp(cursor - half, 0, Math.max(0, options.length - VISIBLE_ROWS));
@@ -7849,12 +8024,13 @@ var SelectPrompt = ({ message, options, onSubmit, onCancel }) => {
7849
8024
  /* @__PURE__ */ jsx24(Box13, { flexDirection: "column", marginTop: 1, children: options.slice(start, end).map((opt, localIdx) => {
7850
8025
  const i = start + localIdx;
7851
8026
  const focused = i === cursor;
8027
+ const disabled = opt.disabled === true;
7852
8028
  return /* @__PURE__ */ jsxs13(Box13, { children: [
7853
- /* @__PURE__ */ jsxs13(Text14, { color: focused ? inkColors.primary : inkColors.muted, children: [
7854
- focused ? glyphs.actionCursor : " ",
8029
+ /* @__PURE__ */ jsxs13(Text14, { color: focused && !disabled ? inkColors.primary : inkColors.muted, children: [
8030
+ focused && !disabled ? glyphs.actionCursor : " ",
7855
8031
  " "
7856
8032
  ] }),
7857
- /* @__PURE__ */ jsx24(Text14, { bold: focused, children: opt.label }),
8033
+ /* @__PURE__ */ jsx24(Text14, { bold: focused && !disabled, dimColor: disabled, children: opt.label }),
7858
8034
  opt.description !== void 0 && /* @__PURE__ */ jsxs13(Text14, { dimColor: true, children: [
7859
8035
  " ",
7860
8036
  glyphs.emDash,
@@ -7868,6 +8044,7 @@ var SelectPrompt = ({ message, options, onSubmit, onCancel }) => {
7868
8044
  " of ",
7869
8045
  String(options.length)
7870
8046
  ] }),
8047
+ footer !== void 0 && /* @__PURE__ */ jsx24(Text14, { dimColor: true, children: footer }),
7871
8048
  /* @__PURE__ */ jsx24(Text14, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 \u21B5 submit \xB7 esc cancel" })
7872
8049
  ] });
7873
8050
  };
@@ -8116,11 +8293,11 @@ var Card = ({ title, tone = "rule", dim, right, children }) => {
8116
8293
  import { useEffect as useEffect12, useState as useState19 } from "react";
8117
8294
  import { Box as Box18, Text as Text18, useInput as useInput9 } from "ink";
8118
8295
  import { jsx as jsx29, jsxs as jsxs18 } from "react/jsx-runtime";
8119
- var isEnabled = (item) => item !== void 0 && item.disabledReason === void 0;
8296
+ var isEnabled2 = (item) => item !== void 0 && item.disabledReason === void 0;
8120
8297
  var findFirstEnabled = (items, from, dir) => {
8121
8298
  let i = from;
8122
8299
  while (i >= 0 && i < items.length) {
8123
- if (isEnabled(items[i])) return i;
8300
+ if (isEnabled2(items[i])) return i;
8124
8301
  i += dir;
8125
8302
  }
8126
8303
  return null;
@@ -8133,7 +8310,7 @@ var ActionMenu = ({ items, initialIndex = 0, active = true }) => {
8133
8310
  useEffect12(() => {
8134
8311
  if (cursor >= items.length) {
8135
8312
  setCursor(items.length === 0 ? 0 : items.length - 1);
8136
- } else if (!isEnabled(items[cursor])) {
8313
+ } else if (!isEnabled2(items[cursor])) {
8137
8314
  const next = findFirstEnabled(items, cursor, 1) ?? findFirstEnabled(items, cursor, -1);
8138
8315
  if (next !== null) setCursor(next);
8139
8316
  }
@@ -8163,11 +8340,11 @@ var ActionMenu = ({ items, initialIndex = 0, active = true }) => {
8163
8340
  }
8164
8341
  if (key.return || input === " ") {
8165
8342
  const item = items[cursor];
8166
- if (item && isEnabled(item)) item.onSelect();
8343
+ if (item && isEnabled2(item)) item.onSelect();
8167
8344
  return;
8168
8345
  }
8169
8346
  if (input.length > 0) {
8170
- const hit = items.findIndex((it) => it.hotkey === input && it.globalHotkey !== true && isEnabled(it));
8347
+ const hit = items.findIndex((it) => it.hotkey === input && it.globalHotkey !== true && isEnabled2(it));
8171
8348
  if (hit !== -1) {
8172
8349
  setCursor(hit);
8173
8350
  const item = items[hit];
@@ -8183,7 +8360,7 @@ var ActionMenu = ({ items, initialIndex = 0, active = true }) => {
8183
8360
  let lastSection;
8184
8361
  return /* @__PURE__ */ jsx29(Box18, { flexDirection: "column", children: items.map((it, i) => {
8185
8362
  const focused = i === cursor;
8186
- const enabled = isEnabled(it);
8363
+ const enabled = isEnabled2(it);
8187
8364
  const showHeader = it.section !== void 0 && it.section !== lastSection;
8188
8365
  if (it.section !== void 0) lastSection = it.section;
8189
8366
  return /* @__PURE__ */ jsxs18(Box18, { flexDirection: "column", children: [
@@ -8524,7 +8701,7 @@ var keySections = [
8524
8701
  ];
8525
8702
 
8526
8703
  // src/application/ui/tui/components/tasks-panel.tsx
8527
- import { useMemo as useMemo7, useState as useState22 } from "react";
8704
+ import { useEffect as useEffect14, useMemo as useMemo7, useRef as useRef7, useState as useState22 } from "react";
8528
8705
  import { Box as Box21, Text as Text22, useInput as useInput11 } from "ink";
8529
8706
 
8530
8707
  // src/application/ui/tui/runtime/use-no-color.ts
@@ -9325,15 +9502,25 @@ var TasksPanel = ({
9325
9502
  const [focusedKey, setFocusedKey] = useState22(void 0);
9326
9503
  const [expandedKeys, setExpandedKeys] = useState22(() => /* @__PURE__ */ new Set());
9327
9504
  const [criteriaExpandedIds, setCriteriaExpandedIds] = useState22(() => /* @__PURE__ */ new Set());
9328
- const [expandedTaskIds, setExpandedTaskIds] = useState22(() => /* @__PURE__ */ new Set());
9329
- const [cardCursor, setCardCursor] = useState22(void 0);
9330
9505
  const activeTaskIdx = bucketed.tasks.findIndex((t) => t.status !== "completed");
9331
9506
  const activeTaskId = activeTaskIdx >= 0 ? bucketed.tasks[activeTaskIdx]?.id : void 0;
9332
- const isCardExpanded = (taskId) => {
9333
- if (expandedTaskIds.has(taskId)) return true;
9334
- if (taskId === activeTaskId) return true;
9335
- return false;
9336
- };
9507
+ const [expandedTaskIds, setExpandedTaskIds] = useState22(
9508
+ () => new Set(activeTaskId !== void 0 ? [activeTaskId] : [])
9509
+ );
9510
+ const [cardCursor, setCardCursor] = useState22(void 0);
9511
+ const prevActiveTaskIdRef = useRef7(activeTaskId);
9512
+ useEffect14(() => {
9513
+ if (activeTaskId !== void 0 && prevActiveTaskIdRef.current !== activeTaskId) {
9514
+ setExpandedTaskIds((prev) => {
9515
+ if (prev.has(activeTaskId)) return prev;
9516
+ const next = new Set(prev);
9517
+ next.add(activeTaskId);
9518
+ return next;
9519
+ });
9520
+ }
9521
+ prevActiveTaskIdRef.current = activeTaskId;
9522
+ }, [activeTaskId]);
9523
+ const isCardExpanded = (taskId) => expandedTaskIds.has(taskId);
9337
9524
  const effectiveCardCursor = useMemo7(() => {
9338
9525
  if (cardCursor !== void 0 && cardCursor >= 0 && cardCursor < bucketed.tasks.length) return cardCursor;
9339
9526
  if (activeTaskIdx >= 0) return activeTaskIdx;
@@ -9355,7 +9542,7 @@ var TasksPanel = ({
9355
9542
  return;
9356
9543
  }
9357
9544
  if (key.escape) {
9358
- if (focusedCardId !== void 0 && focusedCardId !== activeTaskId && expandedTaskIds.has(focusedCardId)) {
9545
+ if (focusedCardId !== void 0 && expandedTaskIds.has(focusedCardId)) {
9359
9546
  setExpandedTaskIds((prev) => {
9360
9547
  const next = new Set(prev);
9361
9548
  next.delete(focusedCardId);
@@ -9390,10 +9577,12 @@ var TasksPanel = ({
9390
9577
  return;
9391
9578
  }
9392
9579
  if (key.return || input === " ") {
9393
- if (!focusedCardExpanded && focusedCardId !== void 0) {
9580
+ const rowCursorAnchored = focusedCardExpanded && flatKeys.length > 0 && focusedIndex >= 0;
9581
+ if (focusedCardId !== void 0 && !rowCursorAnchored) {
9394
9582
  setExpandedTaskIds((prev) => {
9395
9583
  const next = new Set(prev);
9396
- next.add(focusedCardId);
9584
+ if (next.has(focusedCardId)) next.delete(focusedCardId);
9585
+ else next.add(focusedCardId);
9397
9586
  return next;
9398
9587
  });
9399
9588
  return;
@@ -9637,9 +9826,11 @@ var composeSkillSources = (...sources) => ({
9637
9826
 
9638
9827
  // src/business/settings/resolve-effort.ts
9639
9828
  var resolveEffort = (flow, settings) => {
9640
- const row = settings.ai[flow];
9829
+ const row = primaryFlowRow(settings.ai, flow);
9830
+ return resolveEffortForRow(row, settings.ai.effort);
9831
+ };
9832
+ var resolveEffortForRow = (row, globalEffort) => {
9641
9833
  if (row.effort !== void 0) return row.effort;
9642
- const globalEffort = settings.ai.effort;
9643
9834
  if (globalEffort === void 0) return void 0;
9644
9835
  return _floorForProvider(globalEffort, row.provider);
9645
9836
  };
@@ -9913,6 +10104,14 @@ var appendExecutionSetupRun = (execution, run) => ({
9913
10104
  ...execution,
9914
10105
  setupRanAt: [...execution.setupRanAt, run]
9915
10106
  });
10107
+ var setExecutionBaselineBrokenPolicy = (execution, policy) => {
10108
+ if (policy === void 0) {
10109
+ const { baselineBrokenPolicy: _omit, ...rest } = execution;
10110
+ void _omit;
10111
+ return rest;
10112
+ }
10113
+ return { ...execution, baselineBrokenPolicy: policy };
10114
+ };
9916
10115
 
9917
10116
  // src/domain/entity/sprint.ts
9918
10117
  var sprintBaseFrom = (sprint) => ({
@@ -11581,6 +11780,77 @@ var PROVIDER_BINARY2 = {
11581
11780
  "github-copilot": "gh",
11582
11781
  "openai-codex": "codex"
11583
11782
  };
11783
+ var PROVIDER_INSTALL_GUIDANCE = {
11784
+ "claude-code": {
11785
+ docsUrl: "https://docs.claude.com/en/docs/claude-code/setup",
11786
+ commandsByPlatform: {
11787
+ darwin: [
11788
+ "brew install --cask claude-code",
11789
+ "curl -fsSL https://claude.ai/install.sh | bash",
11790
+ "npm install -g @anthropic-ai/claude-code"
11791
+ ],
11792
+ linux: ["curl -fsSL https://claude.ai/install.sh | bash", "npm install -g @anthropic-ai/claude-code"],
11793
+ win32: [
11794
+ "winget install Anthropic.ClaudeCode",
11795
+ "irm https://claude.ai/install.ps1 | iex",
11796
+ "npm install -g @anthropic-ai/claude-code"
11797
+ ]
11798
+ }
11799
+ },
11800
+ "github-copilot": {
11801
+ docsUrl: "https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-in-the-cli",
11802
+ commandsByPlatform: {
11803
+ darwin: ["brew install gh && gh extension install github/gh-copilot", "gh extension install github/gh-copilot"],
11804
+ linux: [
11805
+ "install gh from https://github.com/cli/cli/blob/trunk/docs/install_linux.md, then: gh extension install github/gh-copilot",
11806
+ "gh extension install github/gh-copilot"
11807
+ ],
11808
+ win32: [
11809
+ "winget install --id GitHub.cli && gh extension install github/gh-copilot",
11810
+ "gh extension install github/gh-copilot"
11811
+ ]
11812
+ }
11813
+ },
11814
+ "openai-codex": {
11815
+ docsUrl: "https://github.com/openai/codex",
11816
+ commandsByPlatform: {
11817
+ darwin: [
11818
+ "brew install --cask codex",
11819
+ "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
11820
+ "npm install -g @openai/codex"
11821
+ ],
11822
+ linux: ["curl -fsSL https://chatgpt.com/codex/install.sh | sh", "npm install -g @openai/codex"],
11823
+ win32: [
11824
+ 'powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"',
11825
+ "npm install -g @openai/codex"
11826
+ ]
11827
+ }
11828
+ }
11829
+ };
11830
+ var resolveInstallPlatform = (platform = process.platform) => {
11831
+ if (platform === "darwin" || platform === "win32") return platform;
11832
+ return "linux";
11833
+ };
11834
+ var primaryInstallCommand = (provider, platform = process.platform) => {
11835
+ const os = resolveInstallPlatform(platform);
11836
+ const list = PROVIDER_INSTALL_GUIDANCE[provider].commandsByPlatform[os];
11837
+ const first = list[0];
11838
+ if (first === void 0) {
11839
+ throw new Error(`No install command registered for ${provider} on ${os}`);
11840
+ }
11841
+ return first;
11842
+ };
11843
+ var renderProviderInstallGuidance = (provider, platform = process.platform) => {
11844
+ const os = resolveInstallPlatform(platform);
11845
+ const guidance = PROVIDER_INSTALL_GUIDANCE[provider];
11846
+ const commands = guidance.commandsByPlatform[os];
11847
+ const header = `${provider} CLI (${PROVIDER_BINARY2[provider]}) not on PATH`;
11848
+ const bullets = commands.map((c) => ` \u2022 ${c}`).join("\n");
11849
+ return `${header}
11850
+ Install options (${os}):
11851
+ ${bullets}
11852
+ Docs: ${guidance.docsUrl}`;
11853
+ };
11584
11854
  var defaultWhich = (binary) => new Promise((resolve) => {
11585
11855
  const child = spawn2("command", ["-v", binary], { stdio: "pipe", shell: true });
11586
11856
  let settled = false;
@@ -11621,18 +11891,48 @@ var aiFlowIdForCheck = (flowId) => {
11621
11891
  return void 0;
11622
11892
  }
11623
11893
  };
11894
+ var rowExpectationsFor = (aiFlow, settings) => {
11895
+ if (aiFlow === "implement") {
11896
+ return [
11897
+ {
11898
+ provider: settings.ai.implement.generator.provider,
11899
+ settingsKey: "ai.implement.generator.provider",
11900
+ role: "generator"
11901
+ },
11902
+ {
11903
+ provider: settings.ai.implement.evaluator.provider,
11904
+ settingsKey: "ai.implement.evaluator.provider",
11905
+ role: "evaluator"
11906
+ }
11907
+ ];
11908
+ }
11909
+ return [
11910
+ {
11911
+ provider: primaryFlowRow(settings.ai, aiFlow).provider,
11912
+ settingsKey: `ai.${aiFlow}.provider`
11913
+ }
11914
+ ];
11915
+ };
11916
+ var renderMissing = (missing, aiFlow) => {
11917
+ const formatOne = (m) => {
11918
+ const binary = PROVIDER_BINARY2[m.provider];
11919
+ const installHint = primaryInstallCommand(m.provider);
11920
+ const docsUrl = PROVIDER_INSTALL_GUIDANCE[m.provider].docsUrl;
11921
+ const roleSuffix = m.role !== void 0 ? ` (${m.role})` : "";
11922
+ return `CLI ${binary} not on PATH for flow ${aiFlow}${roleSuffix}. Change ${m.settingsKey} or install with: ${installHint} (alternatives: ${docsUrl}).`;
11923
+ };
11924
+ if (missing.length === 1) return formatOne(missing[0]);
11925
+ return missing.map(formatOne).join(" ");
11926
+ };
11624
11927
  var checkCli = async (flowId, settings, options = {}) => {
11625
11928
  const aiFlow = aiFlowIdForCheck(flowId);
11626
11929
  if (aiFlow === void 0) return void 0;
11627
- const provider = settings.ai[aiFlow].provider;
11628
- const binary = PROVIDER_BINARY2[provider];
11930
+ const expectations = rowExpectationsFor(aiFlow, settings);
11629
11931
  const detect = options.detect ?? (() => detectInstalledProviders());
11630
11932
  const installed = await detect();
11631
- if (installed.has(provider)) return void 0;
11632
- return {
11633
- ok: false,
11634
- reason: `CLI ${binary} not on PATH for flow ${aiFlow}. Change ai.${aiFlow}.provider or install the CLI.`
11635
- };
11933
+ const missing = expectations.filter((e) => !installed.has(e.provider));
11934
+ if (missing.length === 0) return void 0;
11935
+ return { ok: false, reason: renderMissing(missing, aiFlow) };
11636
11936
  };
11637
11937
 
11638
11938
  // src/application/ui/shared/launch/refine.ts
@@ -12277,6 +12577,8 @@ var markTaskDone = (task, now) => {
12277
12577
  ...guard2.value.maxAttempts !== void 0 ? { maxAttempts: guard2.value.maxAttempts } : {},
12278
12578
  ...guard2.value.extraDimensions !== void 0 ? { extraDimensions: guard2.value.extraDimensions } : {},
12279
12579
  ...guard2.value.externalRefs !== void 0 ? { externalRefs: guard2.value.externalRefs } : {},
12580
+ ...guard2.value.escalatedFromModel !== void 0 ? { escalatedFromModel: guard2.value.escalatedFromModel } : {},
12581
+ ...guard2.value.escalatedToModel !== void 0 ? { escalatedToModel: guard2.value.escalatedToModel } : {},
12280
12582
  status: "done",
12281
12583
  attempts,
12282
12584
  finalAttemptN: verified.n
@@ -12306,6 +12608,24 @@ var failCurrentAttempt = (task, now, reason, abortMeta) => {
12306
12608
  }
12307
12609
  return Result.ok(inProgressNext);
12308
12610
  };
12611
+ var recordTaskEscalation = (task, fromModel, toModel) => {
12612
+ if (task.escalatedFromModel !== void 0 || task.escalatedToModel !== void 0) {
12613
+ return Result.error(
12614
+ new InvalidStateError({
12615
+ entity: "task",
12616
+ currentState: "in_progress",
12617
+ attemptedAction: "record-escalation",
12618
+ message: `task '${task.id}' already escalated (${String(task.escalatedFromModel)} \u2192 ${String(task.escalatedToModel)})`,
12619
+ hint: "The once-per-task cap blocks a second escalation; transition to blocked instead."
12620
+ })
12621
+ );
12622
+ }
12623
+ const from = parseRequiredString("task.escalatedFromModel", fromModel);
12624
+ if (!from.ok) return Result.error(from.error);
12625
+ const to = parseRequiredString("task.escalatedToModel", toModel);
12626
+ if (!to.ok) return Result.error(to.error);
12627
+ return Result.ok({ ...task, escalatedFromModel: from.value, escalatedToModel: to.value });
12628
+ };
12309
12629
  var markTaskBlocked = (task, reason) => {
12310
12630
  const guard2 = requireStatus(
12311
12631
  "task",
@@ -12475,13 +12795,13 @@ var parseTaskList = (rawTasks, input) => {
12475
12795
  );
12476
12796
  }
12477
12797
  const dependsOn = [];
12478
- for (const ref2 of t.blockedBy ?? []) {
12479
- const resolved = idMap.get(ref2);
12798
+ for (const ref3 of t.blockedBy ?? []) {
12799
+ const resolved = idMap.get(ref3);
12480
12800
  if (resolved === void 0) {
12481
12801
  return Result.error(
12482
12802
  new ParseError({
12483
12803
  subCode: "schema-mismatch",
12484
- message: `task-list: tasks[${String(i)}].blockedBy references unknown task id '${ref2}'`
12804
+ message: `task-list: tasks[${String(i)}].blockedBy references unknown task id '${ref3}'`
12485
12805
  })
12486
12806
  );
12487
12807
  }
@@ -12534,23 +12854,23 @@ var resolveTicketRef = (t, i, mode) => {
12534
12854
  })
12535
12855
  );
12536
12856
  }
12537
- const ref2 = t.ticketRef;
12538
- const match = mode.tickets.find((tk) => String(tk.id) === ref2);
12857
+ const ref3 = t.ticketRef;
12858
+ const match = mode.tickets.find((tk) => String(tk.id) === ref3);
12539
12859
  if (match === void 0) {
12540
12860
  return Result.error(
12541
12861
  new ParseError({
12542
12862
  subCode: "schema-mismatch",
12543
- message: `task-list: tasks[${String(i)}].ticketRef '${ref2}' is not an approved ticket on the sprint`,
12863
+ message: `task-list: tasks[${String(i)}].ticketRef '${ref3}' is not an approved ticket on the sprint`,
12544
12864
  hint: `available ticket ids: ${mode.tickets.map((tk) => String(tk.id)).join(", ")}`
12545
12865
  })
12546
12866
  );
12547
12867
  }
12548
- const parsed = TicketId.parse(ref2);
12868
+ const parsed = TicketId.parse(ref3);
12549
12869
  if (!parsed.ok) {
12550
12870
  return Result.error(
12551
12871
  new ParseError({
12552
12872
  subCode: "schema-mismatch",
12553
- message: `task-list: tasks[${String(i)}].ticketRef '${ref2}' is not a valid ticket id format`,
12873
+ message: `task-list: tasks[${String(i)}].ticketRef '${ref3}' is not a valid ticket id format`,
12554
12874
  cause: parsed.error
12555
12875
  })
12556
12876
  );
@@ -13805,13 +14125,14 @@ var FULL_AUTO = {
13805
14125
  };
13806
14126
 
13807
14127
  // src/application/flows/implement/leaves/implement-session.ts
13808
- var implementSession = (sandboxCwd, repoPath, sprintDir2, prompt, model, signalsFile, resume, effort) => ({
14128
+ var implementSession = (sandboxCwd, repoPath, sprintDir2, prompt, model, signalsFile, role, resume, effort) => ({
13809
14129
  prompt,
13810
14130
  cwd: repoPath,
13811
14131
  additionalRoots: [sandboxCwd, sprintDir2],
13812
14132
  model,
13813
14133
  permissions: FULL_AUTO,
13814
14134
  signalsFile,
14135
+ role,
13815
14136
  ...resume !== void 0 ? { resume } : {},
13816
14137
  ...effort !== void 0 ? { effort } : {}
13817
14138
  });
@@ -14048,6 +14369,7 @@ var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
14048
14369
  prompt.value,
14049
14370
  deps.model,
14050
14371
  signalsFile,
14372
+ "evaluator",
14051
14373
  input.priorEvaluatorSessionId,
14052
14374
  deps.effort
14053
14375
  )
@@ -14135,52 +14457,187 @@ var evaluatorLeaf = (deps, taskId) => leaf(`evaluator-${String(taskId)}`, {
14135
14457
  }
14136
14458
  });
14137
14459
 
14138
- // src/business/task/finalize-gen-eval.ts
14139
- var mapExit = (exit) => {
14140
- switch (exit.kind) {
14141
- case "passed":
14142
- return { verdict: "passed" };
14143
- case "self-blocked":
14144
- return { verdict: "failed", blockedReason: exit.reason };
14145
- case "malformed":
14146
- return { verdict: "malformed", warning: { kind: "malformed", detail: exit.detail } };
14147
- case "plateau":
14148
- return { verdict: "failed", warning: { kind: "plateau", dimensions: exit.dimensions } };
14149
- case "budget-exhausted":
14150
- return {
14151
- verdict: "failed",
14152
- warning: { kind: "budget-exhausted", turnsUsed: exit.turnsUsed, turnBudget: exit.turnBudget }
14153
- };
14460
+ // src/business/task/escalation-policy.ts
14461
+ var decideEscalation = (props) => {
14462
+ if (!props.flagOn) return { kind: "flag-off" };
14463
+ if (props.task.escalatedFromModel !== void 0 && props.task.escalatedToModel !== void 0) {
14464
+ return {
14465
+ kind: "already-escalated",
14466
+ from: props.task.escalatedFromModel,
14467
+ to: props.task.escalatedToModel
14468
+ };
14154
14469
  }
14155
- };
14156
- var finalizeGenEvalUseCase = async (props) => {
14157
- const log = props.logger.named("task.finalize-gen-eval");
14158
- let exit;
14159
- if (props.exit !== void 0) {
14160
- exit = props.exit;
14161
- } else {
14162
- const cfg = await props.readConfig();
14163
- exit = { kind: "budget-exhausted", turnsUsed: props.turnsUsed, turnBudget: Math.max(1, cfg.maxTurns) };
14470
+ if (props.task.maxAttempts !== void 0 && props.task.attempts.length >= props.task.maxAttempts) {
14471
+ return {
14472
+ kind: "budget-exhausted",
14473
+ attemptsUsed: props.task.attempts.length,
14474
+ maxAttempts: props.task.maxAttempts
14475
+ };
14164
14476
  }
14165
- log.debug(`finalizing gen-eval (${exit.kind})`, { taskId: props.task.id, exitKind: exit.kind });
14166
- const mapped = mapExit(exit);
14167
- const persisted = await props.taskRepo.update(props.sprintId, props.task);
14477
+ const effective = mergeEscalationMap(props.userMap);
14478
+ const to = effective[props.generatorModel];
14479
+ if (to === void 0 || to === props.generatorModel) {
14480
+ return { kind: "no-mapping", currentModel: props.generatorModel };
14481
+ }
14482
+ return { kind: "escalate", from: props.generatorModel, to };
14483
+ };
14484
+ var escalationBannerId = (taskId) => `model-escalation-${taskId}`;
14485
+ var applyEscalation = (props) => {
14486
+ const { task, decision, eventBus, clock } = props;
14487
+ const log = props.logger.named("task.escalation-policy");
14488
+ const bannerId = escalationBannerId(String(task.id));
14489
+ const now = clock();
14490
+ switch (decision.kind) {
14491
+ case "flag-off":
14492
+ return Result.ok({ task });
14493
+ case "escalate": {
14494
+ const stamped = recordTaskEscalation(task, decision.from, decision.to);
14495
+ if (!stamped.ok) return Result.error(stamped.error);
14496
+ eventBus.publish({
14497
+ type: "model-escalated",
14498
+ taskId: String(task.id),
14499
+ attemptN: task.attempts.length,
14500
+ from: decision.from,
14501
+ to: decision.to,
14502
+ reason: "plateau",
14503
+ at: now
14504
+ });
14505
+ eventBus.publish({
14506
+ type: "banner-show",
14507
+ id: bannerId,
14508
+ tier: "info",
14509
+ message: `escalated generator model: ${decision.from} \u2192 ${decision.to}`,
14510
+ cause: "plateau",
14511
+ at: now
14512
+ });
14513
+ log.info(`escalating generator model: ${decision.from} \u2192 ${decision.to}`, {
14514
+ taskId: String(task.id),
14515
+ attemptN: task.attempts.length,
14516
+ from: decision.from,
14517
+ to: decision.to,
14518
+ reason: "plateau"
14519
+ });
14520
+ return Result.ok({ task: stamped.value });
14521
+ }
14522
+ case "already-escalated": {
14523
+ const message = `plateau persists after escalation (${decision.from} \u2192 ${decision.to}); blocking task`;
14524
+ eventBus.publish({
14525
+ type: "banner-show",
14526
+ id: bannerId,
14527
+ tier: "warn",
14528
+ message: "plateau persists after escalation",
14529
+ cause: `${decision.from} \u2192 ${decision.to}`,
14530
+ at: now
14531
+ });
14532
+ log.warn(message, {
14533
+ taskId: String(task.id),
14534
+ from: decision.from,
14535
+ to: decision.to
14536
+ });
14537
+ return Result.ok({ task, blockedReason: message });
14538
+ }
14539
+ case "no-mapping": {
14540
+ const message = `plateau at top of configured escalation ladder for '${decision.currentModel}'`;
14541
+ eventBus.publish({
14542
+ type: "banner-show",
14543
+ id: bannerId,
14544
+ tier: "warn",
14545
+ message,
14546
+ at: now
14547
+ });
14548
+ log.warn(message, { taskId: String(task.id), currentModel: decision.currentModel });
14549
+ return Result.ok({ task, blockedReason: message });
14550
+ }
14551
+ case "budget-exhausted": {
14552
+ const message = `plateau with attempt budget exhausted (attempts=${String(decision.attemptsUsed)}, maxAttempts=${String(decision.maxAttempts)})`;
14553
+ eventBus.publish({
14554
+ type: "banner-show",
14555
+ id: bannerId,
14556
+ tier: "warn",
14557
+ message: "plateau, attempt budget exhausted",
14558
+ cause: `attempts=${String(decision.attemptsUsed)}/${String(decision.maxAttempts)}`,
14559
+ at: now
14560
+ });
14561
+ log.warn(message, {
14562
+ taskId: String(task.id),
14563
+ attemptsUsed: decision.attemptsUsed,
14564
+ maxAttempts: decision.maxAttempts
14565
+ });
14566
+ return Result.ok({ task, blockedReason: message });
14567
+ }
14568
+ }
14569
+ };
14570
+
14571
+ // src/business/task/finalize-gen-eval.ts
14572
+ var mapExit = (exit) => {
14573
+ switch (exit.kind) {
14574
+ case "passed":
14575
+ return { verdict: "passed" };
14576
+ case "self-blocked":
14577
+ return { verdict: "failed", blockedReason: exit.reason };
14578
+ case "malformed":
14579
+ return { verdict: "malformed", warning: { kind: "malformed", detail: exit.detail } };
14580
+ case "plateau":
14581
+ return { verdict: "failed", warning: { kind: "plateau", dimensions: exit.dimensions } };
14582
+ case "budget-exhausted":
14583
+ return {
14584
+ verdict: "failed",
14585
+ warning: { kind: "budget-exhausted", turnsUsed: exit.turnsUsed, turnBudget: exit.turnBudget }
14586
+ };
14587
+ }
14588
+ };
14589
+ var finalizeGenEvalUseCase = async (props) => {
14590
+ const log = props.logger.named("task.finalize-gen-eval");
14591
+ const cfg = await props.readConfig();
14592
+ let exit;
14593
+ if (props.exit !== void 0) {
14594
+ exit = props.exit;
14595
+ } else {
14596
+ exit = { kind: "budget-exhausted", turnsUsed: props.turnsUsed, turnBudget: Math.max(1, cfg.maxTurns) };
14597
+ }
14598
+ log.debug(`finalizing gen-eval (${exit.kind})`, { taskId: props.task.id, exitKind: exit.kind });
14599
+ const mapped = mapExit(exit);
14600
+ let taskForPersist = props.task;
14601
+ let blockedReason = mapped.blockedReason;
14602
+ let shouldFailAttempt = false;
14603
+ if (exit.kind === "plateau") {
14604
+ const decision = decideEscalation({
14605
+ task: props.task,
14606
+ generatorModel: props.generatorModel,
14607
+ flagOn: cfg.escalateOnPlateau,
14608
+ userMap: cfg.escalationMap
14609
+ });
14610
+ const applied = applyEscalation({
14611
+ task: props.task,
14612
+ decision,
14613
+ eventBus: props.eventBus,
14614
+ logger: props.logger,
14615
+ clock: props.clock
14616
+ });
14617
+ if (!applied.ok) return Result.error(applied.error);
14618
+ taskForPersist = applied.value.task;
14619
+ if (applied.value.blockedReason !== void 0) blockedReason = applied.value.blockedReason;
14620
+ if (decision.kind === "escalate") shouldFailAttempt = true;
14621
+ }
14622
+ const persisted = await props.taskRepo.update(props.sprintId, taskForPersist);
14168
14623
  if (!persisted.ok) {
14169
- log.error("persist failed", { taskId: props.task.id, error: persisted.error.message });
14624
+ log.error("persist failed", { taskId: taskForPersist.id, error: persisted.error.message });
14170
14625
  return Result.error(persisted.error);
14171
14626
  }
14172
14627
  log.info(`gen-eval finalised \u2192 verdict=${mapped.verdict}`, {
14173
- taskId: props.task.id,
14628
+ taskId: taskForPersist.id,
14174
14629
  exitKind: exit.kind,
14175
14630
  verdict: mapped.verdict,
14176
- ...mapped.warning !== void 0 ? { warningKind: mapped.warning.kind } : {}
14631
+ ...mapped.warning !== void 0 ? { warningKind: mapped.warning.kind } : {},
14632
+ ...blockedReason !== void 0 ? { blockedReason } : {}
14177
14633
  });
14178
14634
  return Result.ok({
14179
- task: props.task,
14635
+ task: taskForPersist,
14180
14636
  exit,
14181
14637
  verdict: mapped.verdict,
14182
14638
  ...mapped.warning !== void 0 ? { warning: mapped.warning } : {},
14183
- ...mapped.blockedReason !== void 0 ? { blockedReason: mapped.blockedReason } : {}
14639
+ ...blockedReason !== void 0 ? { blockedReason } : {},
14640
+ ...shouldFailAttempt ? { shouldFailAttempt: true } : {}
14184
14641
  });
14185
14642
  };
14186
14643
 
@@ -14194,7 +14651,10 @@ var finalizeGenEvalLeaf = (deps, taskId) => leaf(`finalize-gen-eval-${String(tas
14194
14651
  turnsUsed: input.turnsUsed,
14195
14652
  readConfig: deps.readConfig,
14196
14653
  taskRepo: deps.taskRepo,
14197
- logger: deps.logger
14654
+ logger: deps.logger,
14655
+ eventBus: deps.eventBus,
14656
+ clock: deps.clock,
14657
+ generatorModel: input.generatorModel
14198
14658
  })
14199
14659
  },
14200
14660
  input: (ctx) => {
@@ -14214,11 +14674,13 @@ var finalizeGenEvalLeaf = (deps, taskId) => leaf(`finalize-gen-eval-${String(tas
14214
14674
  message: `finalize-gen-eval-${String(taskId)}: expected in_progress task`
14215
14675
  });
14216
14676
  }
14677
+ const generatorModel = ctx.currentTask.escalatedToModel ?? deps.configuredGeneratorModel;
14217
14678
  return {
14218
14679
  task: ctx.currentTask,
14219
14680
  sprintId: ctx.sprintId,
14220
14681
  ...ctx.lastExit !== void 0 ? { exit: ctx.lastExit } : {},
14221
- turnsUsed: ctx.genEvalTurn ?? 0
14682
+ turnsUsed: ctx.genEvalTurn ?? 0,
14683
+ generatorModel
14222
14684
  };
14223
14685
  },
14224
14686
  output: (ctx, out) => {
@@ -14230,7 +14692,8 @@ var finalizeGenEvalLeaf = (deps, taskId) => leaf(`finalize-gen-eval-${String(tas
14230
14692
  lastExit: out.exit,
14231
14693
  lastVerdict: out.verdict,
14232
14694
  ...out.warning !== void 0 ? { lastWarning: out.warning } : {},
14233
- ...out.blockedReason !== void 0 ? { lastBlockReason: out.blockedReason } : {}
14695
+ ...out.blockedReason !== void 0 ? { lastBlockReason: out.blockedReason } : {},
14696
+ ...out.shouldFailAttempt === true ? { lastShouldFailAttempt: true } : {}
14234
14697
  };
14235
14698
  }
14236
14699
  });
@@ -14490,6 +14953,11 @@ var generatorLeaf = (deps, taskId) => leaf(`generator-${String(taskId)}`, {
14490
14953
  totalCap: deps.maxTurns,
14491
14954
  at: deps.clock()
14492
14955
  });
14956
+ deps.eventBus.publish({
14957
+ type: "banner-clear",
14958
+ id: escalationBannerId(String(taskId)),
14959
+ at: deps.clock()
14960
+ });
14493
14961
  deps.logger.named("task.round-started").info(`round ${String(roundNum)}/${String(deps.maxTurns)} of attempt ${String(input.task.attempts.length)}`, {
14494
14962
  taskId: input.task.id,
14495
14963
  attemptN: input.task.attempts.length,
@@ -14518,14 +14986,16 @@ var generatorLeaf = (deps, taskId) => leaf(`generator-${String(taskId)}`, {
14518
14986
  });
14519
14987
  if (!prompt.ok) return Result.error(prompt.error);
14520
14988
  await writeRoundPrompt(input.workspaceRoot, roundNum, "generator", String(prompt.value), deps.logger);
14989
+ const effectiveModel = task.escalatedToModel ?? deps.model;
14521
14990
  const spawn3 = await deps.provider.generate(
14522
14991
  implementSession(
14523
14992
  input.workspaceRoot,
14524
14993
  deps.cwd,
14525
14994
  deps.sprintDir,
14526
14995
  prompt.value,
14527
- deps.model,
14996
+ effectiveModel,
14528
14997
  signalsFile,
14998
+ "generator",
14529
14999
  input.priorGeneratorSessionId,
14530
15000
  deps.effort
14531
15001
  )
@@ -14845,111 +15315,217 @@ var postTaskVerifyLeaf = (deps, opts, taskId) => leaf(`post-task-verify-${String
14845
15315
 
14846
15316
  // src/application/flows/implement/leaves/pre-task-verify.ts
14847
15317
  import { join as join28 } from "path";
14848
- var preTaskVerifyLeaf = (deps, opts, taskId) => leaf(`pre-task-verify-${String(taskId)}`, {
14849
- useCase: {
14850
- execute: async (input) => {
14851
- const { run, rawOutput, spawnErrorMessage } = await runVerifyScriptUseCase({
14852
- cwd: opts.cwd,
14853
- phase: "pre",
14854
- ...opts.verifyScript !== void 0 ? { verifyScript: opts.verifyScript } : {},
14855
- ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
14856
- clock: deps.clock,
14857
- runShellScript: (cwd, script, scriptOpts) => deps.shellScriptRunner.run(cwd, script, scriptOpts),
14858
- logger: deps.logger
14859
- });
14860
- if (opts.sprintDir !== void 0 && rawOutput.length > 0) {
14861
- const attemptN = input.task.attempts.length;
14862
- const logPath = join28(
14863
- String(opts.sprintDir),
14864
- "logs",
14865
- "verify",
14866
- String(input.task.id),
14867
- `pre-attempt-${String(attemptN)}.log`
14868
- );
14869
- const wrote = await writeTextAtomic(logPath, rawOutput);
14870
- if (!wrote.ok) {
15318
+ var defaultEnvironment = () => ({
15319
+ isStdinTty: process.stdin.isTTY === true,
15320
+ isCi: isTruthyEnv(process.env.CI),
15321
+ isNoTui: isTruthyEnv(process.env.RALPHCTL_NO_TUI)
15322
+ });
15323
+ var isTruthyEnv = (raw) => raw !== void 0 && raw !== "" && raw !== "0";
15324
+ var isInteractive = (env) => env.isStdinTty && !env.isCi && !env.isNoTui;
15325
+ var askRedBaselineDecision = async (interactive, cwd, exitCode) => {
15326
+ const detail = exitCode !== null ? ` (exit=${String(exitCode)})` : "";
15327
+ return interactive.askChoice(
15328
+ `Pre-task verify failed${detail} at ${String(cwd)}. The baseline is already red \u2014 how should the harness proceed?`,
15329
+ [
15330
+ {
15331
+ label: "Proceed anyway \u2014 run the task on the broken baseline",
15332
+ value: "proceed",
15333
+ description: "remembered for the rest of this sprint until the baseline turns green again"
15334
+ },
15335
+ {
15336
+ label: "Skip this task \u2014 mark it blocked, continue with the next task",
15337
+ value: "skip",
15338
+ description: "one-shot; the next task still gets prompted on a red baseline"
15339
+ },
15340
+ {
15341
+ label: "Abort the sprint \u2014 stop the implement run now",
15342
+ value: "abort",
15343
+ description: "fix the baseline, then re-launch implement"
15344
+ }
15345
+ ]
15346
+ );
15347
+ };
15348
+ var preTaskVerifyLeaf = (deps, opts, taskId) => {
15349
+ const env = deps.environment ?? defaultEnvironment();
15350
+ return leaf(`pre-task-verify-${String(taskId)}`, {
15351
+ useCase: {
15352
+ execute: async (input) => {
15353
+ const { run, rawOutput, spawnErrorMessage } = await runVerifyScriptUseCase({
15354
+ cwd: opts.cwd,
15355
+ phase: "pre",
15356
+ ...opts.verifyScript !== void 0 ? { verifyScript: opts.verifyScript } : {},
15357
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
15358
+ clock: deps.clock,
15359
+ runShellScript: (cwd, script, scriptOpts) => deps.shellScriptRunner.run(cwd, script, scriptOpts),
15360
+ logger: deps.logger
15361
+ });
15362
+ if (opts.sprintDir !== void 0 && rawOutput.length > 0) {
15363
+ const attemptN = input.task.attempts.length;
15364
+ const logPath = join28(
15365
+ String(opts.sprintDir),
15366
+ "logs",
15367
+ "verify",
15368
+ String(input.task.id),
15369
+ `pre-attempt-${String(attemptN)}.log`
15370
+ );
15371
+ const wrote = await writeTextAtomic(logPath, rawOutput);
15372
+ if (!wrote.ok) {
15373
+ deps.eventBus.publish({
15374
+ type: "log",
15375
+ level: "warn",
15376
+ message: `pre-task-verify ${String(opts.cwd)}: failed to persist full log to ${logPath} \u2014 ${wrote.error.message}`,
15377
+ at: deps.clock()
15378
+ });
15379
+ }
15380
+ }
15381
+ let updated = appendAttemptVerifyRun(input.task, run);
15382
+ if (!updated.ok) return Result.error(updated.error);
15383
+ if (run.outcome === "failed") {
15384
+ const flagged = markAttemptBaselineBroken(updated.value);
15385
+ if (!flagged.ok) return Result.error(flagged.error);
15386
+ updated = flagged;
15387
+ }
15388
+ const persisted = await deps.taskRepo.update(input.sprintId, updated.value);
15389
+ if (!persisted.ok) {
14871
15390
  deps.eventBus.publish({
14872
15391
  type: "log",
14873
15392
  level: "warn",
14874
- message: `pre-task-verify ${String(opts.cwd)}: failed to persist full log to ${logPath} \u2014 ${wrote.error.message}`,
15393
+ message: `pre-task-verify audit persist failed for task ${String(taskId)} \u2014 ${persisted.error.message}`,
14875
15394
  at: deps.clock()
14876
15395
  });
14877
15396
  }
15397
+ let execution = input.execution;
15398
+ if (run.outcome === "failed") {
15399
+ if (execution.baselineBrokenPolicy === "proceed") {
15400
+ emitBaselineRedLog(deps, opts, run);
15401
+ emitBaselineRedBanner(deps, taskId);
15402
+ return Result.ok({ task: updated.value, run, execution });
15403
+ }
15404
+ if (!isInteractive(env)) {
15405
+ const reason = "baseline already red at task start (non-interactive \u2014 operator could not be prompted)";
15406
+ deps.eventBus.publish({
15407
+ type: "log",
15408
+ level: "warn",
15409
+ message: `pre-task-verify ${String(opts.cwd)}: ${reason}`,
15410
+ at: deps.clock()
15411
+ });
15412
+ return Result.ok({ task: updated.value, run, execution, blockReason: reason });
15413
+ }
15414
+ const decision = await askRedBaselineDecision(deps.interactive, opts.cwd, run.exitCode);
15415
+ if (!decision.ok) return Result.error(decision.error);
15416
+ if (decision.value === "abort") {
15417
+ return Result.error(
15418
+ new AbortError({
15419
+ elementName: `pre-task-verify-${String(taskId)}`,
15420
+ reason: "operator aborted sprint on broken baseline"
15421
+ })
15422
+ );
15423
+ }
15424
+ if (decision.value === "skip") {
15425
+ const reason = "operator skipped task on broken baseline";
15426
+ deps.eventBus.publish({
15427
+ type: "log",
15428
+ level: "warn",
15429
+ message: `pre-task-verify ${String(opts.cwd)}: ${reason}`,
15430
+ at: deps.clock()
15431
+ });
15432
+ return Result.ok({ task: updated.value, run, execution, blockReason: reason });
15433
+ }
15434
+ const nextExecution = setExecutionBaselineBrokenPolicy(execution, "proceed");
15435
+ const saved = await deps.sprintExecutionRepo.save(nextExecution);
15436
+ if (!saved.ok) return Result.error(saved.error);
15437
+ execution = nextExecution;
15438
+ emitBaselineRedLog(deps, opts, run);
15439
+ emitBaselineRedBanner(deps, taskId);
15440
+ return Result.ok({ task: updated.value, run, execution });
15441
+ }
15442
+ if (run.outcome === "spawn-error") {
15443
+ deps.eventBus.publish({
15444
+ type: "log",
15445
+ level: "warn",
15446
+ message: `pre-task-verify ${String(opts.cwd)}: spawn-error \u2014 ${spawnErrorMessage ?? "unknown spawn error"}; attribution will be skipped`,
15447
+ at: deps.clock()
15448
+ });
15449
+ } else {
15450
+ deps.eventBus.publish({
15451
+ type: "banner-clear",
15452
+ id: `baseline-broken-${String(taskId)}`,
15453
+ at: deps.clock()
15454
+ });
15455
+ if (execution.baselineBrokenPolicy === "proceed") {
15456
+ const nextExecution = setExecutionBaselineBrokenPolicy(execution, void 0);
15457
+ const saved = await deps.sprintExecutionRepo.save(nextExecution);
15458
+ if (!saved.ok) return Result.error(saved.error);
15459
+ execution = nextExecution;
15460
+ }
15461
+ }
15462
+ return Result.ok({ task: updated.value, run, execution });
14878
15463
  }
14879
- let updated = appendAttemptVerifyRun(input.task, run);
14880
- if (!updated.ok) return Result.error(updated.error);
14881
- if (run.outcome === "failed") {
14882
- const flagged = markAttemptBaselineBroken(updated.value);
14883
- if (!flagged.ok) return Result.error(flagged.error);
14884
- updated = flagged;
14885
- }
14886
- const persisted = await deps.taskRepo.update(input.sprintId, updated.value);
14887
- if (!persisted.ok) {
14888
- deps.eventBus.publish({
14889
- type: "log",
14890
- level: "warn",
14891
- message: `pre-task-verify audit persist failed for task ${String(taskId)} \u2014 ${persisted.error.message}`,
14892
- at: deps.clock()
15464
+ },
15465
+ input: (ctx) => {
15466
+ if (ctx.currentTask === void 0 || ctx.currentTask.id !== taskId) {
15467
+ throw new InvalidStateError({
15468
+ entity: "chain",
15469
+ currentState: "pre-pre-task-verify",
15470
+ attemptedAction: `pre-task-verify-${String(taskId)}`,
15471
+ message: `pre-task-verify-${String(taskId)}: ctx.currentTask is missing or mismatched`
14893
15472
  });
14894
15473
  }
14895
- if (run.outcome === "failed") {
14896
- deps.eventBus.publish({
14897
- type: "log",
14898
- level: "warn",
14899
- message: `pre-task-verify ${String(opts.cwd)}: baseline already red (exit=${String(run.exitCode)}) \u2014 task will start on broken baseline`,
14900
- at: deps.clock()
14901
- });
14902
- deps.eventBus.publish({
14903
- type: "banner-show",
14904
- id: `baseline-broken-${String(taskId)}`,
14905
- tier: "warn",
14906
- message: "Pre-task verify baseline is red \u2014 task started on broken state",
14907
- cause: `task ${String(taskId)}`,
14908
- at: deps.clock()
14909
- });
14910
- } else if (run.outcome === "spawn-error") {
14911
- deps.eventBus.publish({
14912
- type: "log",
14913
- level: "warn",
14914
- message: `pre-task-verify ${String(opts.cwd)}: spawn-error \u2014 ${spawnErrorMessage ?? "unknown spawn error"}; attribution will be skipped`,
14915
- at: deps.clock()
15474
+ if (ctx.currentTask.status !== "in_progress") {
15475
+ throw new InvalidStateError({
15476
+ entity: "task",
15477
+ currentState: ctx.currentTask.status,
15478
+ attemptedAction: `pre-task-verify-${String(taskId)}`,
15479
+ message: `pre-task-verify-${String(taskId)}: expected in_progress task \u2014 got '${ctx.currentTask.status}'`
14916
15480
  });
14917
- } else {
14918
- deps.eventBus.publish({
14919
- type: "banner-clear",
14920
- id: `baseline-broken-${String(taskId)}`,
14921
- at: deps.clock()
15481
+ }
15482
+ if (ctx.execution === void 0) {
15483
+ throw new InvalidStateError({
15484
+ entity: "chain",
15485
+ currentState: "pre-pre-task-verify",
15486
+ attemptedAction: `pre-task-verify-${String(taskId)}`,
15487
+ message: `pre-task-verify-${String(taskId)}: ctx.execution is undefined \u2014 load-sprint-execution must run first`
14922
15488
  });
14923
15489
  }
14924
- return Result.ok({ task: updated.value, run });
14925
- }
14926
- },
14927
- input: (ctx) => {
14928
- if (ctx.currentTask === void 0 || ctx.currentTask.id !== taskId) {
14929
- throw new InvalidStateError({
14930
- entity: "chain",
14931
- currentState: "pre-pre-task-verify",
14932
- attemptedAction: `pre-task-verify-${String(taskId)}`,
14933
- message: `pre-task-verify-${String(taskId)}: ctx.currentTask is missing or mismatched`
14934
- });
14935
- }
14936
- if (ctx.currentTask.status !== "in_progress") {
14937
- throw new InvalidStateError({
14938
- entity: "task",
14939
- currentState: ctx.currentTask.status,
14940
- attemptedAction: `pre-task-verify-${String(taskId)}`,
14941
- message: `pre-task-verify-${String(taskId)}: expected in_progress task \u2014 got '${ctx.currentTask.status}'`
14942
- });
15490
+ return { task: ctx.currentTask, sprintId: ctx.sprintId, execution: ctx.execution };
15491
+ },
15492
+ output: (ctx, out) => {
15493
+ const next = {
15494
+ ...ctx,
15495
+ currentTask: out.task,
15496
+ tasks: (ctx.tasks ?? []).map((t) => t.id === out.task.id ? out.task : t),
15497
+ execution: out.execution,
15498
+ lastPreVerifyOutcome: out.run.outcome
15499
+ };
15500
+ if (out.blockReason !== void 0) {
15501
+ return {
15502
+ ...next,
15503
+ lastExit: { kind: "self-blocked", reason: out.blockReason },
15504
+ lastBlockReason: out.blockReason
15505
+ };
15506
+ }
15507
+ return next;
14943
15508
  }
14944
- return { task: ctx.currentTask, sprintId: ctx.sprintId };
14945
- },
14946
- output: (ctx, out) => ({
14947
- ...ctx,
14948
- currentTask: out.task,
14949
- tasks: (ctx.tasks ?? []).map((t) => t.id === out.task.id ? out.task : t),
14950
- lastPreVerifyOutcome: out.run.outcome
14951
- })
14952
- });
15509
+ });
15510
+ };
15511
+ var emitBaselineRedLog = (deps, opts, run) => {
15512
+ deps.eventBus.publish({
15513
+ type: "log",
15514
+ level: "warn",
15515
+ message: `pre-task-verify ${String(opts.cwd)}: baseline already red (exit=${String(run.exitCode)}) \u2014 task will start on broken baseline`,
15516
+ at: deps.clock()
15517
+ });
15518
+ };
15519
+ var emitBaselineRedBanner = (deps, taskId) => {
15520
+ deps.eventBus.publish({
15521
+ type: "banner-show",
15522
+ id: `baseline-broken-${String(taskId)}`,
15523
+ tier: "warn",
15524
+ message: "Pre-task verify baseline is red \u2014 task started on broken state",
15525
+ cause: `task ${String(taskId)}`,
15526
+ at: deps.clock()
15527
+ });
15528
+ };
14953
15529
 
14954
15530
  // src/business/task/preflight-task.ts
14955
15531
  var ELEMENT_NAME = "preflight-task";
@@ -15257,6 +15833,9 @@ var settleTask = (props, now) => {
15257
15833
  }
15258
15834
  return markTaskBlocked(aborted.value, props.blockedReason);
15259
15835
  }
15836
+ if (props.shouldFailAttempt === true) {
15837
+ return failCurrentAttempt(task, now, "failed");
15838
+ }
15260
15839
  return markTaskDone(task, now);
15261
15840
  };
15262
15841
  var settleAttemptUseCase = async (props) => {
@@ -15267,7 +15846,7 @@ var settleAttemptUseCase = async (props) => {
15267
15846
  ...props.blockedReason !== void 0 ? { blockedReason: props.blockedReason } : {},
15268
15847
  ...props.warning !== void 0 ? { warning: props.warning.kind } : {}
15269
15848
  });
15270
- if (props.blockedReason === void 0 && props.hasUncommittedChanges !== void 0) {
15849
+ if (props.blockedReason === void 0 && props.shouldFailAttempt !== true && props.hasUncommittedChanges !== void 0) {
15271
15850
  const dirty = await props.hasUncommittedChanges();
15272
15851
  if (!dirty.ok) {
15273
15852
  log.error("settle: worktree status check failed", {
@@ -15471,7 +16050,8 @@ var settleAttemptLeaf = (deps, opts, taskId) => {
15471
16050
  ...warning !== void 0 ? { warning } : {},
15472
16051
  ...ctx.taskWorkspaceRoot !== void 0 ? { workspaceRoot: ctx.taskWorkspaceRoot } : {},
15473
16052
  ...ctx.currentRoundNum !== void 0 ? { roundNum: ctx.currentRoundNum } : {},
15474
- ...ctx.lastEvaluation !== void 0 ? { evaluation: ctx.lastEvaluation } : {}
16053
+ ...ctx.lastEvaluation !== void 0 ? { evaluation: ctx.lastEvaluation } : {},
16054
+ ...ctx.lastShouldFailAttempt === true ? { shouldFailAttempt: true } : {}
15475
16055
  };
15476
16056
  },
15477
16057
  output: (ctx, settled) => {
@@ -15487,7 +16067,8 @@ var settleAttemptLeaf = (deps, opts, taskId) => {
15487
16067
  lastWarning: void 0,
15488
16068
  lastVerifyResult: void 0,
15489
16069
  lastPreVerifyOutcome: void 0,
15490
- lastCommitSha: void 0
16070
+ lastCommitSha: void 0,
16071
+ lastShouldFailAttempt: void 0
15491
16072
  };
15492
16073
  }
15493
16074
  });
@@ -16120,7 +16701,11 @@ var workingTreeCleanCheckLeaf = (deps, cwd, name = "working-tree-clean-check", o
16120
16701
  // src/application/flows/implement/flow.ts
16121
16702
  var IMPLEMENT_TASK_TERMINAL_LEAF = "uninstall-skills";
16122
16703
  var createImplementFlow = (deps, opts) => {
16123
- const readConfig = () => Promise.resolve({ maxTurns: deps.config.harness.maxTurns });
16704
+ const readConfig = () => Promise.resolve({
16705
+ maxTurns: deps.config.harness.maxTurns,
16706
+ escalateOnPlateau: deps.config.harness.escalateOnPlateau,
16707
+ escalationMap: deps.config.harness.escalationMap
16708
+ });
16124
16709
  const resolveRepo = (task) => {
16125
16710
  const repo = opts.repositories.get(task.repositoryId);
16126
16711
  if (repo === void 0) {
@@ -16148,8 +16733,7 @@ var createImplementFlow = (deps, opts) => {
16148
16733
  const perTaskSubChain = (task) => {
16149
16734
  const taskId = task.id;
16150
16735
  const repo = resolveRepo(task);
16151
- const genEvalLeafDeps = {
16152
- provider: deps.provider,
16736
+ const sharedLeafDeps = {
16153
16737
  templateLoader: deps.templateLoader,
16154
16738
  signals: deps.signals,
16155
16739
  // Threaded into both gen-eval leaves so harness-owned sidecars (audit-[09]
@@ -16161,8 +16745,6 @@ var createImplementFlow = (deps, opts) => {
16161
16745
  // sprint-wide artifacts (`progress.md`) that live outside the per-task sandbox.
16162
16746
  sprintDir: opts.sprintDir,
16163
16747
  progressFile: opts.progressFile,
16164
- model: opts.model,
16165
- ...opts.effort !== void 0 ? { effort: opts.effort } : {},
16166
16748
  clock: deps.clock,
16167
16749
  logger: deps.logger,
16168
16750
  eventBus: deps.eventBus,
@@ -16170,6 +16752,18 @@ var createImplementFlow = (deps, opts) => {
16170
16752
  plateauThreshold: deps.config.harness.plateauThreshold,
16171
16753
  ...repo.verifyScript !== void 0 ? { verifyScript: repo.verifyScript } : {}
16172
16754
  };
16755
+ const generatorLeafDeps = {
16756
+ ...sharedLeafDeps,
16757
+ provider: deps.generatorProvider,
16758
+ model: opts.generatorModel,
16759
+ ...opts.generatorEffort !== void 0 ? { effort: opts.generatorEffort } : {}
16760
+ };
16761
+ const evaluatorLeafDeps = {
16762
+ ...sharedLeafDeps,
16763
+ provider: deps.evaluatorProvider,
16764
+ model: opts.evaluatorModel,
16765
+ ...opts.evaluatorEffort !== void 0 ? { effort: opts.evaluatorEffort } : {}
16766
+ };
16173
16767
  return sequential(`task-${String(taskId)}`, [
16174
16768
  branchPreflightLeaf(
16175
16769
  { gitRunner: deps.gitRunner, logger: deps.logger },
@@ -16200,6 +16794,8 @@ var createImplementFlow = (deps, opts) => {
16200
16794
  {
16201
16795
  shellScriptRunner: deps.shellScriptRunner,
16202
16796
  taskRepo: deps.taskRepo,
16797
+ sprintExecutionRepo: deps.sprintExecutionRepo,
16798
+ interactive: deps.interactive,
16203
16799
  clock: deps.clock,
16204
16800
  eventBus: deps.eventBus,
16205
16801
  logger: deps.logger
@@ -16217,11 +16813,11 @@ var createImplementFlow = (deps, opts) => {
16217
16813
  loop(
16218
16814
  `gen-eval-${String(taskId)}`,
16219
16815
  sequential(`gen-eval-turn-${String(taskId)}`, [
16220
- generatorLeaf(genEvalLeafDeps, taskId),
16816
+ generatorLeaf(generatorLeafDeps, taskId),
16221
16817
  guard(
16222
16818
  `evaluator-guard-${String(taskId)}`,
16223
16819
  (ctx) => ctx.lastExit === void 0,
16224
- evaluatorLeaf(genEvalLeafDeps, taskId)
16820
+ evaluatorLeaf(evaluatorLeafDeps, taskId)
16225
16821
  )
16226
16822
  ]),
16227
16823
  {
@@ -16232,7 +16828,17 @@ var createImplementFlow = (deps, opts) => {
16232
16828
  shouldStop: (ctx) => ctx.lastExit !== void 0
16233
16829
  }
16234
16830
  ),
16235
- finalizeGenEvalLeaf({ taskRepo: deps.taskRepo, readConfig, logger: deps.logger }, taskId),
16831
+ finalizeGenEvalLeaf(
16832
+ {
16833
+ taskRepo: deps.taskRepo,
16834
+ readConfig,
16835
+ logger: deps.logger,
16836
+ eventBus: deps.eventBus,
16837
+ clock: deps.clock,
16838
+ configuredGeneratorModel: opts.generatorModel
16839
+ },
16840
+ taskId
16841
+ ),
16236
16842
  // Verify gate sits BEFORE commit so a red verifyScript blocks the task instead of landing
16237
16843
  // broken code on the sprint branch. On `verify-failed` the leaf stamps `lastBlockReason`,
16238
16844
  // the guard around `commit-task` skips, and `settle-attempt` marks the task `blocked`.
@@ -16393,9 +16999,28 @@ var createImplementFlow = (deps, opts) => {
16393
16999
  };
16394
17000
 
16395
17001
  // src/application/ui/shared/launch/implement.ts
17002
+ var applyImplementRoleOverrides = (base, overrides) => {
17003
+ if (overrides === void 0) return base;
17004
+ const next = {
17005
+ generator: base.generator,
17006
+ evaluator: base.evaluator
17007
+ };
17008
+ if (overrides.generator !== void 0) {
17009
+ next.generator = { provider: overrides.generator.provider, model: overrides.generator.model };
17010
+ }
17011
+ if (overrides.evaluator !== void 0) {
17012
+ next.evaluator = { provider: overrides.evaluator.provider, model: overrides.evaluator.model };
17013
+ }
17014
+ return next;
17015
+ };
16396
17016
  var launchImplement = async (ctx) => {
16397
- const { deps, snapshot, extras, settings, provider, skillsAdapter, skillSource, bridge, sessionId: sessionId2, effort } = ctx;
16398
- const missing = await checkCli("implement", settings);
17017
+ const { deps, snapshot, extras, settings, skillsAdapter, skillSource, bridge, sessionId: sessionId2 } = ctx;
17018
+ const implementPair = applyImplementRoleOverrides(settings.ai.implement, extras.implementRoleOverrides);
17019
+ const effectiveSettings = {
17020
+ ...settings,
17021
+ ai: { ...settings.ai, implement: implementPair }
17022
+ };
17023
+ const missing = await checkCli("implement", effectiveSettings);
16399
17024
  if (missing !== void 0) return missing;
16400
17025
  if (!snapshot.sprint) return { ok: false, reason: "No sprint selected." };
16401
17026
  if (!snapshot.project) return { ok: false, reason: "No project loaded for the selected sprint." };
@@ -16440,18 +17065,31 @@ var launchImplement = async (ctx) => {
16440
17065
  ...r.setupScript !== void 0 ? { setupScript: r.setupScript } : {}
16441
17066
  });
16442
17067
  }
17068
+ const generatorProvider = createAiProvider({
17069
+ row: implementPair.generator,
17070
+ harnessConfig: effectiveSettings.harness,
17071
+ eventBus: deps.app.eventBus
17072
+ });
17073
+ const evaluatorProvider = createAiProvider({
17074
+ row: implementPair.evaluator,
17075
+ harnessConfig: effectiveSettings.harness,
17076
+ eventBus: deps.app.eventBus
17077
+ });
17078
+ const generatorEffort = resolveEffortForRow(implementPair.generator, effectiveSettings.ai.effort);
17079
+ const evaluatorEffort = resolveEffortForRow(implementPair.evaluator, effectiveSettings.ai.effort);
16443
17080
  const element = createImplementFlow(
16444
17081
  {
16445
17082
  sprintRepo: deps.app.sprintRepo,
16446
17083
  sprintExecutionRepo: deps.app.sprintExecutionRepo,
16447
17084
  taskRepo: deps.app.taskRepo,
16448
- provider,
17085
+ generatorProvider,
17086
+ evaluatorProvider,
16449
17087
  templateLoader: deps.app.templateLoader,
16450
17088
  signals,
16451
17089
  eventBus: deps.app.eventBus,
16452
17090
  logger: deps.app.logger,
16453
17091
  clock: deps.app.clock,
16454
- config: settings,
17092
+ config: effectiveSettings,
16455
17093
  gitRunner: deps.app.gitRunner,
16456
17094
  shellScriptRunner: deps.app.shellScriptRunner,
16457
17095
  fileLocker: deps.app.fileLocker,
@@ -16468,8 +17106,13 @@ var launchImplement = async (ctx) => {
16468
17106
  repositories,
16469
17107
  progressFile: progressPath.value,
16470
17108
  sprintDir: sprintDirPath.value,
16471
- model: extras.modelOverride ?? settings.ai.implement.model,
16472
- ...effort !== void 0 ? { effort } : {}
17109
+ // `extras.modelOverride` is a legacy single-model knob from the flows-view picker;
17110
+ // applied to the generator role since that's the one that drove the prior single-model
17111
+ // implement path. Evaluator model stays bound to its settings row.
17112
+ generatorModel: extras.modelOverride ?? implementPair.generator.model,
17113
+ ...generatorEffort !== void 0 ? { generatorEffort } : {},
17114
+ evaluatorModel: implementPair.evaluator.model,
17115
+ ...evaluatorEffort !== void 0 ? { evaluatorEffort } : {}
16473
17116
  }
16474
17117
  );
16475
17118
  const runner = createRunner({
@@ -16477,11 +17120,12 @@ var launchImplement = async (ctx) => {
16477
17120
  element,
16478
17121
  initialCtx: { sprintId: snapshot.sprint.id }
16479
17122
  });
16480
- runner.subscribe((evt) => {
17123
+ const unsubRunner = runner.subscribe((evt) => {
16481
17124
  if (evt.type === "completed" || evt.type === "failed" || evt.type === "aborted") {
16482
17125
  chainLog.stop();
16483
17126
  void chainLog.flush();
16484
17127
  unsubTaskTracker();
17128
+ unsubRunner();
16485
17129
  }
16486
17130
  });
16487
17131
  const taskNames = new Map(todoTasks.map((t) => [String(t.id), t.name]));
@@ -16503,6 +17147,8 @@ var launchImplement = async (ctx) => {
16503
17147
  for (const leaf2 of flattened) {
16504
17148
  if (leaf2.label !== void 0 && leaf2.label.length > 0) planLabelByName.set(leaf2.name, leaf2.label);
16505
17149
  }
17150
+ const generatorModel = extras.modelOverride ?? implementPair.generator.model;
17151
+ const evaluatorModel = implementPair.evaluator.model;
16506
17152
  return {
16507
17153
  ok: true,
16508
17154
  runner: bridge(runner),
@@ -16512,7 +17158,9 @@ var launchImplement = async (ctx) => {
16512
17158
  plannedLeaves,
16513
17159
  ...planLabelByName.size > 0 ? { planLabelByName } : {},
16514
17160
  terminalSubstepName: IMPLEMENT_TASK_TERMINAL_LEAF,
16515
- ...taskRecovering.size > 0 ? { taskRecovering } : {}
17161
+ ...taskRecovering.size > 0 ? { taskRecovering } : {},
17162
+ generatorModel,
17163
+ evaluatorModel
16516
17164
  };
16517
17165
  };
16518
17166
 
@@ -17109,9 +17757,9 @@ var launchReview = (ctx) => {
17109
17757
  fileLocker: deps.app.fileLocker,
17110
17758
  locksRoot: deps.storage.locksRoot,
17111
17759
  appendFile: deps.app.appendFile,
17112
- // Review uses the implement model — same code-mutation profile, same accuracy
17113
- // expectations. No per-flow `review` row in settings today.
17114
- model: extras.modelOverride ?? settings.ai.implement.model
17760
+ // Review uses the implement generator model — same code-mutation profile, same
17761
+ // accuracy expectations. No per-flow `review` row in settings today.
17762
+ model: extras.modelOverride ?? settings.ai.implement.generator.model
17115
17763
  },
17116
17764
  {
17117
17765
  sprintId: snapshot.sprint.id,
@@ -17529,9 +18177,9 @@ var collectArtefactPaths = (state) => {
17529
18177
  if (a.settings !== void 0) paths.push(String(a.settings.path));
17530
18178
  if (a.settingsLocal !== void 0) paths.push(String(a.settingsLocal.path));
17531
18179
  if (a.mcpConfig !== void 0) paths.push(String(a.mcpConfig.path));
17532
- for (const ref2 of a.skills) paths.push(String(ref2.path));
17533
- for (const ref2 of a.commands) paths.push(String(ref2.path));
17534
- for (const ref2 of a.agents) paths.push(String(ref2.path));
18180
+ for (const ref3 of a.skills) paths.push(String(ref3.path));
18181
+ for (const ref3 of a.commands) paths.push(String(ref3.path));
18182
+ for (const ref3 of a.agents) paths.push(String(ref3.path));
17535
18183
  } else if (a.tool === "copilot") {
17536
18184
  if (a.copilotInstructions !== void 0) paths.push(String(a.copilotInstructions.path));
17537
18185
  }
@@ -17861,24 +18509,30 @@ var FLOW_IDS = ["refine", "plan", "implement", "readiness", "ideate"];
17861
18509
  var pickRowForProvider = (ai, provider) => {
17862
18510
  if (ai.readiness.provider === provider) return "readiness";
17863
18511
  for (const flow of FLOW_IDS) {
17864
- if (ai[flow].provider === provider) return flow;
18512
+ if (primaryFlowRow(ai, flow).provider === provider) return flow;
17865
18513
  }
17866
18514
  throw new Error(`pickRowForProvider: provider ${provider} not referenced in ai settings`);
17867
18515
  };
17868
18516
  var uniqueProvidersFromAi = (ai) => {
17869
18517
  const seen = /* @__PURE__ */ new Set();
17870
18518
  const ordered = [];
18519
+ const visit = (provider) => {
18520
+ if (seen.has(provider)) return;
18521
+ seen.add(provider);
18522
+ ordered.push(provider);
18523
+ };
17871
18524
  for (const flow of FLOW_IDS) {
17872
- const provider = ai[flow].provider;
17873
- if (!seen.has(provider)) {
17874
- seen.add(provider);
17875
- ordered.push(provider);
18525
+ if (flow === "implement") {
18526
+ visit(ai.implement.generator.provider);
18527
+ visit(ai.implement.evaluator.provider);
18528
+ continue;
17876
18529
  }
18530
+ visit(ai[flow].provider);
17877
18531
  }
17878
18532
  return ordered;
17879
18533
  };
17880
- var resolveEffortForRow = (ai, flow) => {
17881
- const row = ai[flow];
18534
+ var resolveEffortForRow2 = (ai, flow) => {
18535
+ const row = primaryFlowRow(ai, flow);
17882
18536
  if (row.effort !== void 0) return row.effort;
17883
18537
  const globalEffort = ai.effort;
17884
18538
  if (globalEffort === void 0) return void 0;
@@ -17887,8 +18541,8 @@ var resolveEffortForRow = (ai, flow) => {
17887
18541
  };
17888
18542
  var buildPerToolSubchain = (deps, opts, provider, tool) => {
17889
18543
  const rowFlow = pickRowForProvider(opts.ai, provider);
17890
- const row = opts.ai[rowFlow];
17891
- const effort = resolveEffortForRow(opts.ai, rowFlow);
18544
+ const row = primaryFlowRow(opts.ai, rowFlow);
18545
+ const effort = resolveEffortForRow2(opts.ai, rowFlow);
17892
18546
  const provideAi = deps.providerFor(provider);
17893
18547
  const skillsAdapter = deps.skillsAdapterFor(provider);
17894
18548
  return sequential(`tool-${tool}`, [
@@ -17942,7 +18596,7 @@ var createReadinessFlow = (deps, opts) => {
17942
18596
  var flowIdForProvider = (settings, provider) => {
17943
18597
  if (settings.ai.readiness.provider === provider) return "readiness";
17944
18598
  for (const flow of FLOW_IDS) {
17945
- if (settings.ai[flow].provider === provider) return flow;
18599
+ if (primaryFlowRow(settings.ai, flow).provider === provider) return flow;
17946
18600
  }
17947
18601
  throw new Error(`flowIdForProvider: provider ${provider} not referenced in ai settings`);
17948
18602
  };
@@ -19677,12 +20331,14 @@ var sessionHintsFromLaunchResult = (result) => ({
19677
20331
  ...result.plannedLeaves !== void 0 ? { plannedLeaves: result.plannedLeaves } : {},
19678
20332
  ...result.planLabelByName !== void 0 ? { planLabelByName: result.planLabelByName } : {},
19679
20333
  ...result.terminalSubstepName !== void 0 ? { terminalSubstepName: result.terminalSubstepName } : {},
19680
- ...result.taskRecovering !== void 0 ? { taskRecovering: result.taskRecovering } : {}
20334
+ ...result.taskRecovering !== void 0 ? { taskRecovering: result.taskRecovering } : {},
20335
+ ...result.generatorModel !== void 0 ? { generatorModel: result.generatorModel } : {},
20336
+ ...result.evaluatorModel !== void 0 ? { evaluatorModel: result.evaluatorModel } : {}
19681
20337
  });
19682
20338
  var modelsForFlowProvider = (flowId, settings) => {
19683
20339
  const aiFlow = aiFlowIdFor(flowId);
19684
20340
  if (aiFlow === void 0) return [];
19685
- switch (settings.ai[aiFlow].provider) {
20341
+ switch (primaryFlowRow(settings.ai, aiFlow).provider) {
19686
20342
  case "claude-code":
19687
20343
  return CLAUDE_MODELS;
19688
20344
  case "github-copilot":
@@ -19694,7 +20350,7 @@ var modelsForFlowProvider = (flowId, settings) => {
19694
20350
  var modelForFlow = (flowId, settings) => {
19695
20351
  const aiFlow = aiFlowIdFor(flowId);
19696
20352
  if (aiFlow === void 0) return void 0;
19697
- return settings.ai[aiFlow].model;
20353
+ return primaryFlowRow(settings.ai, aiFlow).model;
19698
20354
  };
19699
20355
  var aiFlowIdFor = (flowId) => {
19700
20356
  switch (flowId) {
@@ -19738,7 +20394,7 @@ var launchFlow = async (deps, flowId, snapshot, extras = {}) => {
19738
20394
  eventBus: deps.app.eventBus
19739
20395
  });
19740
20396
  const skillsAdapter = createSkillsAdapter({
19741
- provider: settings.ai[adapterFlow].provider,
20397
+ provider: primaryFlowRow(settings.ai, adapterFlow).provider,
19742
20398
  logger: deps.app.logger
19743
20399
  });
19744
20400
  const effort = aiFlow !== void 0 ? resolveEffort(aiFlow, settings) : void 0;
@@ -19804,16 +20460,19 @@ var launchSprintBoundFlow = async (deps, flowId, snapshot, extras = {}) => {
19804
20460
  return result;
19805
20461
  };
19806
20462
  var attachReseatSubscriber = (runner, fallbackLabel, onReseat) => {
19807
- runner.subscribe((event) => {
20463
+ const unsub = runner.subscribe((event) => {
20464
+ if (event.type === "failed" || event.type === "aborted") {
20465
+ unsub();
20466
+ return;
20467
+ }
19808
20468
  if (event.type !== "completed") return;
19809
20469
  const ctx = event.ctx;
19810
20470
  if (ctx.sprint !== void 0) {
19811
20471
  onReseat({ id: ctx.sprint.id, name: ctx.sprint.name });
19812
- return;
19813
- }
19814
- if (ctx.sprintId !== void 0) {
20472
+ } else if (ctx.sprintId !== void 0) {
19815
20473
  onReseat({ id: ctx.sprintId, name: fallbackLabel ?? String(ctx.sprintId) });
19816
20474
  }
20475
+ unsub();
19817
20476
  });
19818
20477
  };
19819
20478
 
@@ -19843,7 +20502,7 @@ var HomeView = () => {
19843
20502
  const currentSprint = snapshot?.sprint;
19844
20503
  const recentSprints = snapshot?.recentSprints ?? [];
19845
20504
  const [localError, setLocalError] = useState23(void 0);
19846
- const errorTimerRef = useRef7(void 0);
20505
+ const errorTimerRef = useRef8(void 0);
19847
20506
  const flashErr = useCallback8((text) => {
19848
20507
  setLocalError(text);
19849
20508
  if (errorTimerRef.current !== void 0) clearTimeout(errorTimerRef.current);
@@ -19852,14 +20511,14 @@ var HomeView = () => {
19852
20511
  errorTimerRef.current = void 0;
19853
20512
  }, SWITCH_FEEDBACK_MS);
19854
20513
  }, []);
19855
- useEffect14(
20514
+ useEffect15(
19856
20515
  () => () => {
19857
20516
  if (errorTimerRef.current !== void 0) clearTimeout(errorTimerRef.current);
19858
20517
  },
19859
20518
  []
19860
20519
  );
19861
20520
  const lastSwitch = selection.lastSwitch;
19862
- useEffect14(() => {
20521
+ useEffect15(() => {
19863
20522
  if (lastSwitch === void 0) return void 0;
19864
20523
  const elapsed = Date.now() - lastSwitch.at;
19865
20524
  const remaining = SWITCH_FEEDBACK_MS - elapsed;
@@ -20639,6 +21298,7 @@ var FlowsView = () => {
20639
21298
  modelOverride = picked.value;
20640
21299
  }
20641
21300
  }
21301
+ const implementRoleOverrides = entry.manifest.id === "implement" ? getImplementRoleOverrides() : void 0;
20642
21302
  const result = await launchFlow(
20643
21303
  { app: deps, interactive, storage: storage2, runInTerminal: getRunInTerminal() },
20644
21304
  entry.manifest.id,
@@ -20646,6 +21306,7 @@ var FlowsView = () => {
20646
21306
  {
20647
21307
  ...ui.sessionRepositoryId !== void 0 ? { repositoryId: ui.sessionRepositoryId } : {},
20648
21308
  ...modelOverride !== void 0 ? { modelOverride } : {},
21309
+ ...implementRoleOverrides !== void 0 ? { implementRoleOverrides } : {},
20649
21310
  settingsSnapshot: settings
20650
21311
  }
20651
21312
  );
@@ -20653,10 +21314,15 @@ var FlowsView = () => {
20653
21314
  setLaunchError(`${entry.manifest.title}: ${result.reason}`);
20654
21315
  return;
20655
21316
  }
20656
- result.runner.subscribe((event) => {
21317
+ const unsubRepoCapture = result.runner.subscribe((event) => {
21318
+ if (event.type === "failed" || event.type === "aborted") {
21319
+ unsubRepoCapture();
21320
+ return;
21321
+ }
20657
21322
  if (event.type !== "completed") return;
20658
21323
  const ctx = event.ctx;
20659
21324
  if (ctx.repository !== void 0) ui.setSessionRepositoryId(ctx.repository.id);
21325
+ unsubRepoCapture();
20660
21326
  });
20661
21327
  sessions.register({
20662
21328
  runner: result.runner,
@@ -20717,11 +21383,11 @@ var FlowsView = () => {
20717
21383
  };
20718
21384
 
20719
21385
  // src/application/ui/tui/views/projects-view.tsx
20720
- import { useEffect as useEffect17, useState as useState27 } from "react";
21386
+ import { useEffect as useEffect18, useState as useState27 } from "react";
20721
21387
  import { Box as Box28, Text as Text30, useInput as useInput15 } from "ink";
20722
21388
 
20723
21389
  // src/application/ui/tui/components/card-list.tsx
20724
- import { useEffect as useEffect15, useMemo as useMemo10, useState as useState25 } from "react";
21390
+ import { useEffect as useEffect16, useMemo as useMemo10, useState as useState25 } from "react";
20725
21391
  import { Box as Box26, Text as Text28, useInput as useInput14 } from "ink";
20726
21392
  import { jsx as jsx38, jsxs as jsxs27 } from "react/jsx-runtime";
20727
21393
  var clamp3 = (n, min, max) => Math.max(min, Math.min(max, n));
@@ -20737,10 +21403,10 @@ function CardList({
20737
21403
  footer
20738
21404
  }) {
20739
21405
  const [cursor, setCursor] = useState25(() => clamp3(initialIndex, 0, Math.max(0, items.length - 1)));
20740
- useEffect15(() => {
21406
+ useEffect16(() => {
20741
21407
  if (cursor >= items.length) setCursor(Math.max(0, items.length - 1));
20742
21408
  }, [items.length, cursor]);
20743
- useEffect15(() => {
21409
+ useEffect16(() => {
20744
21410
  if (items.length === 0) return;
20745
21411
  const item = items[cursor];
20746
21412
  if (item !== void 0) onCursor?.(item, cursor);
@@ -20824,7 +21490,7 @@ var EmptyState = ({ title, hint, action }) => /* @__PURE__ */ jsxs28(
20824
21490
  );
20825
21491
 
20826
21492
  // src/application/ui/tui/runtime/use-edit-field.ts
20827
- import { useCallback as useCallback9, useEffect as useEffect16, useRef as useRef8, useState as useState26 } from "react";
21493
+ import { useCallback as useCallback9, useEffect as useEffect17, useRef as useRef9, useState as useState26 } from "react";
20828
21494
  var enqueueText = (queue, title, kind, initial) => new Promise((resolve, reject) => {
20829
21495
  const base = { message: title, initial, resolve, reject };
20830
21496
  const prompt = kind === "short" ? { kind: "text", ...base } : { kind: "textarea", ...base };
@@ -20834,8 +21500,8 @@ var useEditField = () => {
20834
21500
  const queue = usePromptQueue();
20835
21501
  const ui = useUiState();
20836
21502
  const [feedback, setFeedbackState] = useState26(void 0);
20837
- const mountedRef = useRef8(true);
20838
- useEffect16(
21503
+ const mountedRef = useRef9(true);
21504
+ useEffect17(
20839
21505
  () => () => {
20840
21506
  mountedRef.current = false;
20841
21507
  },
@@ -20937,7 +21603,7 @@ var ProjectsView = () => {
20937
21603
  reload();
20938
21604
  }
20939
21605
  });
20940
- useEffect17(() => confirmDelete !== void 0 ? ui.claimPrompt() : void 0, [confirmDelete, ui.claimPrompt]);
21606
+ useEffect18(() => confirmDelete !== void 0 ? ui.claimPrompt() : void 0, [confirmDelete, ui.claimPrompt]);
20941
21607
  const handleDeleteConfirmed = async (target, confirmed) => {
20942
21608
  setConfirmDelete(void 0);
20943
21609
  if (!confirmed) return;
@@ -21036,7 +21702,7 @@ var ProjectsView = () => {
21036
21702
  };
21037
21703
 
21038
21704
  // src/application/ui/tui/views/project-detail-view.tsx
21039
- import React44, { useEffect as useEffect18, useState as useState28 } from "react";
21705
+ import React44, { useEffect as useEffect19, useState as useState28 } from "react";
21040
21706
  import { Box as Box30, Text as Text32, useInput as useInput16 } from "ink";
21041
21707
 
21042
21708
  // src/application/ui/tui/components/field-list.tsx
@@ -21222,7 +21888,7 @@ var ProjectDetailView = () => {
21222
21888
  if (target !== void 0) void launchPerRepoFlow("detect-skills", target);
21223
21889
  }
21224
21890
  });
21225
- useEffect18(() => confirmRemove !== void 0 ? ui.claimPrompt() : void 0, [confirmRemove, ui.claimPrompt]);
21891
+ useEffect19(() => confirmRemove !== void 0 ? ui.claimPrompt() : void 0, [confirmRemove, ui.claimPrompt]);
21226
21892
  const handleRemoveConfirmed = async (target, confirmed) => {
21227
21893
  setConfirmRemove(void 0);
21228
21894
  if (!confirmed || project === void 0) return;
@@ -21347,7 +22013,7 @@ var Body = ({ project, cursorIdx, feedback }) => /* @__PURE__ */ jsxs31(Box30, {
21347
22013
  ] });
21348
22014
 
21349
22015
  // src/application/ui/tui/views/sprints-view.tsx
21350
- import { useEffect as useEffect19, useState as useState29 } from "react";
22016
+ import { useEffect as useEffect20, useState as useState29 } from "react";
21351
22017
  import { Box as Box31, Text as Text33, useInput as useInput17 } from "ink";
21352
22018
 
21353
22019
  // src/business/task/unblock-task.ts
@@ -21406,7 +22072,7 @@ var SprintsView = () => {
21406
22072
  const [feedback, setFeedback] = useState29(void 0);
21407
22073
  const [focusedSprintTasks, setFocusedSprintTasks] = useState29([]);
21408
22074
  const focusedSprint = items.find((s) => s.id === cursorId) ?? items[0];
21409
- useEffect19(() => {
22075
+ useEffect20(() => {
21410
22076
  if (focusedSprint === void 0) {
21411
22077
  setFocusedSprintTasks([]);
21412
22078
  return void 0;
@@ -21432,7 +22098,7 @@ var SprintsView = () => {
21432
22098
  { keys: "r", label: "reload" },
21433
22099
  ...stuckCount > 0 ? [{ keys: "u", label: `unblock (${String(stuckCount)})` }] : []
21434
22100
  ]);
21435
- useEffect19(() => confirmDelete !== void 0 ? ui.claimPrompt() : void 0, [confirmDelete, ui.claimPrompt]);
22101
+ useEffect20(() => confirmDelete !== void 0 ? ui.claimPrompt() : void 0, [confirmDelete, ui.claimPrompt]);
21436
22102
  const launchCreateSprint2 = async () => {
21437
22103
  if (selection.projectId === void 0) {
21438
22104
  setFeedback("\u2717 pick a project first (Projects \u2192 open one)");
@@ -21650,7 +22316,7 @@ var SprintsView = () => {
21650
22316
  };
21651
22317
 
21652
22318
  // src/application/ui/tui/views/sprint-detail-view.tsx
21653
- import React46, { useEffect as useEffect20, useMemo as useMemo11, useState as useState30 } from "react";
22319
+ import React46, { useEffect as useEffect21, useMemo as useMemo11, useState as useState30 } from "react";
21654
22320
  import { Box as Box32, Text as Text34, useInput as useInput18 } from "ink";
21655
22321
 
21656
22322
  // src/application/flows/ticket-remove/flow.ts
@@ -21766,21 +22432,19 @@ var SprintDetailView = () => {
21766
22432
  const focusedTicket = focusedNow?.kind === "ticket" && ticketsEditable ? focusedNow.ticket : void 0;
21767
22433
  const focusedTodoTask = focusedNow?.kind === "task" && focusedNow.task.status === "todo" ? focusedNow.task : void 0;
21768
22434
  const canEdit = focusedTicket !== void 0 || focusedTodoTask !== void 0;
21769
- useViewHints(
21770
- inDetail ? [{ keys: "esc", label: "back to list" }] : [
21771
- { keys: "n", label: "flows" },
21772
- { keys: "\u21B5/o", label: "open" },
21773
- { keys: "a", label: "add ticket" },
21774
- ...canEdit ? [{ keys: "e", label: "edit field" }] : [],
21775
- { keys: "d", label: "remove ticket" },
21776
- // Surface the `m` chord only when this sprint is not already the current one — once
21777
- // they match, the action is a no-op and the hint adds noise. Suppressed while a
21778
- // stuck task is focused so the `u unblock` hint (a more urgent operator action)
21779
- // stays prominent in the footer without competing for horizontal space.
21780
- ...sprint !== void 0 && selection.sprintId !== sprint.id && focusedStuckTask === void 0 ? [{ keys: "m", label: "current" }] : [],
21781
- ...focusedStuckTask !== void 0 ? [{ keys: "u", label: "unblock" }] : []
21782
- ]
21783
- );
22435
+ useViewHints([
22436
+ { keys: "n", label: "flows" },
22437
+ { keys: "\u21B5/o", label: inDetail ? "expand/collapse" : "expand" },
22438
+ { keys: "a", label: "add ticket" },
22439
+ ...canEdit ? [{ keys: "e", label: "edit field" }] : [],
22440
+ { keys: "d", label: "remove ticket" },
22441
+ // Surface the `m` chord only when this sprint is not already the current one — once
22442
+ // they match, the action is a no-op and the hint adds noise. Suppressed while a
22443
+ // stuck task is focused so the `u unblock` hint (a more urgent operator action)
22444
+ // stays prominent in the footer without competing for horizontal space.
22445
+ ...sprint !== void 0 && selection.sprintId !== sprint.id && focusedStuckTask === void 0 ? [{ keys: "m", label: "current" }] : [],
22446
+ ...focusedStuckTask !== void 0 ? [{ keys: "u", label: "unblock" }] : []
22447
+ ]);
21784
22448
  const buildTicketEdit = (ticket, field) => {
21785
22449
  if (sprint === void 0) return void 0;
21786
22450
  if (field === "requirements" && ticket.status !== "approved") {
@@ -21858,8 +22522,8 @@ var SprintDetailView = () => {
21858
22522
  };
21859
22523
  useInput18((input, key) => {
21860
22524
  if (ui.helpOpen || ui.promptActive || confirmRemove !== void 0 || sprint === void 0) return;
21861
- if (inDetail) {
21862
- if (key.escape || input === "q") setOpenIdx(void 0);
22525
+ if ((key.escape || input === "q") && inDetail) {
22526
+ setOpenIdx(void 0);
21863
22527
  return;
21864
22528
  }
21865
22529
  if (input === "a" && ticketsEditable) {
@@ -21886,7 +22550,8 @@ var SprintDetailView = () => {
21886
22550
  return;
21887
22551
  }
21888
22552
  if ((key.return || input === "o") && focusList.length > 0) {
21889
- setOpenIdx(Math.min(cursorIdx, focusList.length - 1));
22553
+ const target = Math.min(cursorIdx, focusList.length - 1);
22554
+ setOpenIdx((prev) => prev === target ? void 0 : target);
21890
22555
  return;
21891
22556
  }
21892
22557
  if (input === "d" && ticketsEditable) {
@@ -21898,8 +22563,8 @@ var SprintDetailView = () => {
21898
22563
  void handleUnblock(focusedStuckTask);
21899
22564
  }
21900
22565
  });
21901
- useEffect20(() => confirmRemove !== void 0 ? ui.claimPrompt() : void 0, [confirmRemove, ui.claimPrompt]);
21902
- useEffect20(() => inDetail ? ui.claimEscape() : void 0, [inDetail, ui.claimEscape]);
22566
+ useEffect21(() => confirmRemove !== void 0 ? ui.claimPrompt() : void 0, [confirmRemove, ui.claimPrompt]);
22567
+ useEffect21(() => inDetail ? ui.claimEscape() : void 0, [inDetail, ui.claimEscape]);
21903
22568
  const handleRemoveConfirmed = async (target, confirmed) => {
21904
22569
  setConfirmRemove(void 0);
21905
22570
  if (!confirmed || sprint === void 0) return;
@@ -21968,19 +22633,7 @@ var Body2 = ({
21968
22633
  }) => {
21969
22634
  const { sprint, tasks } = bundle;
21970
22635
  const action = phaseAction(sprint, tasks);
21971
- if (openIdx !== void 0) {
21972
- const focused = focusList[openIdx];
21973
- return /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", children: [
21974
- /* @__PURE__ */ jsx44(SprintHeader, { sprint, tasks, isCurrent }),
21975
- focused !== void 0 && /* @__PURE__ */ jsx44(Box32, { marginTop: spacing.section, children: focused.kind === "ticket" ? /* @__PURE__ */ jsx44(TicketDetail, { ticket: focused.ticket, tasks }) : /* @__PURE__ */ jsx44(TaskDetail, { task: focused.task, sprint, tasks, project }) }),
21976
- /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
21977
- glyphs.bullet,
21978
- " esc back to list ",
21979
- glyphs.bullet,
21980
- " \u2191/\u2193 scroll"
21981
- ] }) })
21982
- ] });
21983
- }
22636
+ const expandedFocusItem = openIdx !== void 0 ? focusList[openIdx] : void 0;
21984
22637
  return /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", children: [
21985
22638
  /* @__PURE__ */ jsx44(SprintHeader, { sprint, tasks, isCurrent }),
21986
22639
  action !== void 0 && (sprint.status === "done" ? /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
@@ -22003,15 +22656,26 @@ var Body2 = ({
22003
22656
  focusList,
22004
22657
  cursorIdx,
22005
22658
  ticketsEditable,
22006
- feedback
22659
+ feedback,
22660
+ expandedFocusItem
22661
+ }
22662
+ ),
22663
+ /* @__PURE__ */ jsx44(
22664
+ TasksSection,
22665
+ {
22666
+ sprint,
22667
+ tasks,
22668
+ focusList,
22669
+ cursorIdx,
22670
+ project,
22671
+ expandedFocusItem
22007
22672
  }
22008
22673
  ),
22009
- /* @__PURE__ */ jsx44(TasksSection, { sprint, tasks, focusList, cursorIdx, project }),
22010
22674
  /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
22011
22675
  glyphs.bullet,
22012
22676
  " \u2191/\u2193 focus ",
22013
22677
  glyphs.bullet,
22014
- " \u21B5/o open ",
22678
+ " \u21B5/o expand/collapse ",
22015
22679
  glyphs.bullet,
22016
22680
  " n flows ",
22017
22681
  glyphs.bullet,
@@ -22160,7 +22824,8 @@ var TicketsSection = ({
22160
22824
  focusList,
22161
22825
  cursorIdx,
22162
22826
  ticketsEditable,
22163
- feedback
22827
+ feedback,
22828
+ expandedFocusItem
22164
22829
  }) => /* @__PURE__ */ jsxs33(Box32, { marginTop: spacing.section, flexDirection: "column", children: [
22165
22830
  /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
22166
22831
  glyphs.badge,
@@ -22174,16 +22839,30 @@ var TicketsSection = ({
22174
22839
  }
22175
22840
  ) }) : /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", marginTop: 1, children: sprint.tickets.map((ticket, idx) => {
22176
22841
  const focused = focusList[cursorIdx]?.kind === "ticket" && focusList[cursorIdx]?.ticket.id === ticket.id;
22842
+ const expanded = expandedFocusItem?.kind === "ticket" && expandedFocusItem.ticket.id === ticket.id;
22177
22843
  const taskCount = tasks.filter((t) => t.ticketId === ticket.id).length;
22178
- return /* @__PURE__ */ jsx44(TicketCard, { ticket, taskCount, focused, index: idx }, ticket.id);
22844
+ return /* @__PURE__ */ jsx44(
22845
+ TicketCard,
22846
+ {
22847
+ ticket,
22848
+ tasks,
22849
+ taskCount,
22850
+ focused,
22851
+ expanded,
22852
+ index: idx
22853
+ },
22854
+ ticket.id
22855
+ );
22179
22856
  }) }),
22180
- /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsx44(Text34, { dimColor: true, children: ticketsEditable ? `${glyphs.bullet} a add ${glyphs.bullet} \u21B5/o open ${glyphs.bullet} d remove` : `${glyphs.bullet} tickets frozen (sprint not in draft) ${glyphs.bullet} \u21B5/o open` }) }),
22857
+ /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsx44(Text34, { dimColor: true, children: ticketsEditable ? `${glyphs.bullet} a add ${glyphs.bullet} \u21B5/o expand/collapse ${glyphs.bullet} d remove` : `${glyphs.bullet} tickets frozen (sprint not in draft) ${glyphs.bullet} \u21B5/o expand/collapse` }) }),
22181
22858
  feedback !== void 0 && /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: 1, children: /* @__PURE__ */ jsx44(Text34, { color: feedback.startsWith("\u2717") ? inkColors.error : inkColors.primary, children: feedback }) })
22182
22859
  ] });
22183
22860
  var TicketCard = ({
22184
22861
  ticket,
22862
+ tasks,
22185
22863
  taskCount,
22186
22864
  focused,
22865
+ expanded,
22187
22866
  index
22188
22867
  }) => /* @__PURE__ */ jsx44(Box32, { marginBottom: 1, children: /* @__PURE__ */ jsx44(
22189
22868
  Card,
@@ -22222,11 +22901,19 @@ var TicketCard = ({
22222
22901
  " requirements \u2713"
22223
22902
  ] })
22224
22903
  ] }),
22225
- ticket.description !== void 0 && /* @__PURE__ */ jsx44(Description, { text: ticket.description, maxLines: 2 })
22904
+ !expanded && ticket.description !== void 0 && /* @__PURE__ */ jsx44(Description, { text: ticket.description, maxLines: 2 }),
22905
+ expanded && /* @__PURE__ */ jsx44(TicketDetailBody, { ticket, tasks })
22226
22906
  ] })
22227
22907
  }
22228
22908
  ) });
22229
- var TasksSection = ({ sprint, tasks, focusList, cursorIdx, project }) => /* @__PURE__ */ jsxs33(Box32, { marginTop: spacing.section, flexDirection: "column", children: [
22909
+ var TasksSection = ({
22910
+ sprint,
22911
+ tasks,
22912
+ focusList,
22913
+ cursorIdx,
22914
+ project,
22915
+ expandedFocusItem
22916
+ }) => /* @__PURE__ */ jsxs33(Box32, { marginTop: spacing.section, flexDirection: "column", children: [
22230
22917
  /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
22231
22918
  glyphs.badge,
22232
22919
  " Tasks"
@@ -22234,26 +22921,39 @@ var TasksSection = ({ sprint, tasks, focusList, cursorIdx, project }) => /* @__P
22234
22921
  tasks.length === 0 ? /* @__PURE__ */ jsx44(Box32, { marginTop: 1, children: /* @__PURE__ */ jsx44(EmptyState, { title: "No tasks yet", hint: "Run plan from Flows (n) once tickets are approved." }) }) : /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", marginTop: 1, children: tasks.map((task, idx) => {
22235
22922
  const focusItem = focusList[cursorIdx];
22236
22923
  const focused = focusItem?.kind === "task" && focusItem.task.id === task.id;
22924
+ const expanded = expandedFocusItem?.kind === "task" && expandedFocusItem.task.id === task.id;
22237
22925
  const ticket = sprint.tickets.find((t) => t.id === task.ticketId);
22238
22926
  const repoName = repositoryName(project, task.repositoryId);
22239
22927
  return /* @__PURE__ */ jsx44(
22240
22928
  TaskCard,
22241
22929
  {
22242
22930
  task,
22931
+ sprint,
22932
+ tasks,
22933
+ project,
22243
22934
  ticketTitle: ticket?.title,
22244
22935
  repoName,
22245
22936
  focused,
22937
+ expanded,
22246
22938
  index: idx + 1
22247
22939
  },
22248
22940
  task.id
22249
22941
  );
22250
- }) })
22942
+ }) }),
22943
+ /* @__PURE__ */ jsx44(Box32, { paddingX: spacing.indent, marginTop: spacing.section, children: /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
22944
+ glyphs.bullet,
22945
+ " \u21B5/o expand/collapse"
22946
+ ] }) })
22251
22947
  ] });
22252
22948
  var TaskCard = ({
22253
22949
  task,
22950
+ sprint,
22951
+ tasks,
22952
+ project,
22254
22953
  ticketTitle,
22255
22954
  repoName,
22256
22955
  focused,
22956
+ expanded,
22257
22957
  index
22258
22958
  }) => {
22259
22959
  const lastAttempt2 = task.attempts[task.attempts.length - 1];
@@ -22309,12 +23009,13 @@ var TaskCard = ({
22309
23009
  fmtDuration(lastAttemptElapsed)
22310
23010
  ] })
22311
23011
  ] }),
22312
- task.description !== void 0 && /* @__PURE__ */ jsx44(Description, { text: task.description, maxLines: 2 }),
22313
- task.status === "blocked" && /* @__PURE__ */ jsx44(Box32, { paddingLeft: 2, children: /* @__PURE__ */ jsxs33(Text34, { color: inkColors.error, children: [
23012
+ !expanded && task.description !== void 0 && /* @__PURE__ */ jsx44(Description, { text: task.description, maxLines: 2 }),
23013
+ !expanded && task.status === "blocked" && /* @__PURE__ */ jsx44(Box32, { paddingLeft: 2, children: /* @__PURE__ */ jsxs33(Text34, { color: inkColors.error, children: [
22314
23014
  glyphs.cross,
22315
23015
  " blocked: ",
22316
23016
  task.blockedReason
22317
- ] }) })
23017
+ ] }) }),
23018
+ expanded && /* @__PURE__ */ jsx44(TaskDetailBody, { task, sprint, tasks, project })
22318
23019
  ] })
22319
23020
  }
22320
23021
  ) });
@@ -22330,43 +23031,24 @@ var attemptElapsedMs = (attempt) => {
22330
23031
  const started = Date.parse(attempt.startedAt);
22331
23032
  return Number.isFinite(finished) && Number.isFinite(started) ? finished - started : void 0;
22332
23033
  };
22333
- var TicketDetail = ({
23034
+ var TicketDetailBody = ({
22334
23035
  ticket,
22335
23036
  tasks
22336
23037
  }) => {
22337
23038
  const referencedTasks = tasks.filter((t) => t.ticketId === ticket.id);
22338
- return /* @__PURE__ */ jsx44(
22339
- Card,
22340
- {
22341
- title: `Ticket \u2014 ${ticket.title}`,
22342
- tone: "info",
22343
- right: /* @__PURE__ */ jsx44(StatusChip, { label: ticket.status, kind: ticketStatusKind(ticket.status) }),
22344
- children: /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", paddingX: spacing.indent, children: [
22345
- /* @__PURE__ */ jsx44(
22346
- FieldList,
22347
- {
22348
- fields: [
22349
- { label: "Title", value: ticket.title },
22350
- { label: "Status", value: ticket.status },
22351
- ...ticket.link !== void 0 ? [{ label: "Link", value: String(ticket.link) }] : [],
22352
- { label: "Tasks", value: String(referencedTasks.length) }
22353
- ]
22354
- }
22355
- ),
22356
- ticket.description !== void 0 && /* @__PURE__ */ jsx44(Section, { heading: "Description", children: /* @__PURE__ */ jsx44(Description, { text: ticket.description, maxLines: Number.POSITIVE_INFINITY }) }),
22357
- ticket.status === "approved" && /* @__PURE__ */ jsx44(Section, { heading: "Requirements", children: /* @__PURE__ */ jsx44(Description, { text: ticket.requirements, maxLines: Number.POSITIVE_INFINITY }) }),
22358
- referencedTasks.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Referenced tasks", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: referencedTasks.map((t) => /* @__PURE__ */ jsxs33(Box32, { children: [
22359
- /* @__PURE__ */ jsx44(StatusChip, { label: t.status, kind: taskStatusKind(t.status) }),
22360
- /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
22361
- " ",
22362
- t.name
22363
- ] })
22364
- ] }, t.id)) }) })
23039
+ return /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", children: [
23040
+ ticket.description !== void 0 && /* @__PURE__ */ jsx44(Section, { heading: "Description", children: /* @__PURE__ */ jsx44(Description, { text: ticket.description, maxLines: Number.POSITIVE_INFINITY }) }),
23041
+ ticket.status === "approved" && /* @__PURE__ */ jsx44(Section, { heading: "Requirements", children: /* @__PURE__ */ jsx44(Description, { text: ticket.requirements, maxLines: Number.POSITIVE_INFINITY }) }),
23042
+ referencedTasks.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Referenced tasks", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: referencedTasks.map((t) => /* @__PURE__ */ jsxs33(Box32, { children: [
23043
+ /* @__PURE__ */ jsx44(StatusChip, { label: t.status, kind: taskStatusKind(t.status) }),
23044
+ /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
23045
+ " ",
23046
+ t.name
22365
23047
  ] })
22366
- }
22367
- );
23048
+ ] }, t.id)) }) })
23049
+ ] });
22368
23050
  };
22369
- var TaskDetail = ({
23051
+ var TaskDetailBody = ({
22370
23052
  task,
22371
23053
  sprint,
22372
23054
  tasks,
@@ -22375,69 +23057,55 @@ var TaskDetail = ({
22375
23057
  const ticket = sprint.tickets.find((t) => t.id === task.ticketId);
22376
23058
  const dependsOnTasks = task.dependsOn.map((id) => tasks.find((t) => t.id === id)).filter((t) => t !== void 0);
22377
23059
  const repoName = repositoryName(project, task.repositoryId);
22378
- return /* @__PURE__ */ jsx44(
22379
- Card,
22380
- {
22381
- title: `Task \u2014 ${task.name}`,
22382
- tone: "info",
22383
- right: /* @__PURE__ */ jsx44(StatusChip, { label: task.status, kind: taskStatusKind(task.status) }),
22384
- children: /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", paddingX: spacing.indent, children: [
22385
- /* @__PURE__ */ jsx44(
22386
- FieldList,
23060
+ return /* @__PURE__ */ jsxs33(Box32, { flexDirection: "column", children: [
23061
+ /* @__PURE__ */ jsx44(
23062
+ FieldList,
23063
+ {
23064
+ fields: [
23065
+ { label: "Order", value: String(task.order) },
22387
23066
  {
22388
- fields: [
22389
- { label: "Name", value: task.name },
22390
- { label: "Status", value: task.status },
22391
- { label: "Order", value: String(task.order) },
22392
- {
22393
- label: "Repository",
22394
- value: repoName !== void 0 ? `${repoName} (${String(task.repositoryId)})` : String(task.repositoryId)
22395
- },
22396
- {
22397
- label: "Ticket",
22398
- value: ticket !== void 0 ? `${ticket.title} [${ticket.status}]` : String(task.ticketId)
22399
- },
22400
- {
22401
- label: "Attempts",
22402
- value: `${String(task.attempts.length)}${task.maxAttempts !== void 0 ? `/${String(task.maxAttempts)}` : ""}`
22403
- },
22404
- ...task.status === "done" ? [{ label: "Final attempt", value: `#${String(task.finalAttemptN)}` }] : [],
22405
- ...task.extraDimensions !== void 0 && task.extraDimensions.length > 0 ? [{ label: "Extra dims", value: task.extraDimensions.join(", ") }] : []
22406
- ]
22407
- }
22408
- ),
22409
- task.status === "blocked" && /* @__PURE__ */ jsx44(Box32, { marginTop: 1, children: /* @__PURE__ */ jsxs33(Text34, { color: inkColors.error, children: [
22410
- glyphs.cross,
22411
- " blocked: ",
22412
- task.blockedReason
22413
- ] }) }),
22414
- task.description !== void 0 && /* @__PURE__ */ jsx44(Section, { heading: "Description", children: /* @__PURE__ */ jsx44(Description, { text: task.description, maxLines: Number.POSITIVE_INFINITY }) }),
22415
- task.steps.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Steps", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.steps.map((s, i) => /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
22416
- String(i + 1),
22417
- ". ",
22418
- s
22419
- ] }, `step-${String(i)}`)) }) }),
22420
- task.verificationCriteria.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Verification", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.verificationCriteria.map((c, i) => /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
22421
- glyphs.bullet,
22422
- " [",
22423
- c.id,
22424
- "] ",
22425
- c.check,
22426
- c.check === "auto" && c.command !== void 0 ? ` \`${c.command}\`` : "",
22427
- " \u2014 ",
22428
- c.assertion
22429
- ] }, `vc-${String(i)}`)) }) }),
22430
- dependsOnTasks.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Depends on", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: dependsOnTasks.map((d) => /* @__PURE__ */ jsxs33(Box32, { children: [
22431
- /* @__PURE__ */ jsx44(StatusChip, { label: d.status, kind: taskStatusKind(d.status) }),
22432
- /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
22433
- " ",
22434
- d.name
22435
- ] })
22436
- ] }, d.id)) }) }),
22437
- task.attempts.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Attempt history", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.attempts.map((attempt) => /* @__PURE__ */ jsx44(AttemptCard, { attempt }, `attempt-${String(attempt.n)}`)) }) })
23067
+ label: "Repository",
23068
+ value: repoName !== void 0 ? `${repoName} (${String(task.repositoryId)})` : String(task.repositoryId)
23069
+ },
23070
+ {
23071
+ label: "Ticket",
23072
+ value: ticket !== void 0 ? `${ticket.title} [${ticket.status}]` : String(task.ticketId)
23073
+ },
23074
+ ...task.status === "done" ? [{ label: "Final attempt", value: `#${String(task.finalAttemptN)}` }] : [],
23075
+ ...task.extraDimensions !== void 0 && task.extraDimensions.length > 0 ? [{ label: "Extra dims", value: task.extraDimensions.join(", ") }] : []
23076
+ ]
23077
+ }
23078
+ ),
23079
+ task.status === "blocked" && /* @__PURE__ */ jsx44(Box32, { marginTop: 1, children: /* @__PURE__ */ jsxs33(Text34, { color: inkColors.error, children: [
23080
+ glyphs.cross,
23081
+ " blocked: ",
23082
+ task.blockedReason
23083
+ ] }) }),
23084
+ task.description !== void 0 && /* @__PURE__ */ jsx44(Section, { heading: "Description", children: /* @__PURE__ */ jsx44(Description, { text: task.description, maxLines: Number.POSITIVE_INFINITY }) }),
23085
+ task.steps.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Steps", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.steps.map((s, i) => /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
23086
+ String(i + 1),
23087
+ ". ",
23088
+ s
23089
+ ] }, `step-${String(i)}`)) }) }),
23090
+ task.verificationCriteria.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Verification", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.verificationCriteria.map((c, i) => /* @__PURE__ */ jsxs33(Text34, { dimColor: true, children: [
23091
+ glyphs.bullet,
23092
+ " [",
23093
+ c.id,
23094
+ "] ",
23095
+ c.check,
23096
+ c.check === "auto" && c.command !== void 0 ? ` \`${c.command}\`` : "",
23097
+ " \u2014 ",
23098
+ c.assertion
23099
+ ] }, `vc-${String(i)}`)) }) }),
23100
+ dependsOnTasks.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Depends on", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: dependsOnTasks.map((d) => /* @__PURE__ */ jsxs33(Box32, { children: [
23101
+ /* @__PURE__ */ jsx44(StatusChip, { label: d.status, kind: taskStatusKind(d.status) }),
23102
+ /* @__PURE__ */ jsxs33(Text34, { bold: true, children: [
23103
+ " ",
23104
+ d.name
22438
23105
  ] })
22439
- }
22440
- );
23106
+ ] }, d.id)) }) }),
23107
+ task.attempts.length > 0 && /* @__PURE__ */ jsx44(Section, { heading: "Attempt history", children: /* @__PURE__ */ jsx44(Box32, { flexDirection: "column", paddingLeft: 2, children: task.attempts.map((attempt) => /* @__PURE__ */ jsx44(AttemptCard, { attempt }, `attempt-${String(attempt.n)}`)) }) })
23108
+ ] });
22441
23109
  };
22442
23110
  var AttemptCard = ({ attempt }) => {
22443
23111
  const elapsedMs = attemptElapsedMs(attempt);
@@ -22575,7 +23243,7 @@ var Description = ({
22575
23243
  };
22576
23244
 
22577
23245
  // src/application/ui/tui/views/execute-view.tsx
22578
- import React55, { useEffect as useEffect26, useMemo as useMemo14 } from "react";
23246
+ import React55, { useEffect as useEffect27, useMemo as useMemo14 } from "react";
22579
23247
  import { Box as Box41, Text as Text43, useInput as useInput20 } from "ink";
22580
23248
 
22581
23249
  // src/application/ui/tui/components/step-trace.tsx
@@ -23220,7 +23888,7 @@ var MultiFlowStrip = ({
23220
23888
  };
23221
23889
 
23222
23890
  // src/application/ui/tui/runtime/use-token-usage.ts
23223
- import { useEffect as useEffect21, useState as useState31 } from "react";
23891
+ import { useEffect as useEffect22, useState as useState31 } from "react";
23224
23892
  var TOKEN_USAGE_SESSION_CAP = 100;
23225
23893
  var isTokenUsage = (e) => e.type === "token-usage";
23226
23894
  var toUsage = (e) => ({
@@ -23234,7 +23902,7 @@ var toUsage = (e) => ({
23234
23902
  });
23235
23903
  var useTokenUsage = (bus) => {
23236
23904
  const [usage, setUsage] = useState31(() => /* @__PURE__ */ new Map());
23237
- useEffect21(() => {
23905
+ useEffect22(() => {
23238
23906
  const unsub = bus.subscribe((event) => {
23239
23907
  if (!isTokenUsage(event)) return;
23240
23908
  setUsage((prev) => {
@@ -23255,12 +23923,12 @@ var useTokenUsage = (bus) => {
23255
23923
  };
23256
23924
 
23257
23925
  // src/application/ui/tui/runtime/use-sink-stream.ts
23258
- import { useEffect as useEffect22, useState as useState32 } from "react";
23926
+ import { useEffect as useEffect23, useState as useState32 } from "react";
23259
23927
  var useSinkStream = (bus, opts = {}) => {
23260
23928
  const limit = opts.limit ?? 100;
23261
23929
  const replay = opts.replay !== false;
23262
23930
  const [items, setItems] = useState32(() => replay ? bus.entries.slice(-limit) : []);
23263
- useEffect22(() => {
23931
+ useEffect23(() => {
23264
23932
  if (replay) setItems(bus.entries.slice(-limit));
23265
23933
  const unsub = bus.subscribe((value) => {
23266
23934
  setItems((prev) => {
@@ -23274,13 +23942,13 @@ var useSinkStream = (bus, opts = {}) => {
23274
23942
  };
23275
23943
 
23276
23944
  // src/application/ui/tui/runtime/use-event-bus.ts
23277
- import { useEffect as useEffect23, useRef as useRef9, useState as useState33 } from "react";
23945
+ import { useEffect as useEffect24, useRef as useRef10, useState as useState33 } from "react";
23278
23946
  var useEventBusBuffer = (bus, opts) => {
23279
23947
  const limit = opts.limit ?? 100;
23280
23948
  const [items, setItems] = useState33([]);
23281
- const filterRef = useRef9(opts.filter);
23949
+ const filterRef = useRef10(opts.filter);
23282
23950
  filterRef.current = opts.filter;
23283
- useEffect23(() => {
23951
+ useEffect24(() => {
23284
23952
  const unsub = bus.subscribe((event) => {
23285
23953
  if (!filterRef.current(event)) return;
23286
23954
  setItems((prev) => {
@@ -23294,11 +23962,11 @@ var useEventBusBuffer = (bus, opts) => {
23294
23962
  };
23295
23963
 
23296
23964
  // src/application/ui/tui/runtime/use-task-round-tracker.ts
23297
- import { useEffect as useEffect24, useState as useState34 } from "react";
23965
+ import { useEffect as useEffect25, useState as useState34 } from "react";
23298
23966
  var isTaskRoundStarted = (e) => e.type === "task-round-started";
23299
23967
  var useTaskRoundTracker = (bus) => {
23300
23968
  const [rounds, setRounds] = useState34(() => /* @__PURE__ */ new Map());
23301
- useEffect24(() => {
23969
+ useEffect25(() => {
23302
23970
  const unsub = bus.subscribe((event) => {
23303
23971
  if (!isTaskRoundStarted(event)) return;
23304
23972
  setRounds((prev) => {
@@ -23531,7 +24199,7 @@ var renderActiveTaskSummary = ({ task, displayName }) => {
23531
24199
  };
23532
24200
 
23533
24201
  // src/application/ui/tui/components/cancel-scope-overlay.tsx
23534
- import { useEffect as useEffect25 } from "react";
24202
+ import { useEffect as useEffect26 } from "react";
23535
24203
  import { Box as Box40, Text as Text42, useInput as useInput19 } from "ink";
23536
24204
  import { jsx as jsx52, jsxs as jsxs41 } from "react/jsx-runtime";
23537
24205
  var CancelScopeOverlay = ({
@@ -23554,7 +24222,7 @@ var CancelScopeOverlay = ({
23554
24222
  onDismiss();
23555
24223
  }
23556
24224
  });
23557
- useEffect25(() => void 0, []);
24225
+ useEffect26(() => void 0, []);
23558
24226
  const wasted = attemptElapsedMs2 !== void 0 ? fmtDuration(attemptElapsedMs2) : void 0;
23559
24227
  const remainingHint = remainingTaskCount > 1 ? `${String(remainingTaskCount - 1)} other task${remainingTaskCount - 1 === 1 ? "" : "s"} still queued` : "no other tasks queued";
23560
24228
  return /* @__PURE__ */ jsx52(Box40, { flexDirection: "column", paddingX: spacing.indent, paddingY: 0, marginTop: spacing.section, children: /* @__PURE__ */ jsxs41(
@@ -23722,7 +24390,7 @@ var ExecuteView = () => {
23722
24390
  if (input === "D") router.reset();
23723
24391
  });
23724
24392
  const [now, setNow] = React55.useState(() => Date.now());
23725
- useEffect26(() => {
24393
+ useEffect27(() => {
23726
24394
  if (!isRunning) return void 0;
23727
24395
  const id = setInterval(() => {
23728
24396
  setNow(Date.now());
@@ -23779,7 +24447,7 @@ var ExecuteView = () => {
23779
24447
  const currentTask = currentTaskIdx >= 0 ? bucketed?.tasks[currentTaskIdx] : void 0;
23780
24448
  const currentTaskName = currentTask !== void 0 ? descriptor.taskNames?.get(currentTask.id) ?? `${currentTask.id.slice(0, 8)}${glyphs.clipEllipsis}` : void 0;
23781
24449
  const currentSubStep = currentTask?.subSteps[currentTask.subSteps.length - 1]?.leafName;
23782
- useEffect26(() => {
24450
+ useEffect27(() => {
23783
24451
  if (currentTask === void 0 || currentTaskName === void 0) {
23784
24452
  ui.setActiveTaskSummaryProvider(void 0);
23785
24453
  return void 0;
@@ -23834,6 +24502,7 @@ var ExecuteView = () => {
23834
24502
  const onDismissCancelScope = React55.useCallback(() => {
23835
24503
  setCancelScopeOpen(false);
23836
24504
  }, []);
24505
+ const modelLine = descriptor.generatorModel !== void 0 && descriptor.evaluatorModel !== void 0 ? descriptor.generatorModel === descriptor.evaluatorModel ? descriptor.generatorModel : `${descriptor.generatorModel} ${glyphs.arrowRight} ${descriptor.evaluatorModel} (eval)` : void 0;
23837
24506
  const headerCard = /* @__PURE__ */ jsx53(Card, { title: descriptor.title, tone: isRunning ? "info" : descriptor.status === "completed" ? "success" : "rule", children: /* @__PURE__ */ jsxs42(Box41, { flexDirection: "column", children: [
23838
24507
  /* @__PURE__ */ jsxs42(Box41, { children: [
23839
24508
  /* @__PURE__ */ jsx53(Text43, { dimColor: true, children: "flow " }),
@@ -23862,6 +24531,13 @@ var ExecuteView = () => {
23862
24531
  ] }),
23863
24532
  isRunning && /* @__PURE__ */ jsx53(Box41, { marginLeft: 2, children: /* @__PURE__ */ jsx53(Spinner, { active: isRunning, color: inkColors.info, label: "live" }) })
23864
24533
  ] }),
24534
+ modelLine !== void 0 && /* @__PURE__ */ jsxs42(Box41, { children: [
24535
+ /* @__PURE__ */ jsxs42(Text43, { dimColor: true, children: [
24536
+ glyphs.activityArrow,
24537
+ " model "
24538
+ ] }),
24539
+ /* @__PURE__ */ jsx53(Text43, { color: inkColors.highlight, children: modelLine })
24540
+ ] }),
23865
24541
  currentTask !== void 0 && currentTaskName !== void 0 && /* @__PURE__ */ jsxs42(Box41, { children: [
23866
24542
  /* @__PURE__ */ jsxs42(Text43, { dimColor: true, children: [
23867
24543
  glyphs.activityArrow,
@@ -24062,11 +24738,11 @@ var ExecuteView = () => {
24062
24738
  };
24063
24739
 
24064
24740
  // src/application/ui/tui/views/sessions-view.tsx
24065
- import { useEffect as useEffect28, useState as useState36 } from "react";
24741
+ import { useEffect as useEffect29, useState as useState36 } from "react";
24066
24742
  import { Box as Box43, Text as Text45, useInput as useInput22 } from "ink";
24067
24743
 
24068
24744
  // src/application/ui/tui/components/list-view.tsx
24069
- import { useEffect as useEffect27, useMemo as useMemo15, useState as useState35 } from "react";
24745
+ import { useEffect as useEffect28, useMemo as useMemo15, useState as useState35 } from "react";
24070
24746
  import { Box as Box42, Text as Text44, useInput as useInput21 } from "ink";
24071
24747
  import { jsx as jsx54, jsxs as jsxs43 } from "react/jsx-runtime";
24072
24748
  var clamp4 = (n, min, max) => Math.max(min, Math.min(max, n));
@@ -24081,10 +24757,10 @@ function ListView({
24081
24757
  initialIndex = 0
24082
24758
  }) {
24083
24759
  const [cursor, setCursor] = useState35(() => clamp4(initialIndex, 0, Math.max(0, items.length - 1)));
24084
- useEffect27(() => {
24760
+ useEffect28(() => {
24085
24761
  if (cursor >= items.length) setCursor(Math.max(0, items.length - 1));
24086
24762
  }, [items.length, cursor]);
24087
- useEffect27(() => {
24763
+ useEffect28(() => {
24088
24764
  if (items.length === 0) return;
24089
24765
  const item = items[cursor];
24090
24766
  if (item !== void 0) onCursor?.(item, cursor);
@@ -24163,7 +24839,7 @@ var SessionsView = () => {
24163
24839
  const [cursorId, setCursorId] = useState36(void 0);
24164
24840
  const [confirmCancel, setConfirmCancel] = useState36(void 0);
24165
24841
  const [feedback, setFeedback] = useState36(void 0);
24166
- useEffect28(() => confirmCancel !== void 0 ? ui.claimPrompt() : void 0, [confirmCancel, ui.claimPrompt]);
24842
+ useEffect29(() => confirmCancel !== void 0 ? ui.claimPrompt() : void 0, [confirmCancel, ui.claimPrompt]);
24167
24843
  useInput22((input) => {
24168
24844
  if (ui.helpOpen || ui.promptActive || confirmCancel !== void 0) return;
24169
24845
  if (input === "c") {
@@ -24253,7 +24929,7 @@ var SessionsView = () => {
24253
24929
  };
24254
24930
 
24255
24931
  // src/application/ui/tui/views/settings-view.tsx
24256
- import React58, { useEffect as useEffect29, useMemo as useMemo16, useState as useState37 } from "react";
24932
+ import React58, { useEffect as useEffect30, useMemo as useMemo16, useState as useState37 } from "react";
24257
24933
  import { Box as Box44, Text as Text46, useInput as useInput23 } from "ink";
24258
24934
 
24259
24935
  // src/application/flows/settings-show/flow.ts
@@ -24286,8 +24962,38 @@ var createSettingsSetProviderFlow = (deps) => leaf("settings-set-provider", {
24286
24962
  async execute(input) {
24287
24963
  const current = await deps.settingsRepo.load();
24288
24964
  if (!current.ok) return Result.error(current.error);
24289
- const rebuiltRow = defaultAiSettingsForProvider(input.provider)[input.flow];
24290
- const nextAi = { ...current.value.ai, [input.flow]: rebuiltRow };
24965
+ const detect = deps.detectInstalledProviders ?? detectInstalledProviders;
24966
+ const installed = await detect();
24967
+ if (!installed.has(input.provider)) {
24968
+ const settingsKey = input.flow === "implement" ? `ai.implement.${input.role ?? "generator"}.provider` : `ai.${input.flow}.provider`;
24969
+ return Result.error(
24970
+ new ValidationError({
24971
+ field: settingsKey,
24972
+ value: input.provider,
24973
+ message: `${input.provider} CLI (${PROVIDER_BINARY2[input.provider]}) not on PATH \u2014 cannot set ${settingsKey}`,
24974
+ hint: renderProviderInstallGuidance(input.provider)
24975
+ })
24976
+ );
24977
+ }
24978
+ const defaultsForProvider = defaultAiSettingsForProvider(input.provider);
24979
+ let nextAi;
24980
+ if (input.flow === "implement") {
24981
+ if (input.role === void 0) {
24982
+ return Result.error(
24983
+ new ValidationError({
24984
+ field: "role",
24985
+ value: "undefined",
24986
+ message: "implement requires an explicit role (generator | evaluator) for a provider switch"
24987
+ })
24988
+ );
24989
+ }
24990
+ const rebuiltRoleRow = defaultsForProvider.implement[input.role];
24991
+ const nextImplement = { ...current.value.ai.implement, [input.role]: rebuiltRoleRow };
24992
+ nextAi = { ...current.value.ai, implement: nextImplement };
24993
+ } else {
24994
+ const rebuiltRow = defaultsForProvider[input.flow];
24995
+ nextAi = { ...current.value.ai, [input.flow]: rebuiltRow };
24996
+ }
24291
24997
  const next = { ...current.value, ai: nextAi };
24292
24998
  const saved = await deps.settingsRepo.save(next);
24293
24999
  if (!saved.ok) return Result.error(saved.error);
@@ -24305,7 +25011,10 @@ var MIXED = {
24305
25011
  effort: "high",
24306
25012
  refine: { provider: "openai-codex", model: "gpt-5.5" },
24307
25013
  plan: { provider: "github-copilot", model: "claude-sonnet-4.6", effort: "xhigh" },
24308
- implement: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" },
25014
+ implement: {
25015
+ generator: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" },
25016
+ evaluator: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" }
25017
+ },
24309
25018
  readiness: { provider: "github-copilot", model: "gpt-5-mini", effort: "medium" },
24310
25019
  ideate: { provider: "claude-code", model: "claude-opus-4-7" }
24311
25020
  };
@@ -24313,7 +25022,10 @@ var CLAUDE_ONLY = {
24313
25022
  effort: "high",
24314
25023
  refine: { provider: "claude-code", model: "claude-sonnet-4-6" },
24315
25024
  plan: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" },
24316
- implement: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" },
25025
+ implement: {
25026
+ generator: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" },
25027
+ evaluator: { provider: "claude-code", model: "claude-opus-4-7", effort: "xhigh" }
25028
+ },
24317
25029
  readiness: { provider: "claude-code", model: "claude-haiku-4-5", effort: "medium" },
24318
25030
  ideate: { provider: "claude-code", model: "claude-opus-4-7" }
24319
25031
  };
@@ -24321,7 +25033,10 @@ var COPILOT_ONLY = {
24321
25033
  effort: "high",
24322
25034
  refine: { provider: "github-copilot", model: "claude-sonnet-4.6" },
24323
25035
  plan: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" },
24324
- implement: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" },
25036
+ implement: {
25037
+ generator: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" },
25038
+ evaluator: { provider: "github-copilot", model: "claude-opus-4.6", effort: "xhigh" }
25039
+ },
24325
25040
  readiness: { provider: "github-copilot", model: "gpt-5-mini", effort: "medium" },
24326
25041
  ideate: { provider: "github-copilot", model: "claude-opus-4.6" }
24327
25042
  };
@@ -24329,7 +25044,10 @@ var CODEX_ONLY = {
24329
25044
  effort: "high",
24330
25045
  refine: { provider: "openai-codex", model: "gpt-5.4" },
24331
25046
  plan: { provider: "openai-codex", model: "gpt-5.5", effort: "high" },
24332
- implement: { provider: "openai-codex", model: "gpt-5.3-codex", effort: "high" },
25047
+ implement: {
25048
+ generator: { provider: "openai-codex", model: "gpt-5.3-codex", effort: "high" },
25049
+ evaluator: { provider: "openai-codex", model: "gpt-5.3-codex", effort: "high" }
25050
+ },
24333
25051
  readiness: { provider: "openai-codex", model: "gpt-5.4-mini", effort: "medium" },
24334
25052
  ideate: { provider: "openai-codex", model: "gpt-5.5" }
24335
25053
  };
@@ -24365,7 +25083,7 @@ var createSettingsApplyPresetFlow = (deps) => leaf("settings-apply-preset", {
24365
25083
  var buildWarnings = (ai, installed) => {
24366
25084
  const byProvider = /* @__PURE__ */ new Map();
24367
25085
  for (const flow of FLOW_IDS) {
24368
- const provider = ai[flow].provider;
25086
+ const provider = primaryFlowRow(ai, flow).provider;
24369
25087
  if (installed.has(provider)) continue;
24370
25088
  const existing = byProvider.get(provider);
24371
25089
  if (existing) existing.push(flow);
@@ -24375,38 +25093,48 @@ var buildWarnings = (ai, installed) => {
24375
25093
  };
24376
25094
 
24377
25095
  // src/business/settings/apply-key.ts
24378
- var SETTINGS_KEY_HINT = "supported keys: ai.effort, ai.{flow}.provider, ai.{flow}.model, ai.{flow}.effort (flow in {refine,plan,implement,readiness,ideate}), harness.{maxTurns,maxAttempts,rateLimitRetries,plateauThreshold}, logging.level, concurrency.maxParallelTasks, ui.notifications.enabled";
25096
+ var SETTINGS_KEY_HINT = "supported keys: ai.effort, ai.{flow}.{provider,model,effort} (flow in {refine,plan,readiness,ideate}), ai.implement.{generator,evaluator}.{provider,model,effort}, harness.{maxTurns,maxAttempts,rateLimitRetries,plateauThreshold,escalateOnPlateau}, harness.escalationMap.<fromModel>, logging.level, concurrency.maxParallelTasks, ui.notifications.enabled";
25097
+ var IMPLEMENT_ROLES = ["generator", "evaluator"];
25098
+ var isImplementRole = (raw) => IMPLEMENT_ROLES.includes(raw);
24379
25099
  var AI_PROVIDERS = ["claude-code", "github-copilot", "openai-codex"];
24380
25100
  var isAiProvider = (raw) => AI_PROVIDERS.includes(raw);
24381
25101
  var isFlowId = (raw) => FLOW_IDS.includes(raw);
24382
- var setAiFlowField = (current, flow, field, raw) => {
24383
- const row = current.ai[flow];
24384
- let nextRow;
25102
+ var updateFlowRow = (row, fieldKey, field, raw) => {
24385
25103
  if (field === "provider") {
24386
25104
  if (!isAiProvider(raw)) {
24387
25105
  return Result.error(
24388
25106
  new ValidationError({
24389
- field: `ai.${flow}.provider`,
25107
+ field: fieldKey,
24390
25108
  value: raw,
24391
25109
  message: `'${raw}' is not a recognised provider`,
24392
25110
  hint: `expected one of: ${AI_PROVIDERS.join(", ")}`
24393
25111
  })
24394
25112
  );
24395
25113
  }
24396
- nextRow = { ...row, provider: raw };
24397
- } else if (field === "model") {
24398
- nextRow = { ...row, model: raw };
24399
- } else {
24400
- const trimmed = raw.trim();
24401
- if (trimmed.length === 0) {
24402
- const { effort: _drop, ...rowWithoutEffort } = row;
24403
- void _drop;
24404
- nextRow = rowWithoutEffort;
24405
- } else {
24406
- nextRow = { ...row, effort: trimmed };
24407
- }
25114
+ return Result.ok({ ...row, provider: raw });
25115
+ }
25116
+ if (field === "model") {
25117
+ return Result.ok({ ...row, model: raw });
24408
25118
  }
24409
- const nextAi = { ...current.ai, [flow]: nextRow };
25119
+ const trimmed = raw.trim();
25120
+ if (trimmed.length === 0) {
25121
+ const { effort: _drop, ...rowWithoutEffort } = row;
25122
+ void _drop;
25123
+ return Result.ok(rowWithoutEffort);
25124
+ }
25125
+ return Result.ok({ ...row, effort: trimmed });
25126
+ };
25127
+ var setAiFlowField = (current, flow, field, raw) => {
25128
+ const updated = updateFlowRow(current.ai[flow], `ai.${flow}.${field}`, field, raw);
25129
+ if (!updated.ok) return Result.error(updated.error);
25130
+ const nextAi = { ...current.ai, [flow]: updated.value };
25131
+ return Result.ok({ ...current, ai: nextAi });
25132
+ };
25133
+ var setAiImplementRoleField = (current, role, field, raw) => {
25134
+ const updated = updateFlowRow(current.ai.implement[role], `ai.implement.${role}.${field}`, field, raw);
25135
+ if (!updated.ok) return Result.error(updated.error);
25136
+ const nextImplement = { ...current.ai.implement, [role]: updated.value };
25137
+ const nextAi = { ...current.ai, implement: nextImplement };
24410
25138
  return Result.ok({ ...current, ai: nextAi });
24411
25139
  };
24412
25140
  var applySettingsKey = (current, key, raw) => {
@@ -24415,9 +25143,26 @@ var applySettingsKey = (current, key, raw) => {
24415
25143
  if (parts.length === 3 && parts[0] === "ai") {
24416
25144
  const maybeFlow = parts[1] ?? "";
24417
25145
  const maybeField = parts[2] ?? "";
24418
- if (isFlowId(maybeFlow) && (maybeField === "provider" || maybeField === "model" || maybeField === "effort")) {
25146
+ if (isFlowId(maybeFlow) && maybeFlow !== "implement" && (maybeField === "provider" || maybeField === "model" || maybeField === "effort")) {
24419
25147
  return setAiFlowField(current, maybeFlow, maybeField, raw);
24420
25148
  }
25149
+ if (maybeFlow === "implement" && (maybeField === "provider" || maybeField === "model" || maybeField === "effort")) {
25150
+ return Result.error(
25151
+ new ValidationError({
25152
+ field: key,
25153
+ value: raw,
25154
+ message: `'${key}' is no longer addressable \u2014 implement splits into generator and evaluator roles`,
25155
+ hint: `use ai.implement.generator.${maybeField} or ai.implement.evaluator.${maybeField}`
25156
+ })
25157
+ );
25158
+ }
25159
+ }
25160
+ if (parts.length === 4 && parts[0] === "ai" && parts[1] === "implement") {
25161
+ const maybeRole = parts[2] ?? "";
25162
+ const maybeField = parts[3] ?? "";
25163
+ if (isImplementRole(maybeRole) && (maybeField === "provider" || maybeField === "model" || maybeField === "effort")) {
25164
+ return setAiImplementRoleField(current, maybeRole, maybeField, raw);
25165
+ }
24421
25166
  }
24422
25167
  if (key === "ai.effort") {
24423
25168
  const trimmed = raw.trim();
@@ -24429,6 +25174,26 @@ var applySettingsKey = (current, key, raw) => {
24429
25174
  return Result.ok({ ...current, ai: { ...current.ai, effort: trimmed } });
24430
25175
  }
24431
25176
  }
25177
+ if (key.startsWith("harness.escalationMap.")) {
25178
+ const fromModel = key.slice("harness.escalationMap.".length);
25179
+ if (fromModel.length === 0) {
25180
+ return Result.error(
25181
+ new ValidationError({
25182
+ field: "key",
25183
+ value: key,
25184
+ message: `'${key}' is missing the source model id`,
25185
+ hint: "use harness.escalationMap.<fromModel>"
25186
+ })
25187
+ );
25188
+ }
25189
+ const nextMap = { ...current.harness.escalationMap };
25190
+ if (raw.trim().length === 0) {
25191
+ delete nextMap[fromModel];
25192
+ } else {
25193
+ nextMap[fromModel] = raw;
25194
+ }
25195
+ return Result.ok({ ...current, harness: { ...current.harness, escalationMap: nextMap } });
25196
+ }
24432
25197
  switch (key) {
24433
25198
  case "harness.maxTurns":
24434
25199
  case "harness.maxAttempts":
@@ -24441,6 +25206,20 @@ var applySettingsKey = (current, key, raw) => {
24441
25206
  const which = key.split(".")[1];
24442
25207
  return Result.ok({ ...current, harness: { ...current.harness, [which]: n } });
24443
25208
  }
25209
+ case "harness.escalateOnPlateau": {
25210
+ const b = parseBool(raw);
25211
+ if (b === void 0) {
25212
+ return Result.error(
25213
+ new ValidationError({
25214
+ field: key,
25215
+ value: raw,
25216
+ message: `'${raw}' is not a boolean`,
25217
+ hint: "use 'true' or 'false'"
25218
+ })
25219
+ );
25220
+ }
25221
+ return Result.ok({ ...current, harness: { ...current.harness, escalateOnPlateau: b } });
25222
+ }
24444
25223
  case "logging.level": {
24445
25224
  return Result.ok({ ...current, logging: { level: raw } });
24446
25225
  }
@@ -24532,29 +25311,36 @@ var buildEditableFields = (s) => {
24532
25311
  options: [DEFAULT_TOKEN, ...GLOBAL_EFFORT_LEVELS],
24533
25312
  current: s.ai.effort ?? DEFAULT_TOKEN
24534
25313
  });
24535
- for (const flow of FLOW_IDS) {
24536
- const row = s.ai[flow];
25314
+ const pushRowFields = (keyPrefix, label, row) => {
24537
25315
  fields.push({
24538
25316
  kind: "select",
24539
- key: `ai.${flow}.provider`,
24540
- label: `${capitalize(flow)} provider`,
25317
+ key: `${keyPrefix}.provider`,
25318
+ label: `${label} provider`,
24541
25319
  options: AI_PROVIDERS2,
24542
25320
  current: row.provider
24543
25321
  });
24544
25322
  fields.push({
24545
25323
  kind: "model",
24546
- key: `ai.${flow}.model`,
24547
- label: `${capitalize(flow)} model`,
25324
+ key: `${keyPrefix}.model`,
25325
+ label: `${label} model`,
24548
25326
  options: [...modelOptionsFor(row.provider), CUSTOM_TOKEN],
24549
25327
  current: row.model
24550
25328
  });
24551
25329
  fields.push({
24552
25330
  kind: "select",
24553
- key: `ai.${flow}.effort`,
24554
- label: `${capitalize(flow)} effort`,
25331
+ key: `${keyPrefix}.effort`,
25332
+ label: `${label} effort`,
24555
25333
  options: [DEFAULT_TOKEN, ...PROVIDER_EFFORT_LEVELS[row.provider]],
24556
25334
  current: row.effort ?? DEFAULT_TOKEN
24557
25335
  });
25336
+ };
25337
+ for (const flow of FLOW_IDS) {
25338
+ if (flow === "implement") {
25339
+ pushRowFields("ai.implement.generator", "Implement (generator)", s.ai.implement.generator);
25340
+ pushRowFields("ai.implement.evaluator", "Implement (evaluator)", s.ai.implement.evaluator);
25341
+ continue;
25342
+ }
25343
+ pushRowFields(`ai.${flow}`, capitalize(flow), s.ai[flow]);
24558
25344
  }
24559
25345
  fields.push(
24560
25346
  { kind: "text", key: "harness.maxTurns", label: "Max turns", current: String(s.harness.maxTurns) },
@@ -24589,6 +25375,7 @@ var SettingsView = () => {
24589
25375
  const logLevel = useLogLevel();
24590
25376
  const [settings, setSettings] = useState37(void 0);
24591
25377
  const [loadError, setLoadError] = useState37(void 0);
25378
+ const [installedProviders, setInstalledProviders] = useState37(void 0);
24592
25379
  const [cursor, setCursor] = useState37(0);
24593
25380
  const [editingField, setEditingField] = useState37(void 0);
24594
25381
  const [customModelField, setCustomModelField] = useState37(void 0);
@@ -24607,14 +25394,23 @@ var SettingsView = () => {
24607
25394
  if (result.ok) setSettings(result.value.ctx.output);
24608
25395
  else setLoadError(result.error.error.message);
24609
25396
  }, [deps]);
24610
- useEffect29(() => {
25397
+ useEffect30(() => {
24611
25398
  void refresh();
24612
25399
  }, [refresh]);
25400
+ useEffect30(() => {
25401
+ let cancelled = false;
25402
+ void detectInstalledProviders().then((installed) => {
25403
+ if (!cancelled) setInstalledProviders(installed);
25404
+ });
25405
+ return () => {
25406
+ cancelled = true;
25407
+ };
25408
+ }, []);
24613
25409
  const fields = useMemo16(
24614
25410
  () => settings === void 0 ? [] : buildEditableFields(settings),
24615
25411
  [settings]
24616
25412
  );
24617
- useEffect29(() => {
25413
+ useEffect30(() => {
24618
25414
  if (cursor >= fields.length && fields.length > 0) setCursor(fields.length - 1);
24619
25415
  }, [fields, cursor]);
24620
25416
  useInput23((input, key) => {
@@ -24641,7 +25437,7 @@ var SettingsView = () => {
24641
25437
  setEditingField(field);
24642
25438
  }
24643
25439
  });
24644
- useEffect29(
25440
+ useEffect30(
24645
25441
  () => editingField !== void 0 || customModelField !== void 0 || pendingPreset !== void 0 ? ui.claimPrompt() : void 0,
24646
25442
  [editingField, customModelField, pendingPreset, ui.claimPrompt]
24647
25443
  );
@@ -24666,17 +25462,22 @@ var SettingsView = () => {
24666
25462
  closeEditor();
24667
25463
  return;
24668
25464
  }
24669
- const providerMatch = /^ai\.(refine|plan|implement|readiness|ideate)\.provider$/.exec(field.key);
24670
- if (providerMatch !== null) {
24671
- const flow = providerMatch[1];
25465
+ const implementRoleProviderMatch = /^ai\.implement\.(generator|evaluator)\.provider$/.exec(field.key);
25466
+ const flatProviderMatch = /^ai\.(refine|plan|readiness|ideate)\.provider$/.exec(field.key);
25467
+ if (implementRoleProviderMatch !== null || flatProviderMatch !== null) {
24672
25468
  const providerFlow = createSettingsSetProviderFlow({ settingsRepo: deps.settingsRepo });
24673
- const saved2 = await providerFlow.execute({ input: { flow, provider: raw } });
25469
+ const flow = implementRoleProviderMatch !== null ? "implement" : flatProviderMatch[1];
25470
+ const role = implementRoleProviderMatch?.[1];
25471
+ const saved2 = await providerFlow.execute({
25472
+ input: { flow, provider: raw, ...role !== void 0 ? { role } : {} }
25473
+ });
24674
25474
  if (!saved2.ok) {
24675
25475
  setFeedback({ tone: "error", text: saved2.error.error.message });
24676
25476
  closeEditor();
24677
25477
  return;
24678
25478
  }
24679
- setFeedback({ tone: "ok", text: `${capitalize(flow)} provider = ${raw} \xB7 model reset to default` });
25479
+ const label = role !== void 0 ? `Implement (${role})` : capitalize(flow);
25480
+ setFeedback({ tone: "ok", text: `${label} provider = ${raw} \xB7 model reset to default` });
24680
25481
  closeEditor();
24681
25482
  await refresh();
24682
25483
  return;
@@ -24702,6 +25503,27 @@ var SettingsView = () => {
24702
25503
  closeEditor();
24703
25504
  await refresh();
24704
25505
  };
25506
+ const isProviderField = (field) => field.kind === "select" && (field.key.endsWith(".provider") || field.key === "ai.provider");
25507
+ const buildProviderOptions = (options) => {
25508
+ const installed = installedProviders;
25509
+ const choices = options.map((value) => {
25510
+ const provider = value;
25511
+ const available = installed === void 0 || installed.has(provider);
25512
+ const label = available ? value : `${value} (not installed)`;
25513
+ return available ? { label, value } : { label, value, disabled: true };
25514
+ });
25515
+ const anyEnabled = choices.some((o) => o.disabled !== true);
25516
+ const missing = options.filter((v) => installed !== void 0 && !installed.has(v));
25517
+ const footerParts = [];
25518
+ if (!anyEnabled) {
25519
+ footerParts.push("No AI provider CLI is installed.");
25520
+ }
25521
+ for (const m of missing) {
25522
+ footerParts.push(`install ${m}: ${primaryInstallCommand(m)}`);
25523
+ }
25524
+ if (footerParts.length === 0) return { choices };
25525
+ return { choices, footer: footerParts.join(" \xB7 ") };
25526
+ };
24705
25527
  const renderEditor = (field) => {
24706
25528
  if (field.kind === "model" && customModelField !== void 0) {
24707
25529
  return /* @__PURE__ */ jsx56(
@@ -24715,6 +25537,19 @@ var SettingsView = () => {
24715
25537
  );
24716
25538
  }
24717
25539
  if (field.kind === "select" || field.kind === "model") {
25540
+ if (field.kind === "select" && isProviderField(field)) {
25541
+ const { choices, footer } = buildProviderOptions(field.options);
25542
+ return /* @__PURE__ */ jsx56(
25543
+ SelectPrompt,
25544
+ {
25545
+ message: `${field.label} (current: ${field.current})`,
25546
+ options: choices,
25547
+ ...footer !== void 0 ? { footer } : {},
25548
+ onSubmit: (value) => void submit(String(value), field),
25549
+ onCancel: closeEditor
25550
+ }
25551
+ );
25552
+ }
24718
25553
  return /* @__PURE__ */ jsx56(
24719
25554
  SelectPrompt,
24720
25555
  {
@@ -24785,16 +25620,48 @@ var SettingsView = () => {
24785
25620
  ] }, w.provider)) })
24786
25621
  ] }),
24787
25622
  /* @__PURE__ */ jsx56(Box44, { marginTop: spacing.section, children: /* @__PURE__ */ jsx56(Card, { title: "AI \u2014 global", tone: "primary", children: /* @__PURE__ */ jsx56(FieldList, { fields: [{ label: "Effort (default)", value: valueFor("ai.effort") }] }) }) }),
24788
- FLOW_IDS.map((flow) => /* @__PURE__ */ jsx56(Box44, { marginTop: spacing.section, children: /* @__PURE__ */ jsx56(Card, { title: `AI \u2014 ${capitalize(flow)}`, tone: "primary", children: /* @__PURE__ */ jsx56(
24789
- FieldList,
24790
- {
24791
- fields: [
24792
- { label: "Provider", value: valueFor(`ai.${flow}.provider`) },
24793
- { label: "Model", value: valueFor(`ai.${flow}.model`) },
24794
- { label: "Effort", value: valueFor(`ai.${flow}.effort`) }
24795
- ]
24796
- }
24797
- ) }) }, flow)),
25623
+ FLOW_IDS.map(
25624
+ (flow) => flow === "implement" ? (
25625
+ // Implement is the only flow whose runtime carries two AI sessions per task — the
25626
+ // generator that proposes a commit and the evaluator that judges it. Render the
25627
+ // parent flow name as a non-editable card title; the two roles render as indented
25628
+ // sub-rows underneath so the operator sees at a glance that they're two halves of
25629
+ // the same flow rather than two independent flows. Edits on either role flow
25630
+ // through the same dotted-path keys (`ai.implement.<role>.<field>`), so changing
25631
+ // one role's provider/model/effort cannot perturb the other.
25632
+ /* @__PURE__ */ jsx56(Box44, { marginTop: spacing.section, children: /* @__PURE__ */ jsx56(Card, { title: "AI \u2014 Implement", tone: "primary", children: ["generator", "evaluator"].map((role, idx) => /* @__PURE__ */ jsxs45(
25633
+ Box44,
25634
+ {
25635
+ flexDirection: "column",
25636
+ paddingLeft: spacing.indent,
25637
+ marginTop: idx === 0 ? 0 : spacing.section,
25638
+ children: [
25639
+ /* @__PURE__ */ jsx56(Text46, { dimColor: true, bold: true, children: role }),
25640
+ /* @__PURE__ */ jsx56(
25641
+ FieldList,
25642
+ {
25643
+ fields: [
25644
+ { label: "Provider", value: valueFor(`ai.implement.${role}.provider`) },
25645
+ { label: "Model", value: valueFor(`ai.implement.${role}.model`) },
25646
+ { label: "Effort", value: valueFor(`ai.implement.${role}.effort`) }
25647
+ ]
25648
+ }
25649
+ )
25650
+ ]
25651
+ },
25652
+ role
25653
+ )) }) }, flow)
25654
+ ) : /* @__PURE__ */ jsx56(Box44, { marginTop: spacing.section, children: /* @__PURE__ */ jsx56(Card, { title: `AI \u2014 ${capitalize(flow)}`, tone: "primary", children: /* @__PURE__ */ jsx56(
25655
+ FieldList,
25656
+ {
25657
+ fields: [
25658
+ { label: "Provider", value: valueFor(`ai.${flow}.provider`) },
25659
+ { label: "Model", value: valueFor(`ai.${flow}.model`) },
25660
+ { label: "Effort", value: valueFor(`ai.${flow}.effort`) }
25661
+ ]
25662
+ }
25663
+ ) }) }, flow)
25664
+ ),
24798
25665
  /* @__PURE__ */ jsx56(Box44, { marginTop: spacing.section, children: /* @__PURE__ */ jsx56(Card, { title: "Harness budgets", tone: "primary", children: /* @__PURE__ */ jsx56(
24799
25666
  FieldList,
24800
25667
  {
@@ -24850,7 +25717,7 @@ var SettingsView = () => {
24850
25717
  };
24851
25718
 
24852
25719
  // src/application/ui/tui/views/doctor-view.tsx
24853
- import React59, { useEffect as useEffect30 } from "react";
25720
+ import React59, { useEffect as useEffect31 } from "react";
24854
25721
  import { Box as Box45, Text as Text47, useInput as useInput24 } from "ink";
24855
25722
  import { jsx as jsx57, jsxs as jsxs46 } from "react/jsx-runtime";
24856
25723
  var GROUP_ORDER = [
@@ -24880,7 +25747,7 @@ var DoctorView = () => {
24880
25747
  useViewHints([{ keys: "r", label: "reload" }]);
24881
25748
  const refreshDoctor = system.refreshDoctor;
24882
25749
  const triggered = React59.useRef(false);
24883
- useEffect30(() => {
25750
+ useEffect31(() => {
24884
25751
  if (triggered.current) return;
24885
25752
  if (results !== void 0 || system.doctorLoading) return;
24886
25753
  triggered.current = true;
@@ -24965,7 +25832,7 @@ var ProbeRow = ({ probe }) => /* @__PURE__ */ jsxs46(Box45, { flexDirection: "co
24965
25832
  ] });
24966
25833
 
24967
25834
  // src/application/ui/tui/views/export-context-view.tsx
24968
- import { useCallback as useCallback10, useEffect as useEffect31, useState as useState38 } from "react";
25835
+ import { useCallback as useCallback10, useEffect as useEffect32, useState as useState38 } from "react";
24969
25836
  import { Box as Box46, Text as Text48, useInput as useInput25 } from "ink";
24970
25837
  import { join as join43 } from "path";
24971
25838
 
@@ -25128,7 +25995,7 @@ var ExportContextView = () => {
25128
25995
  const out = result.value.ctx.output;
25129
25996
  setRun({ kind: "done", path: String(out.outputPath), bytes: out.byteCount });
25130
25997
  }, [deps, storage2, selection.projectId, selection.sprintId]);
25131
- useEffect31(() => {
25998
+ useEffect32(() => {
25132
25999
  void runExport();
25133
26000
  }, [runExport]);
25134
26001
  useInput25((input) => {
@@ -25161,7 +26028,7 @@ var ExportContextView = () => {
25161
26028
  };
25162
26029
 
25163
26030
  // src/application/ui/tui/views/export-requirements-view.tsx
25164
- import { useCallback as useCallback11, useEffect as useEffect32, useState as useState39 } from "react";
26031
+ import { useCallback as useCallback11, useEffect as useEffect33, useState as useState39 } from "react";
25165
26032
  import { Box as Box47, Text as Text49, useInput as useInput26 } from "ink";
25166
26033
  import { join as join44 } from "path";
25167
26034
 
@@ -25249,7 +26116,7 @@ var ExportRequirementsView = () => {
25249
26116
  const out = result.value.ctx.output;
25250
26117
  setRun({ kind: "done", path: String(out.outputPath), bytes: out.byteCount });
25251
26118
  }, [deps, storage2, selection.sprintId]);
25252
- useEffect32(() => {
26119
+ useEffect33(() => {
25253
26120
  void runExport();
25254
26121
  }, [runExport]);
25255
26122
  useInput26((input) => {
@@ -25282,7 +26149,7 @@ var ExportRequirementsView = () => {
25282
26149
  };
25283
26150
 
25284
26151
  // src/application/ui/tui/views/create-pr-view.tsx
25285
- import { useCallback as useCallback12, useEffect as useEffect33, useState as useState40 } from "react";
26152
+ import { useCallback as useCallback12, useEffect as useEffect34, useState as useState40 } from "react";
25286
26153
  import { join as join46 } from "path";
25287
26154
  import { Box as Box48, Text as Text50, useInput as useInput27 } from "ink";
25288
26155
 
@@ -25805,7 +26672,7 @@ var CreatePrView = () => {
25805
26672
  { keys: "a", label: "toggle AI" },
25806
26673
  { keys: "esc", label: "back" }
25807
26674
  ]);
25808
- useEffect33(() => {
26675
+ useEffect34(() => {
25809
26676
  const load = async () => {
25810
26677
  if (selection.projectId === void 0 || selection.sprintId === void 0) {
25811
26678
  setPrep({ kind: "error", message: "No project or sprint selected." });
@@ -25937,7 +26804,7 @@ var CreatePrView = () => {
25937
26804
  };
25938
26805
 
25939
26806
  // src/application/ui/tui/views/welcome-view.tsx
25940
- import { useEffect as useEffect34, useRef as useRef10, useState as useState41 } from "react";
26807
+ import { useEffect as useEffect35, useRef as useRef11, useState as useState41 } from "react";
25941
26808
  import { Box as Box49, Text as Text51 } from "ink";
25942
26809
  import { jsx as jsx61, jsxs as jsxs50 } from "react/jsx-runtime";
25943
26810
  var PRESET_FOR_PROVIDER = {
@@ -25964,8 +26831,8 @@ var WelcomeView = () => {
25964
26831
  const [step, setStep] = useState41("detecting");
25965
26832
  const [chosenPreset, setChosenPreset] = useState41(void 0);
25966
26833
  const [errorMsg, setErrorMsg] = useState41(void 0);
25967
- const seededRef = useRef10(false);
25968
- useEffect34(() => {
26834
+ const seededRef = useRef11(false);
26835
+ useEffect35(() => {
25969
26836
  if (seededRef.current) return;
25970
26837
  seededRef.current = true;
25971
26838
  const seed = async () => {
@@ -26007,13 +26874,13 @@ var WelcomeView = () => {
26007
26874
  };
26008
26875
 
26009
26876
  // src/application/ui/tui/views/create-project-view.tsx
26010
- import { useEffect as useEffect36, useState as useState43 } from "react";
26877
+ import { useEffect as useEffect37, useState as useState43 } from "react";
26011
26878
  import { Box as Box51, Text as Text53 } from "ink";
26012
26879
  import { homedir as osHomedir2 } from "os";
26013
26880
  import { basename as basename5, join as join48 } from "path";
26014
26881
 
26015
26882
  // src/application/ui/tui/prompts/path-picker-prompt.tsx
26016
- import { useEffect as useEffect35, useState as useState42 } from "react";
26883
+ import { useEffect as useEffect36, useState as useState42 } from "react";
26017
26884
  import { promises as fs26 } from "fs";
26018
26885
  import { dirname as dirname18, join as join47 } from "path";
26019
26886
  import { homedir } from "os";
@@ -26038,7 +26905,7 @@ var PathPickerPrompt = ({
26038
26905
  const [showHidden, setShowHidden] = useState42(false);
26039
26906
  const [error, setError] = useState42(void 0);
26040
26907
  const [typing, setTyping] = useState42(false);
26041
- useEffect35(() => {
26908
+ useEffect36(() => {
26042
26909
  const load = async () => {
26043
26910
  try {
26044
26911
  const items = await fs26.readdir(cwd, { withFileTypes: true });
@@ -26057,7 +26924,7 @@ var PathPickerPrompt = ({
26057
26924
  { kind: "select" },
26058
26925
  ...entries.map((e) => ({ kind: "entry", entry: e }))
26059
26926
  ];
26060
- useEffect35(() => {
26927
+ useEffect36(() => {
26061
26928
  setCursor((c) => clamp5(c, 0, Math.max(0, rows.length - 1)));
26062
26929
  }, [rows.length]);
26063
26930
  useInput28(
@@ -26232,7 +27099,7 @@ var CreateProjectView = () => {
26232
27099
  const selection = useSelection();
26233
27100
  const ui = useUiState();
26234
27101
  const [step, setStep] = useState43({ kind: "name" });
26235
- useEffect36(() => ui.claimPrompt(), [ui.claimPrompt]);
27102
+ useEffect37(() => ui.claimPrompt(), [ui.claimPrompt]);
26236
27103
  const cancel = () => router.pop();
26237
27104
  const submit = async (s) => {
26238
27105
  setStep({ kind: "saving" });
@@ -26406,7 +27273,7 @@ var StepView = ({ step, onChange, onCancel, onSubmit }) => {
26406
27273
  };
26407
27274
 
26408
27275
  // src/application/ui/tui/views/add-repository-view.tsx
26409
- import { useEffect as useEffect37, useState as useState44 } from "react";
27276
+ import { useEffect as useEffect38, useState as useState44 } from "react";
26410
27277
  import { Box as Box52, Text as Text54 } from "ink";
26411
27278
  import { homedir as osHomedir3 } from "os";
26412
27279
  import { basename as basename6, join as join49 } from "path";
@@ -26435,7 +27302,7 @@ var AddRepositoryView = () => {
26435
27302
  const ui = useUiState();
26436
27303
  const { projectId } = useViewProps();
26437
27304
  const [step, setStep] = useState44({ kind: "path" });
26438
- useEffect37(() => {
27305
+ useEffect38(() => {
26439
27306
  if (step.kind === "path" || step.kind === "name" || step.kind === "confirm") {
26440
27307
  return ui.claimPrompt();
26441
27308
  }
@@ -26554,7 +27421,7 @@ var StepView2 = ({ step, onChange, onCancel, onSubmit }) => {
26554
27421
  };
26555
27422
 
26556
27423
  // src/application/ui/tui/views/add-ticket-view.tsx
26557
- import { useEffect as useEffect38, useState as useState45 } from "react";
27424
+ import { useEffect as useEffect39, useState as useState45 } from "react";
26558
27425
  import { Box as Box53, Text as Text55 } from "ink";
26559
27426
 
26560
27427
  // src/application/flows/ticket-add/flow.ts
@@ -26618,9 +27485,9 @@ var AddTicketView = () => {
26618
27485
  const ui = useUiState();
26619
27486
  const { sprintId } = useViewProps();
26620
27487
  const [step, setStep] = useState45({ kind: "link" });
26621
- useEffect38(() => ui.claimPrompt(), [ui.claimPrompt]);
27488
+ useEffect39(() => ui.claimPrompt(), [ui.claimPrompt]);
26622
27489
  const cancel = () => router.pop();
26623
- useEffect38(() => {
27490
+ useEffect39(() => {
26624
27491
  if (step.kind !== "fetching") return;
26625
27492
  const fetcher = deps.issueFetcher;
26626
27493
  if (fetcher === void 0) {
@@ -26846,7 +27713,7 @@ var runFetch = async (fetcher, url) => {
26846
27713
  };
26847
27714
 
26848
27715
  // src/application/ui/tui/views/pick-project-view.tsx
26849
- import { useEffect as useEffect39, useMemo as useMemo17, useState as useState46 } from "react";
27716
+ import { useEffect as useEffect40, useMemo as useMemo17, useState as useState46 } from "react";
26850
27717
  import { Box as Box54, Text as Text56, useInput as useInput29 } from "ink";
26851
27718
  import { jsx as jsx66, jsxs as jsxs55 } from "react/jsx-runtime";
26852
27719
  var PickProjectView = () => {
@@ -26871,10 +27738,10 @@ var PickProjectView = () => {
26871
27738
  return i === -1 ? 0 : i;
26872
27739
  }, [projects, selection.projectId]);
26873
27740
  const [cursor, setCursor] = useState46(initialIdx);
26874
- useEffect39(() => {
27741
+ useEffect40(() => {
26875
27742
  setCursor((c) => c >= projects.length ? Math.max(0, projects.length - 1) : c);
26876
27743
  }, [projects.length]);
26877
- useEffect39(() => {
27744
+ useEffect40(() => {
26878
27745
  setCursor(initialIdx);
26879
27746
  }, [initialIdx]);
26880
27747
  const pick = (project) => {
@@ -26955,7 +27822,7 @@ var PickProjectView = () => {
26955
27822
  };
26956
27823
 
26957
27824
  // src/application/ui/tui/views/pick-sprint-view.tsx
26958
- import { useEffect as useEffect40, useMemo as useMemo18, useState as useState47 } from "react";
27825
+ import { useEffect as useEffect41, useMemo as useMemo18, useState as useState47 } from "react";
26959
27826
  import { Box as Box55, Text as Text57, useInput as useInput30 } from "ink";
26960
27827
 
26961
27828
  // src/application/ui/tui/runtime/use-breakpoint.ts
@@ -27108,7 +27975,7 @@ var PickSprintView = () => {
27108
27975
  return cursorableRowIndices(rows)[0] ?? 0;
27109
27976
  }, [rows, selection.sprintId]);
27110
27977
  const [cursor, setCursor] = useState47(initialIdx);
27111
- useEffect40(() => {
27978
+ useEffect41(() => {
27112
27979
  setCursor((c) => {
27113
27980
  if (rows.length === 0) return 0;
27114
27981
  if (c >= rows.length) return Math.max(0, rows.length - 1);
@@ -27119,7 +27986,7 @@ var PickSprintView = () => {
27119
27986
  return c;
27120
27987
  });
27121
27988
  }, [rows]);
27122
- useEffect40(() => {
27989
+ useEffect41(() => {
27123
27990
  setCursor(initialIdx);
27124
27991
  }, [initialIdx]);
27125
27992
  const pick = (sprint) => {
@@ -27377,7 +28244,7 @@ var renderView = (entry) => {
27377
28244
  };
27378
28245
 
27379
28246
  // src/application/ui/tui/runtime/use-global-keys.ts
27380
- import { useEffect as useEffect41, useMemo as useMemo19, useRef as useRef11 } from "react";
28247
+ import { useEffect as useEffect42, useMemo as useMemo19, useRef as useRef12 } from "react";
27381
28248
  import { useApp, useInput as useInput31 } from "ink";
27382
28249
 
27383
28250
  // src/integration/io/clipboard.ts
@@ -27486,8 +28353,8 @@ var useGlobalKeys = (opts = {}) => {
27486
28353
  () => opts.copyToClipboard ?? createCopyToClipboard(),
27487
28354
  [opts.copyToClipboard]
27488
28355
  );
27489
- const clearTimerRef = useRef11(void 0);
27490
- useEffect41(
28356
+ const clearTimerRef = useRef12(void 0);
28357
+ useEffect42(
27491
28358
  () => () => {
27492
28359
  if (clearTimerRef.current !== void 0) clearTimeout(clearTimerRef.current);
27493
28360
  },
@@ -27588,20 +28455,20 @@ var emitClipboardBanner = (eventBus, spec) => {
27588
28455
  at: IsoTimestamp.now()
27589
28456
  });
27590
28457
  };
27591
- var scheduleClear = (eventBus, ref2) => {
27592
- if (ref2.current !== void 0) clearTimeout(ref2.current);
27593
- ref2.current = setTimeout(() => {
28458
+ var scheduleClear = (eventBus, ref3) => {
28459
+ if (ref3.current !== void 0) clearTimeout(ref3.current);
28460
+ ref3.current = setTimeout(() => {
27594
28461
  eventBus.publish({
27595
28462
  type: "banner-clear",
27596
28463
  id: CLIPBOARD_BANNER_ID,
27597
28464
  at: IsoTimestamp.now()
27598
28465
  });
27599
- ref2.current = void 0;
28466
+ ref3.current = void 0;
27600
28467
  }, CLIPBOARD_TOAST_DURATION_MS);
27601
28468
  };
27602
28469
 
27603
28470
  // src/application/ui/tui/components/memory-pressure-banner.tsx
27604
- import { useEffect as useEffect42, useState as useState48 } from "react";
28471
+ import { useEffect as useEffect43, useState as useState48 } from "react";
27605
28472
  import { Box as Box56, Text as Text58 } from "ink";
27606
28473
  import { jsxs as jsxs57 } from "react/jsx-runtime";
27607
28474
  var formatMb = (bytes) => `${(bytes / 1048576).toFixed(0)} MB`;
@@ -27609,7 +28476,7 @@ var formatPercent = (ratio) => `${Math.round(ratio * 100)}%`;
27609
28476
  var MemoryPressureBanner = () => {
27610
28477
  const deps = useDeps();
27611
28478
  const [latest, setLatest] = useState48(void 0);
27612
- useEffect42(() => {
28479
+ useEffect43(() => {
27613
28480
  const unsub = deps.eventBus.subscribe((event) => {
27614
28481
  if (event.type === "memory-pressure") setLatest(event);
27615
28482
  });
@@ -27633,13 +28500,13 @@ var MemoryPressureBanner = () => {
27633
28500
  };
27634
28501
 
27635
28502
  // src/application/ui/tui/components/chain-log-degraded-banner.tsx
27636
- import { useEffect as useEffect43, useState as useState49 } from "react";
28503
+ import { useEffect as useEffect44, useState as useState49 } from "react";
27637
28504
  import { Box as Box57, Text as Text59 } from "ink";
27638
28505
  import { jsx as jsx69, jsxs as jsxs58 } from "react/jsx-runtime";
27639
28506
  var ChainLogDegradedBanner = () => {
27640
28507
  const deps = useDeps();
27641
28508
  const [degraded, setDegraded] = useState49(false);
27642
- useEffect43(() => {
28509
+ useEffect44(() => {
27643
28510
  const unsub = deps.eventBus.subscribe((event) => {
27644
28511
  if (event.type === "chain-log-degraded") setDegraded(true);
27645
28512
  });
@@ -27655,7 +28522,7 @@ var ChainLogDegradedBanner = () => {
27655
28522
  // src/application/ui/tui/components/progress-overlay.tsx
27656
28523
  import { promises as fs27 } from "fs";
27657
28524
  import { join as join50 } from "path";
27658
- import { useEffect as useEffect44, useMemo as useMemo20, useState as useState50 } from "react";
28525
+ import { useEffect as useEffect45, useMemo as useMemo20, useState as useState50 } from "react";
27659
28526
  import { Box as Box58, Text as Text60, useInput as useInput32 } from "ink";
27660
28527
  import { jsx as jsx70, jsxs as jsxs59 } from "react/jsx-runtime";
27661
28528
  var CHROME_ROWS = 10;
@@ -27676,7 +28543,7 @@ var ProgressOverlay = () => {
27676
28543
  if (sprintId === void 0) return void 0;
27677
28544
  return join50(String(storage2.dataRoot), "sprints", String(sprintId), "progress.md");
27678
28545
  }, [sprintId, storage2.dataRoot]);
27679
- useEffect44(() => {
28546
+ useEffect45(() => {
27680
28547
  let cancelled = false;
27681
28548
  if (progressPath === void 0) {
27682
28549
  setState({ kind: "missing" });
@@ -27710,7 +28577,7 @@ var ProgressOverlay = () => {
27710
28577
  };
27711
28578
  }, [progressPath]);
27712
28579
  const lineCount = state.kind === "ok" ? state.lines.length : 0;
27713
- useEffect44(() => {
28580
+ useEffect45(() => {
27714
28581
  setOffset(0);
27715
28582
  }, [lineCount]);
27716
28583
  const bodyRows = Math.max(MIN_BODY_ROWS, term.rows - CHROME_ROWS);
@@ -28215,7 +29082,8 @@ var bootstrap = async () => {
28215
29082
  }
28216
29083
  };
28217
29084
  };
28218
- var launchTui = async () => {
29085
+ var launchTui = async (options = {}) => {
29086
+ setImplementRoleOverrides(options.implementRoleOverrides);
28219
29087
  let booted;
28220
29088
  try {
28221
29089
  booted = await bootstrap();
@@ -28236,6 +29104,54 @@ var launchTui = async () => {
28236
29104
  }
28237
29105
  };
28238
29106
 
29107
+ // src/application/ui/cli/parse-implement-role-overrides.ts
29108
+ var ALLOWED_PROVIDERS = /* @__PURE__ */ new Set(["claude-code", "github-copilot", "openai-codex"]);
29109
+ var isAiProvider2 = (v) => ALLOWED_PROVIDERS.has(v);
29110
+ var parseRole = (role, provider, model) => {
29111
+ if (provider !== void 0 && model === void 0) {
29112
+ return {
29113
+ ok: false,
29114
+ error: `--implement-${role}-provider requires --implement-${role}-model (both must be supplied together).`
29115
+ };
29116
+ }
29117
+ if (model !== void 0 && provider === void 0) {
29118
+ return {
29119
+ ok: false,
29120
+ error: `--implement-${role}-model requires --implement-${role}-provider (both must be supplied together).`
29121
+ };
29122
+ }
29123
+ if (provider === void 0 || model === void 0) {
29124
+ return { ok: true };
29125
+ }
29126
+ if (!isAiProvider2(provider)) {
29127
+ return {
29128
+ ok: false,
29129
+ error: `--implement-${role}-provider: '${provider}' is not a supported provider (claude-code | github-copilot | openai-codex).`
29130
+ };
29131
+ }
29132
+ const trimmedModel = model.trim();
29133
+ if (trimmedModel.length === 0) {
29134
+ return { ok: false, error: `--implement-${role}-model must be a non-empty string.` };
29135
+ }
29136
+ return { ok: true, row: { provider, model: trimmedModel } };
29137
+ };
29138
+ var parseImplementRoleOverrides = (input) => {
29139
+ const generator = parseRole("generator", input.generatorProvider, input.generatorModel);
29140
+ if (!generator.ok) return { ok: false, error: generator.error };
29141
+ const evaluator = parseRole("evaluator", input.evaluatorProvider, input.evaluatorModel);
29142
+ if (!evaluator.ok) return { ok: false, error: evaluator.error };
29143
+ if (generator.row === void 0 && evaluator.row === void 0) {
29144
+ return { ok: true, overrides: void 0 };
29145
+ }
29146
+ return {
29147
+ ok: true,
29148
+ overrides: {
29149
+ ...generator.row !== void 0 ? { generator: generator.row } : {},
29150
+ ...evaluator.row !== void 0 ? { evaluator: evaluator.row } : {}
29151
+ }
29152
+ };
29153
+ };
29154
+
28239
29155
  // src/integration/observability/sinks/null-sink.ts
28240
29156
  var nullSink = () => ({
28241
29157
  emit() {
@@ -28423,6 +29339,15 @@ var registerDoctorCommand = (program) => {
28423
29339
  };
28424
29340
 
28425
29341
  // src/application/ui/cli/commands/settings.ts
29342
+ var AI_PROVIDERS3 = ["claude-code", "github-copilot", "openai-codex"];
29343
+ var isAiProvider3 = (raw) => AI_PROVIDERS3.includes(raw);
29344
+ var parseProviderKey = (key) => {
29345
+ const implementMatch = /^ai\.implement\.(generator|evaluator)\.provider$/.exec(key);
29346
+ if (implementMatch !== null) return { flow: "implement", role: implementMatch[1] };
29347
+ const flatMatch = /^ai\.(refine|plan|readiness|ideate)\.provider$/.exec(key);
29348
+ if (flatMatch !== null) return { flow: flatMatch[1] };
29349
+ return void 0;
29350
+ };
28426
29351
  var registerSettingsCommand = (program) => {
28427
29352
  const settings = program.command("settings").description("inspect and mutate ralphctl settings");
28428
29353
  settings.command("show").description("print the current settings as JSON").action(async () => {
@@ -28440,6 +29365,36 @@ var registerSettingsCommand = (program) => {
28440
29365
  });
28441
29366
  settings.command("set <key> <value>").description("mutate one setting and persist (read-modify-write, schema-validated)").action(async (key, value) => {
28442
29367
  const { deps } = await bootstrapCli();
29368
+ const providerKey = parseProviderKey(key);
29369
+ if (providerKey !== void 0) {
29370
+ if (!isAiProvider3(value)) {
29371
+ process.stderr.write(
29372
+ `error: '${value}' is not a recognised provider (expected one of: ${AI_PROVIDERS3.join(", ")})
29373
+ `
29374
+ );
29375
+ process.exit(1);
29376
+ return;
29377
+ }
29378
+ const providerFlow = createSettingsSetProviderFlow({ settingsRepo: deps.settingsRepo });
29379
+ const saved2 = await providerFlow.execute({
29380
+ input: {
29381
+ flow: providerKey.flow,
29382
+ provider: value,
29383
+ ...providerKey.role !== void 0 ? { role: providerKey.role } : {}
29384
+ }
29385
+ });
29386
+ if (!saved2.ok) {
29387
+ const err = saved2.error.error;
29388
+ const hint = "hint" in err && typeof err.hint === "string" ? ` (${err.hint})` : "";
29389
+ process.stderr.write(`error: ${err.message}${hint}
29390
+ `);
29391
+ process.exit(1);
29392
+ return;
29393
+ }
29394
+ process.stdout.write(`${key} = ${value}
29395
+ `);
29396
+ return;
29397
+ }
28443
29398
  const showFlow = createSettingsShowFlow({ settingsRepo: deps.settingsRepo });
28444
29399
  const current = await showFlow.execute({ input: void 0 });
28445
29400
  if (!current.ok) {
@@ -29568,8 +30523,32 @@ var isYes = (answer) => {
29568
30523
  // src/application/ui/cli/cli.ts
29569
30524
  var runCli2 = async (argv) => {
29570
30525
  const program = new Command();
29571
- program.name("ralphctl").description("ralphctl \u2014 interactive TUI and CLI").version(CLI_METADATA.currentVersion, "-v, --version", "show version").action(async () => {
29572
- await launchTui();
30526
+ program.name("ralphctl").description("ralphctl \u2014 interactive TUI and CLI").version(CLI_METADATA.currentVersion, "-v, --version", "show version").option(
30527
+ "--implement-generator-provider <provider>",
30528
+ "override settings.ai.implement.generator.provider for this launch (requires --implement-generator-model)"
30529
+ ).option(
30530
+ "--implement-generator-model <model>",
30531
+ "override settings.ai.implement.generator.model for this launch (requires --implement-generator-provider)"
30532
+ ).option(
30533
+ "--implement-evaluator-provider <provider>",
30534
+ "override settings.ai.implement.evaluator.provider for this launch (requires --implement-evaluator-model)"
30535
+ ).option(
30536
+ "--implement-evaluator-model <model>",
30537
+ "override settings.ai.implement.evaluator.model for this launch (requires --implement-evaluator-provider)"
30538
+ ).action(async (opts) => {
30539
+ const parsed = parseImplementRoleOverrides({
30540
+ ...typeof opts.implementGeneratorProvider === "string" ? { generatorProvider: opts.implementGeneratorProvider } : {},
30541
+ ...typeof opts.implementGeneratorModel === "string" ? { generatorModel: opts.implementGeneratorModel } : {},
30542
+ ...typeof opts.implementEvaluatorProvider === "string" ? { evaluatorProvider: opts.implementEvaluatorProvider } : {},
30543
+ ...typeof opts.implementEvaluatorModel === "string" ? { evaluatorModel: opts.implementEvaluatorModel } : {}
30544
+ });
30545
+ if (!parsed.ok) {
30546
+ process.stderr.write(`ralphctl: ${parsed.error}
30547
+ `);
30548
+ process.exitCode = 1;
30549
+ return;
30550
+ }
30551
+ await launchTui({ ...parsed.overrides !== void 0 ? { implementRoleOverrides: parsed.overrides } : {} });
29573
30552
  });
29574
30553
  registerExportRequirementsCommand(program);
29575
30554
  registerExportContextCommand(program);