ralphctl 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  addCheckScriptToRepository,
4
4
  projectAddCommand
5
- } from "./chunk-PYZEQ2VK.mjs";
6
- import "./chunk-ZLWSPLWI.mjs";
5
+ } from "./chunk-BT5FKIZX.mjs";
6
+ import "./chunk-62HYDA7L.mjs";
7
7
  import "./chunk-CFUVE2BP.mjs";
8
8
  import "./chunk-747KW2RW.mjs";
9
9
  import "./chunk-BSB4EDGR.mjs";
@@ -1091,6 +1091,16 @@ ${ctx.existingAgentsMd}
1091
1091
  FILE_NAME: ctx.fileName
1092
1092
  });
1093
1093
  }
1094
+ function buildEvaluationResumePrompt(ctx) {
1095
+ const template = loadTemplate("task-evaluation-resume");
1096
+ const commitInstruction = ctx.needsCommit ? "\n - **Then commit the fix** with a descriptive message before signaling completion." : "";
1097
+ return composePrompt(template, {
1098
+ HARNESS_CONTEXT: loadPartial("harness-context"),
1099
+ SIGNALS: loadPartial("signals-task"),
1100
+ CRITIQUE: ctx.critique,
1101
+ COMMIT_INSTRUCTION: commitInstruction
1102
+ });
1103
+ }
1094
1104
 
1095
1105
  export {
1096
1106
  buildInteractivePrompt,
@@ -1103,6 +1113,7 @@ export {
1103
1113
  buildSprintFeedbackPrompt,
1104
1114
  buildCheckScriptDiscoverPrompt,
1105
1115
  buildRepoOnboardPrompt,
1116
+ buildEvaluationResumePrompt,
1106
1117
  processLifecycleAdapter,
1107
1118
  resolveProvider,
1108
1119
  providerDisplayName,
@@ -3,7 +3,7 @@ import {
3
3
  ProviderAiSessionAdapter,
4
4
  SignalParser,
5
5
  buildCheckScriptDiscoverPrompt
6
- } from "./chunk-ZLWSPLWI.mjs";
6
+ } from "./chunk-62HYDA7L.mjs";
7
7
  import {
8
8
  EXIT_ERROR,
9
9
  exitWithCode
@@ -3,6 +3,7 @@ import {
3
3
  ProviderAiSessionAdapter,
4
4
  SignalParser,
5
5
  buildAutoPrompt,
6
+ buildEvaluationResumePrompt,
6
7
  buildEvaluatorPrompt,
7
8
  buildIdeateAutoPrompt,
8
9
  buildIdeatePrompt,
@@ -13,7 +14,7 @@ import {
13
14
  buildTicketRefinePrompt,
14
15
  getActiveProvider,
15
16
  spawnInteractive
16
- } from "./chunk-ZLWSPLWI.mjs";
17
+ } from "./chunk-62HYDA7L.mjs";
17
18
  import {
18
19
  fetchIssueFromUrl,
19
20
  formatIssueContext,
@@ -1626,6 +1627,14 @@ function resolveCheckScriptForRepo(repo) {
1626
1627
  }
1627
1628
 
1628
1629
  // src/business/pipelines/steps/run-check-scripts.ts
1630
+ var ERROR_OUTPUT_TAIL_LINES = 100;
1631
+ function tailOutput(output, maxLines = ERROR_OUTPUT_TAIL_LINES) {
1632
+ const lines = output.split("\n");
1633
+ if (lines.length <= maxLines) return output;
1634
+ const hidden = lines.length - maxLines;
1635
+ return `[${String(hidden)} earlier line${hidden !== 1 ? "s" : ""} omitted]
1636
+ ${lines.slice(-maxLines).join("\n")}`;
1637
+ }
1629
1638
  function runCheckScriptsStep(external, persistence, mode, options) {
1630
1639
  return step("run-check-scripts", async (ctx) => {
1631
1640
  const sprint = ctx.sprint;
@@ -1646,15 +1655,17 @@ function runCheckScriptsStep(external, persistence, mode, options) {
1646
1655
  const checkScript = resolveCheckScriptForRepo(resolved?.repo);
1647
1656
  if (!resolved || !checkScript) continue;
1648
1657
  const { repo } = resolved;
1649
- const result = external.runCheckScript(repo.path, checkScript, "sprintStart", repo.checkTimeout);
1658
+ const result = await external.runCheckScript(repo.path, checkScript, "sprintStart", repo.checkTimeout);
1650
1659
  if (!result.passed) {
1651
1660
  checkResults[repoId] = {
1652
1661
  projectPath: repo.path,
1653
1662
  success: false,
1654
1663
  output: result.output
1655
1664
  };
1656
- return Result.error(new StorageError(`Check failed for ${repo.path}: ${checkScript}
1657
- ${result.output}`));
1665
+ return Result.error(
1666
+ new StorageError(`Check failed for ${repo.path}: ${checkScript}
1667
+ ${tailOutput(result.output)}`)
1668
+ );
1658
1669
  }
1659
1670
  const ranAt = (/* @__PURE__ */ new Date()).toISOString();
1660
1671
  sprint.checkRanAt[repoId] = ranAt;
@@ -1680,7 +1691,7 @@ ${result.output}`));
1680
1691
  return Result.ok(partial2);
1681
1692
  }
1682
1693
  const { repo } = resolved;
1683
- const result = external.runCheckScript(repo.path, checkScript, "taskComplete", repo.checkTimeout);
1694
+ const result = await external.runCheckScript(repo.path, checkScript, "taskComplete", repo.checkTimeout);
1684
1695
  checkResults[targetRepoId] = {
1685
1696
  projectPath: repo.path,
1686
1697
  success: result.passed,
@@ -1689,7 +1700,7 @@ ${result.output}`));
1689
1700
  if (!result.passed) {
1690
1701
  return Result.error(
1691
1702
  new StorageError(`Post-task check failed for ${repo.path}: ${checkScript}
1692
- ${result.output}`)
1703
+ ${tailOutput(result.output)}`)
1693
1704
  );
1694
1705
  }
1695
1706
  }
@@ -1892,7 +1903,7 @@ ${instructions}`;
1892
1903
  if (!resolved || !checkScript) return true;
1893
1904
  this.logger.info(`Running post-task check: ${checkScript}`);
1894
1905
  const { repo } = resolved;
1895
- const result = this.external.runCheckScript(repo.path, checkScript, "taskComplete", repo.checkTimeout);
1906
+ const result = await this.external.runCheckScript(repo.path, checkScript, "taskComplete", repo.checkTimeout);
1896
1907
  if (result.passed) {
1897
1908
  this.logger.success("Post-task check: passed");
1898
1909
  }
@@ -2478,10 +2489,9 @@ var EvaluateTaskUseCase = class {
2478
2489
  options
2479
2490
  );
2480
2491
  if (!fixSuccess) {
2481
- const reason = "Generator could not fix issues after feedback";
2482
- await this.persistEvaluationStub(sprintId, taskId, i + 2, reason);
2483
- break;
2492
+ log2.debug(`Fix attempt ${String(i + 1)}: generator did not signal completion \u2014 re-evaluating anyway`);
2484
2493
  }
2494
+ if (i === maxIterations - 1) break;
2485
2495
  const previousEvalResult = evalResult;
2486
2496
  const stopReeval = log2.time("evaluator-re-spawn");
2487
2497
  evalResult = await this.runSingleEvaluation(
@@ -2505,7 +2515,7 @@ var EvaluateTaskUseCase = class {
2505
2515
  }
2506
2516
  const finalStatus = plateaued ? "plateau" : evalResult.status;
2507
2517
  await this.updateTaskEvaluation(sprintId, taskId, evalResult, finalStatus);
2508
- this.reportResult(task.name, evalResult, maxIterations, plateaued);
2518
+ this.reportResult(task.name, evalResult, totalIterations, plateaued);
2509
2519
  return Result.ok({
2510
2520
  taskId,
2511
2521
  status: finalStatus,
@@ -2549,47 +2559,47 @@ var EvaluateTaskUseCase = class {
2549
2559
  }
2550
2560
  }
2551
2561
  /**
2552
- * Spawn a single evaluator session and parse the result.
2553
- *
2554
- * `checkScriptSection` and `projectToolingSection` are passed in
2555
- * pre-resolved — both are stable across fix-loop iterations, so the
2556
- * caller computes them once and threads them through.
2562
+ * Spawn a single evaluator session and parse the result. Stable inputs
2563
+ * (`checkScriptSection`, `projectToolingSection`) are passed in
2564
+ * pre-resolved so the fix loop doesn't re-compute them per iteration.
2557
2565
  */
2558
2566
  async runSingleEvaluation(task, sprint, repoPath, generatorModel, provider, checkScriptSection, projectToolingSection, options) {
2559
2567
  const evaluatorModel = getEvaluatorModel(generatorModel, provider);
2560
- const sprintDir = this.fs.getSprintDir(sprint.id);
2561
2568
  const prompt = this.promptBuilder.buildTaskEvaluationPrompt(
2562
2569
  task,
2563
2570
  repoPath,
2564
2571
  checkScriptSection,
2565
2572
  projectToolingSection
2566
2573
  );
2567
- const args = ["--add-dir", sprintDir];
2574
+ const args = ["--add-dir", this.fs.getSprintDir(sprint.id)];
2568
2575
  if (provider === "claude") {
2569
- if (evaluatorModel) {
2570
- args.push("--model", evaluatorModel);
2571
- }
2576
+ if (evaluatorModel) args.push("--model", evaluatorModel);
2572
2577
  args.push("--max-turns", String(options?.maxTurns ?? EVALUATOR_MAX_TURNS));
2573
2578
  }
2574
- let result;
2579
+ const result = await this.spawnOrNull(prompt, {
2580
+ cwd: repoPath,
2581
+ args,
2582
+ env: this.aiSession.getSpawnEnv(),
2583
+ abortSignal: options?.abortSignal
2584
+ });
2585
+ if (!result.ok) {
2586
+ this.logger.warning(`Evaluator spawn failed for ${task.name}: ${result.message} \u2014 marking malformed`);
2587
+ return { status: "malformed", dimensions: [], rawOutput: `Evaluator spawn failed: ${result.message}` };
2588
+ }
2589
+ return this.parser.parseEvaluation(result.value.output);
2590
+ }
2591
+ /**
2592
+ * Wrap `spawnWithRetry` in a try/catch so callers can handle spawn
2593
+ * failures without nested error handling. Returns a small discriminated
2594
+ * union — ok with the session result, or !ok with the message.
2595
+ */
2596
+ async spawnOrNull(prompt, opts) {
2575
2597
  try {
2576
- result = await this.aiSession.spawnWithRetry(prompt, {
2577
- cwd: repoPath,
2578
- args,
2579
- env: this.aiSession.getSpawnEnv(),
2580
- abortSignal: options?.abortSignal
2581
- });
2598
+ const value = await this.aiSession.spawnWithRetry(prompt, opts);
2599
+ return { ok: true, value };
2582
2600
  } catch (err) {
2583
- this.logger.warning(
2584
- `Evaluator spawn failed for ${task.name}: ${err instanceof Error ? err.message : String(err)} \u2014 marking malformed`
2585
- );
2586
- return {
2587
- status: "malformed",
2588
- dimensions: [],
2589
- rawOutput: `Evaluator spawn failed: ${err instanceof Error ? err.message : String(err)}`
2590
- };
2601
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
2591
2602
  }
2592
- return this.parser.parseEvaluation(result.output);
2593
2603
  }
2594
2604
  /**
2595
2605
  * Resolve the repo's `checkScript` and render it as the evaluator's
@@ -2618,33 +2628,39 @@ var EvaluateTaskUseCase = class {
2618
2628
  }
2619
2629
  /**
2620
2630
  * Resume the generator session with the evaluator critique.
2621
- * Returns true if the generator signaled completion.
2631
+ *
2632
+ * Two load-bearing properties (covered by `fix-loop fence` tests):
2633
+ * 1. Prompt comes from `buildTaskEvaluationResumePrompt` — full template
2634
+ * with signals / fix-protocol / commit instruction. A regression to
2635
+ * an inline string silently drops signal requirements.
2636
+ * 2. When `options.generatorSessionId` is set, `resumeSessionId` is
2637
+ * threaded so the fix continues the original session (`--resume`).
2638
+ * Absent an ID, spawn fresh and log at debug.
2639
+ *
2640
+ * Returns true iff the generator signaled `<task-complete>` — used as a
2641
+ * diagnostic only; the evaluator settles whether the fix actually worked.
2622
2642
  */
2623
2643
  async resumeGeneratorWithCritique(task, sprint, repoPath, critique, options) {
2624
- const sprintDir = this.fs.getSprintDir(sprint.id);
2625
- const resumePrompt = [
2626
- "The evaluator found issues with your implementation. Fix them and signal completion.",
2627
- "",
2628
- "## Evaluator Critique",
2629
- critique
2630
- ].join("\n");
2631
- let spinner = null;
2632
- try {
2633
- spinner = this.logger.spinner(`Fixing evaluation issues: ${task.name}`);
2634
- const result = await this.aiSession.spawnWithRetry(resumePrompt, {
2635
- cwd: repoPath,
2636
- args: ["--add-dir", sprintDir],
2637
- env: this.aiSession.getSpawnEnv(),
2638
- maxTurns: options?.maxTurns,
2639
- abortSignal: options?.abortSignal
2640
- });
2641
- spinner.succeed(`Fix attempt completed: ${task.name}`);
2642
- const signals = this.parser.parseExecutionSignals(result.output);
2643
- return signals.complete;
2644
- } catch {
2645
- spinner?.fail(`Fix attempt failed: ${task.name}`);
2644
+ const resumePrompt = this.promptBuilder.buildTaskEvaluationResumePrompt(critique, options?.needsCommit ?? true);
2645
+ const resumeSessionId = options?.generatorSessionId;
2646
+ this.logger.debug(
2647
+ resumeSessionId ? `Resuming generator session ${resumeSessionId} for fix attempt: ${task.name}` : `No generator session ID \u2014 spawning fresh fix attempt: ${task.name}`
2648
+ );
2649
+ const spinner = this.logger.spinner(`Fixing evaluation issues: ${task.name}`);
2650
+ const result = await this.spawnOrNull(resumePrompt, {
2651
+ cwd: repoPath,
2652
+ args: ["--add-dir", this.fs.getSprintDir(sprint.id)],
2653
+ env: this.aiSession.getSpawnEnv(),
2654
+ maxTurns: options?.maxTurns,
2655
+ resumeSessionId,
2656
+ abortSignal: options?.abortSignal
2657
+ });
2658
+ if (!result.ok) {
2659
+ spinner.fail(`Fix attempt failed: ${task.name}`);
2646
2660
  return false;
2647
2661
  }
2662
+ spinner.succeed(`Fix attempt completed: ${task.name}`);
2663
+ return this.parser.parseExecutionSignals(result.value.output).complete;
2648
2664
  }
2649
2665
  /**
2650
2666
  * Persist a real evaluation entry to the sidecar file.
@@ -2664,49 +2680,46 @@ var EvaluateTaskUseCase = class {
2664
2680
  }
2665
2681
  }
2666
2682
  /**
2667
- * Persist a stub entry when the fix loop bails early.
2668
- */
2669
- async persistEvaluationStub(sprintId, taskId, iteration, reason) {
2670
- try {
2671
- await this.persistence.writeEvaluation(sprintId, taskId, iteration, "failed", `_(no re-evaluation: ${reason})_`);
2672
- } catch {
2673
- this.logger.warning(`Could not persist evaluation stub for task ${taskId}`);
2674
- }
2675
- }
2676
- /**
2677
- * Update the task record with evaluation fields.
2678
- *
2679
- * `statusOverride` is set when plateau detection fires: the critique body
2680
- * is still saved (truncated) for traceability, but the discriminator in
2681
- * `tasks.json` records `'plateau'` so consumers can distinguish it from
2682
- * a plain `'failed'` run.
2683
+ * Update the task record with evaluation fields. `statusOverride` is set
2684
+ * for plateau — the body is still the real critique, but the status
2685
+ * column records `'plateau'` so readers can distinguish it from a plain
2686
+ * failure.
2683
2687
  */
2684
2688
  async updateTaskEvaluation(sprintId, taskId, evalResult, statusOverride) {
2685
- const status = statusOverride ?? evalResult.status;
2686
- const tasks = await this.persistence.getTasks(sprintId);
2687
- const updatedTasks = tasks.map(
2688
- (t) => t.id === taskId ? {
2689
- ...t,
2689
+ await this.persistence.updateTask(
2690
+ taskId,
2691
+ {
2690
2692
  evaluated: true,
2691
- evaluationStatus: status,
2693
+ evaluationStatus: statusOverride ?? evalResult.status,
2692
2694
  evaluationOutput: evalResult.rawOutput.slice(0, MAX_EVAL_OUTPUT)
2693
- } : t
2695
+ },
2696
+ sprintId
2694
2697
  );
2695
- await this.persistence.saveTasks(updatedTasks, sprintId);
2696
2698
  }
2697
2699
  /**
2698
2700
  * Report the evaluation outcome to the user.
2701
+ *
2702
+ * `totalIterations` is the *actual* number of evaluator spawns (initial +
2703
+ * any re-evaluations after fix attempts), NOT the configured maximum.
2704
+ * When the loop breaks early (plateau), runs out of fix budget, or skips
2705
+ * the final re-eval, these two diverge — and the log line must reflect
2706
+ * reality so "6 fix attempts" never shows up when only 1 actually ran.
2707
+ *
2708
+ * The evaluator is advisory: a failing outcome doesn't stop the task
2709
+ * from being marked done; the sprint proceeds. The critique is persisted
2710
+ * in the sidecar for later review, and the warning log lets the user
2711
+ * see what didn't pass without scrolling the evaluations directory.
2699
2712
  */
2700
- reportResult(taskName, evalResult, maxIterations, plateaued) {
2713
+ reportResult(taskName, evalResult, totalIterations, plateaued) {
2701
2714
  if (plateaued) {
2702
2715
  this.logger.warning(
2703
- `Evaluation plateaued on the same failures \u2014 further fix attempts were skipped. Marking done: ${taskName}`
2716
+ `Evaluation plateaued on the same failures after ${String(totalIterations)} iteration(s): ${taskName}`
2704
2717
  );
2705
2718
  } else if (evalResult.status === "malformed") {
2706
- this.logger.warning(`Evaluator output was malformed for ${taskName} \u2014 marking done`);
2719
+ this.logger.warning(`Evaluator output was malformed for ${taskName}`);
2707
2720
  } else if (!isPassed(evalResult)) {
2708
2721
  this.logger.warning(
2709
- `Evaluation did not pass after ${String(maxIterations)} fix attempt(s) \u2014 marking done: ${taskName}`
2722
+ `Evaluation did not pass after ${String(totalIterations)} iteration(s) \u2014 marking done: ${taskName}`
2710
2723
  );
2711
2724
  } else {
2712
2725
  this.logger.success(`Evaluation passed: ${taskName}`);
@@ -2729,40 +2742,24 @@ function loadTaskStep(persistence) {
2729
2742
  function checkAlreadyEvaluatedStep(options) {
2730
2743
  return step("check-already-evaluated", (ctx) => {
2731
2744
  const task = ctx.tasks?.[0];
2732
- if (!task) {
2733
- const empty2 = {};
2734
- return Result.ok(empty2);
2735
- }
2736
- if (task.evaluated && !options.force) {
2737
- const summary = {
2738
- taskId: task.id,
2739
- status: "skipped",
2740
- iterations: 0
2741
- };
2742
- const partial = { evaluationSummary: summary };
2743
- return Result.ok(partial);
2745
+ if (task && task.evaluated && !options.force) {
2746
+ const summary = { taskId: task.id, status: "skipped", iterations: 0 };
2747
+ return Result.ok({ evaluationSummary: summary });
2744
2748
  }
2745
- const empty = {};
2746
- return Result.ok(empty);
2749
+ return Result.ok({});
2747
2750
  });
2748
2751
  }
2749
2752
  function runEvaluatorLoopStep(useCase, options) {
2750
2753
  return step("run-evaluator-loop", async (ctx) => {
2751
2754
  if (ctx.evaluationSummary?.status === "skipped") {
2752
- const empty = {};
2753
- return Result.ok(empty);
2755
+ return Result.ok({});
2754
2756
  }
2755
2757
  const result = await useCase.execute(ctx.sprintId, ctx.taskId, {
2756
- iterations: options.iterations,
2757
- maxTurns: options.maxTurns,
2758
- fallbackModel: ctx.generatorModel ?? void 0,
2758
+ ...options,
2759
2759
  abortSignal: ctx.abortSignal ?? options.abortSignal
2760
2760
  });
2761
- if (!result.ok) {
2762
- return Result.error(result.error);
2763
- }
2764
- const partial = { evaluationSummary: result.value };
2765
- return Result.ok(partial);
2761
+ if (!result.ok) return Result.error(result.error);
2762
+ return Result.ok({ evaluationSummary: result.value });
2766
2763
  });
2767
2764
  }
2768
2765
  function createEvaluatorPipeline(deps, options = {}) {
@@ -2788,10 +2785,7 @@ function createEvaluatorPipeline(deps, options = {}) {
2788
2785
  function evaluateTask(deps) {
2789
2786
  return step("evaluate-task", async (ctx) => {
2790
2787
  const evalCfg = await deps.useCase.getEvaluationConfig(deps.options);
2791
- if (!evalCfg.enabled) {
2792
- const empty = {};
2793
- return Result.ok(empty);
2794
- }
2788
+ if (!evalCfg.enabled) return Result.ok({});
2795
2789
  const innerPipeline = createEvaluatorPipeline(
2796
2790
  {
2797
2791
  persistence: deps.persistence,
@@ -2806,38 +2800,52 @@ function evaluateTask(deps) {
2806
2800
  {
2807
2801
  iterations: evalCfg.iterations,
2808
2802
  maxTurns: deps.options.maxTurns,
2803
+ // noCommit (ExecutionOptions) inverted — if the generator committed
2804
+ // the initial work, the fix must commit too.
2805
+ needsCommit: !deps.options.noCommit,
2806
+ // Model ladder input: evaluator uses a cheaper model than the
2807
+ // generator's. Null when the generator didn't report one (Copilot,
2808
+ // blocked tasks).
2809
+ fallbackModel: ctx.generatorModel ?? void 0,
2810
+ // --resume <id> so the fix continues the generator's session
2811
+ // rather than cold-starting. Undefined → fresh spawn (rare fallback).
2812
+ generatorSessionId: ctx.executionResult?.sessionId,
2809
2813
  abortSignal: ctx.abortSignal
2810
2814
  }
2811
2815
  );
2812
2816
  const innerCtx = {
2813
2817
  sprintId: ctx.sprint.id,
2814
2818
  taskId: ctx.task.id,
2815
- generatorModel: ctx.generatorModel ?? null,
2816
2819
  abortSignal: ctx.abortSignal
2817
2820
  };
2818
- let stepNames = [];
2819
2821
  try {
2820
- const result = await executePipeline(innerPipeline, innerCtx);
2821
- stepNames = result.ok ? result.value.stepResults.map((r) => r.stepName) : (
2822
- // Even on failure the framework populates stepResults up to and
2823
- // including the failing step. Extract them opportunistically —
2824
- // if unavailable, proceed with an empty list.
2825
- []
2826
- );
2827
- if (!result.ok) {
2822
+ const innerResult = await executePipeline(innerPipeline, innerCtx);
2823
+ if (!innerResult.ok) {
2828
2824
  deps.logger.warning(
2829
- `Evaluation failed for ${ctx.task.name}: ${result.error.message}. Proceeding with task completion.`
2825
+ `Evaluator pipeline errored for ${ctx.task.name}: ${innerResult.error.message} \u2014 proceeding with task completion`
2830
2826
  );
2827
+ return Result.ok({ evaluationStepNames: [] });
2831
2828
  }
2829
+ logIfNonTerminal(deps.logger, ctx.task.name, innerResult.value.context.evaluationSummary);
2830
+ return Result.ok({
2831
+ evaluationStepNames: innerResult.value.stepResults.map((r) => r.stepName)
2832
+ });
2832
2833
  } catch (err) {
2833
2834
  deps.logger.warning(
2834
- `Evaluator threw for ${ctx.task.name}: ${err instanceof Error ? err.message : String(err)}. Proceeding with task completion.`
2835
+ `Evaluator threw for ${ctx.task.name}: ${err instanceof Error ? err.message : String(err)} \u2014 proceeding with task completion`
2835
2836
  );
2837
+ return Result.ok({ evaluationStepNames: [] });
2836
2838
  }
2837
- const partial = { evaluationStepNames: stepNames };
2838
- return Result.ok(partial);
2839
2839
  });
2840
2840
  }
2841
+ function logIfNonTerminal(logger, taskName, summary) {
2842
+ if (!summary) return;
2843
+ if (summary.status === "failed" || summary.status === "malformed" || summary.status === "plateau") {
2844
+ logger.warning(
2845
+ `Evaluation ${summary.status} for ${taskName} after ${String(summary.iterations)} iteration(s) \u2014 proceeding with task completion`
2846
+ );
2847
+ }
2848
+ }
2841
2849
 
2842
2850
  // src/business/pipelines/execute/steps/recover-dirty-tree.ts
2843
2851
  function recoverDirtyTree2(deps) {
@@ -4023,6 +4031,9 @@ var TextPromptBuilderAdapter = class {
4023
4031
  extraDimensions: task.extraDimensions ?? []
4024
4032
  });
4025
4033
  }
4034
+ buildTaskEvaluationResumePrompt(critique, needsCommit) {
4035
+ return buildEvaluationResumePrompt({ critique, needsCommit });
4036
+ }
4026
4037
  buildFeedbackPrompt(sprintName, completedTasks, feedback, branch) {
4027
4038
  return buildSprintFeedbackPrompt(sprintName, completedTasks, feedback, branch);
4028
4039
  }
@@ -4608,8 +4619,9 @@ function describeMcpHint(name) {
4608
4619
  }
4609
4620
 
4610
4621
  // src/integration/external/lifecycle.ts
4611
- import { spawnSync } from "child_process";
4622
+ import { spawn } from "child_process";
4612
4623
  var DEFAULT_HOOK_TIMEOUT_MS = 5 * 60 * 1e3;
4624
+ var MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
4613
4625
  function getHookTimeoutMs() {
4614
4626
  const envVal = process.env["RALPHCTL_SETUP_TIMEOUT_MS"];
4615
4627
  if (envVal) {
@@ -4621,16 +4633,76 @@ function getHookTimeoutMs() {
4621
4633
  function runLifecycleHook(projectPath, script, event, timeoutOverrideMs) {
4622
4634
  assertSafeCwd(projectPath);
4623
4635
  const timeoutMs = timeoutOverrideMs ?? getHookTimeoutMs();
4624
- const result = spawnSync(script, {
4625
- cwd: projectPath,
4626
- shell: true,
4627
- stdio: ["pipe", "pipe", "pipe"],
4628
- encoding: "utf-8",
4629
- timeout: timeoutMs,
4630
- env: { ...process.env, RALPHCTL_LIFECYCLE_EVENT: event }
4636
+ return new Promise((resolve) => {
4637
+ const child = spawn(script, {
4638
+ cwd: projectPath,
4639
+ shell: true,
4640
+ detached: process.platform !== "win32",
4641
+ stdio: ["pipe", "pipe", "pipe"],
4642
+ env: { ...process.env, RALPHCTL_LIFECYCLE_EVENT: event }
4643
+ });
4644
+ const killTree = () => {
4645
+ if (process.platform !== "win32" && typeof child.pid === "number") {
4646
+ try {
4647
+ process.kill(-child.pid, "SIGTERM");
4648
+ return;
4649
+ } catch {
4650
+ }
4651
+ }
4652
+ try {
4653
+ child.kill("SIGTERM");
4654
+ } catch {
4655
+ }
4656
+ };
4657
+ const chunks = [];
4658
+ let totalBytes = 0;
4659
+ let timedOut = false;
4660
+ let capExceeded = false;
4661
+ let settled = false;
4662
+ const appendChunk = (chunk) => {
4663
+ if (capExceeded) return;
4664
+ totalBytes += chunk.length;
4665
+ if (totalBytes > MAX_OUTPUT_BYTES) {
4666
+ capExceeded = true;
4667
+ killTree();
4668
+ return;
4669
+ }
4670
+ chunks.push(chunk);
4671
+ };
4672
+ child.stdout.on("data", (chunk) => {
4673
+ appendChunk(chunk);
4674
+ });
4675
+ child.stderr.on("data", (chunk) => {
4676
+ appendChunk(chunk);
4677
+ });
4678
+ const timer = setTimeout(() => {
4679
+ timedOut = true;
4680
+ killTree();
4681
+ }, timeoutMs);
4682
+ const finish = (passed, suffix) => {
4683
+ if (settled) return;
4684
+ settled = true;
4685
+ clearTimeout(timer);
4686
+ const base = Buffer.concat(chunks).toString("utf-8").trim();
4687
+ const output = suffix ? base ? `${base}
4688
+ ${suffix}` : suffix : base;
4689
+ resolve({ passed, output });
4690
+ };
4691
+ child.on("error", (err) => {
4692
+ finish(false, `[spawn error: ${err.message}]`);
4693
+ });
4694
+ child.on("close", (code) => {
4695
+ if (timedOut) {
4696
+ finish(false, `[timeout exceeded after ${String(timeoutMs)}ms]`);
4697
+ return;
4698
+ }
4699
+ if (capExceeded) {
4700
+ finish(false, `[output exceeded ${String(MAX_OUTPUT_BYTES)} byte cap \u2014 truncated]`);
4701
+ return;
4702
+ }
4703
+ finish(code === 0);
4704
+ });
4631
4705
  });
4632
- const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4633
- return { passed: result.status === 0, output };
4634
4706
  }
4635
4707
 
4636
4708
  // src/integration/ai/task-context.ts
@@ -4650,7 +4722,7 @@ function getRecentGitHistory(projectPath, count = 20) {
4650
4722
  }
4651
4723
 
4652
4724
  // src/integration/external/git.ts
4653
- import { spawnSync as spawnSync2 } from "child_process";
4725
+ import { spawnSync } from "child_process";
4654
4726
  var BRANCH_NAME_RE = /^[a-zA-Z0-9/_.-]+$/;
4655
4727
  var BRANCH_NAME_INVALID_PATTERNS = [/\.\./, /\.$/, /\/$/, /\.lock$/, /^-/, /\/\//];
4656
4728
  function isValidBranchName(name) {
@@ -4663,7 +4735,7 @@ function isValidBranchName(name) {
4663
4735
  }
4664
4736
  function getCurrentBranch(cwd) {
4665
4737
  assertSafeCwd(cwd);
4666
- const result = spawnSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
4738
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
4667
4739
  cwd,
4668
4740
  encoding: "utf-8",
4669
4741
  stdio: ["pipe", "pipe", "pipe"]
@@ -4678,7 +4750,7 @@ function branchExists(cwd, name) {
4678
4750
  if (!isValidBranchName(name)) {
4679
4751
  throw new Error(`Invalid branch name: ${name}`);
4680
4752
  }
4681
- const result = spawnSync2("git", ["show-ref", "--verify", `refs/heads/${name}`], {
4753
+ const result = spawnSync("git", ["show-ref", "--verify", `refs/heads/${name}`], {
4682
4754
  cwd,
4683
4755
  encoding: "utf-8",
4684
4756
  stdio: ["pipe", "pipe", "pipe"]
@@ -4695,7 +4767,7 @@ function createAndCheckoutBranch(cwd, name) {
4695
4767
  return;
4696
4768
  }
4697
4769
  if (branchExists(cwd, name)) {
4698
- const result = spawnSync2("git", ["checkout", name], {
4770
+ const result = spawnSync("git", ["checkout", name], {
4699
4771
  cwd,
4700
4772
  encoding: "utf-8",
4701
4773
  stdio: ["pipe", "pipe", "pipe"]
@@ -4704,7 +4776,7 @@ function createAndCheckoutBranch(cwd, name) {
4704
4776
  throw new Error(`Failed to checkout branch '${name}' in ${cwd}: ${result.stderr.trim()}`);
4705
4777
  }
4706
4778
  } else {
4707
- const result = spawnSync2("git", ["checkout", "-b", name], {
4779
+ const result = spawnSync("git", ["checkout", "-b", name], {
4708
4780
  cwd,
4709
4781
  encoding: "utf-8",
4710
4782
  stdio: ["pipe", "pipe", "pipe"]
@@ -4720,7 +4792,7 @@ function verifyCurrentBranch(cwd, expected) {
4720
4792
  }
4721
4793
  function getDefaultBranch(cwd) {
4722
4794
  assertSafeCwd(cwd);
4723
- const result = spawnSync2("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
4795
+ const result = spawnSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
4724
4796
  cwd,
4725
4797
  encoding: "utf-8",
4726
4798
  stdio: ["pipe", "pipe", "pipe"]
@@ -4741,7 +4813,7 @@ function getDefaultBranch(cwd) {
4741
4813
  function getHeadSha(cwd) {
4742
4814
  try {
4743
4815
  assertSafeCwd(cwd);
4744
- const result = spawnSync2("git", ["rev-parse", "HEAD"], {
4816
+ const result = spawnSync("git", ["rev-parse", "HEAD"], {
4745
4817
  cwd,
4746
4818
  encoding: "utf-8",
4747
4819
  stdio: ["pipe", "pipe", "pipe"]
@@ -4754,7 +4826,7 @@ function getHeadSha(cwd) {
4754
4826
  }
4755
4827
  function hasUncommittedChanges(cwd) {
4756
4828
  assertSafeCwd(cwd);
4757
- const result = spawnSync2("git", ["status", "--porcelain"], {
4829
+ const result = spawnSync("git", ["status", "--porcelain"], {
4758
4830
  cwd,
4759
4831
  encoding: "utf-8",
4760
4832
  stdio: ["pipe", "pipe", "pipe"]
@@ -4766,7 +4838,7 @@ function hasUncommittedChanges(cwd) {
4766
4838
  }
4767
4839
  function hardResetWorkingTree(cwd) {
4768
4840
  assertSafeCwd(cwd);
4769
- const reset = spawnSync2("git", ["reset", "--hard", "HEAD"], {
4841
+ const reset = spawnSync("git", ["reset", "--hard", "HEAD"], {
4770
4842
  cwd,
4771
4843
  encoding: "utf-8",
4772
4844
  stdio: ["pipe", "pipe", "pipe"]
@@ -4774,7 +4846,7 @@ function hardResetWorkingTree(cwd) {
4774
4846
  if (reset.status !== 0) {
4775
4847
  throw new StorageError(`Failed to reset working tree in ${cwd}: ${reset.stderr.trim() || reset.stdout.trim()}`);
4776
4848
  }
4777
- const clean = spawnSync2("git", ["clean", "-fd"], {
4849
+ const clean = spawnSync("git", ["clean", "-fd"], {
4778
4850
  cwd,
4779
4851
  encoding: "utf-8",
4780
4852
  stdio: ["pipe", "pipe", "pipe"]
@@ -4785,7 +4857,7 @@ function hardResetWorkingTree(cwd) {
4785
4857
  }
4786
4858
  function autoCommit(cwd, message) {
4787
4859
  assertSafeCwd(cwd);
4788
- const add = spawnSync2("git", ["add", "-A"], {
4860
+ const add = spawnSync("git", ["add", "-A"], {
4789
4861
  cwd,
4790
4862
  encoding: "utf-8",
4791
4863
  stdio: ["pipe", "pipe", "pipe"]
@@ -4793,7 +4865,7 @@ function autoCommit(cwd, message) {
4793
4865
  if (add.status !== 0) {
4794
4866
  throw new Error(`Failed to stage changes in ${cwd}: ${add.stderr.trim()}`);
4795
4867
  }
4796
- const commit = spawnSync2("git", ["commit", "-m", message], {
4868
+ const commit = spawnSync("git", ["commit", "-m", message], {
4797
4869
  cwd,
4798
4870
  encoding: "utf-8",
4799
4871
  stdio: ["pipe", "pipe", "pipe"]
@@ -4806,14 +4878,14 @@ function generateBranchName(sprintId) {
4806
4878
  return `ralphctl/${sprintId}`;
4807
4879
  }
4808
4880
  function isGhAvailable() {
4809
- const result = spawnSync2("gh", ["--version"], {
4881
+ const result = spawnSync("gh", ["--version"], {
4810
4882
  encoding: "utf-8",
4811
4883
  stdio: ["pipe", "pipe", "pipe"]
4812
4884
  });
4813
4885
  return result.status === 0;
4814
4886
  }
4815
4887
  function isGlabAvailable() {
4816
- const result = spawnSync2("glab", ["--version"], {
4888
+ const result = spawnSync("glab", ["--version"], {
4817
4889
  encoding: "utf-8",
4818
4890
  stdio: ["pipe", "pipe", "pipe"]
4819
4891
  });
@@ -6,7 +6,7 @@ import {
6
6
  getAllConfigSchemaEntries,
7
7
  getConfigDefaultValue,
8
8
  parseConfigValue
9
- } from "./chunk-PYZEQ2VK.mjs";
9
+ } from "./chunk-BT5FKIZX.mjs";
10
10
  import {
11
11
  editorInput
12
12
  } from "./chunk-OGEXYSFS.mjs";
@@ -41,14 +41,14 @@ import {
41
41
  updateTask,
42
42
  updateTaskStatus,
43
43
  validateImportTasks
44
- } from "./chunk-OFILN7QL.mjs";
44
+ } from "./chunk-D6QZNEYN.mjs";
45
45
  import {
46
46
  SignalParser,
47
47
  buildTicketRefinePrompt,
48
48
  processLifecycleAdapter,
49
49
  providerDisplayName,
50
50
  resolveProvider
51
- } from "./chunk-ZLWSPLWI.mjs";
51
+ } from "./chunk-62HYDA7L.mjs";
52
52
  import {
53
53
  fetchIssueFromUrl,
54
54
  formatIssueContext,
@@ -176,6 +176,7 @@ import {
176
176
  ProjectNotFoundError,
177
177
  SprintNotFoundError,
178
178
  SprintStatusError,
179
+ StepError,
179
180
  StorageError,
180
181
  TaskNotFoundError,
181
182
  TicketNotFoundError
@@ -184,7 +185,7 @@ import {
184
185
  // package.json
185
186
  var package_default = {
186
187
  name: "ralphctl",
187
- version: "0.4.5",
188
+ version: "0.5.0",
188
189
  description: "Agent harness for long-running AI coding tasks \u2014 orchestrates Claude Code & GitHub Copilot across repositories",
189
190
  homepage: "https://github.com/lukas-grigis/ralphctl",
190
191
  type: "module",
@@ -2068,12 +2069,20 @@ var InMemoryExecutionRegistry = class {
2068
2069
  this.transition(executionId, "completed", summary ?? void 0);
2069
2070
  }
2070
2071
  } catch (err) {
2071
- void err;
2072
2072
  if (abortSignal.aborted) {
2073
2073
  this.transition(executionId, "cancelled");
2074
- } else {
2075
- this.transition(executionId, "failed");
2074
+ return;
2076
2075
  }
2076
+ const errInfo = err instanceof StepError ? { message: err.message, stepName: err.stepName } : { message: err instanceof Error ? err.message : String(err) };
2077
+ const entry = this.entries.get(executionId);
2078
+ entry?.logEventBus.emit({
2079
+ kind: "log",
2080
+ level: "error",
2081
+ message: errInfo.stepName ? `[${errInfo.stepName}] ${errInfo.message}` : errInfo.message,
2082
+ context: {},
2083
+ timestamp: /* @__PURE__ */ new Date()
2084
+ });
2085
+ this.transition(executionId, "failed", void 0, errInfo);
2077
2086
  }
2078
2087
  }
2079
2088
  get(id) {
@@ -2100,7 +2109,7 @@ var InMemoryExecutionRegistry = class {
2100
2109
  getLogEventBus(id) {
2101
2110
  return this.entries.get(id)?.logEventBus ?? null;
2102
2111
  }
2103
- transition(executionId, status, summary) {
2112
+ transition(executionId, status, summary, error2) {
2104
2113
  const entry = this.entries.get(executionId);
2105
2114
  if (!entry) return;
2106
2115
  if (entry.execution.status === status) return;
@@ -2108,7 +2117,8 @@ var InMemoryExecutionRegistry = class {
2108
2117
  ...entry.execution,
2109
2118
  status,
2110
2119
  endedAt: /* @__PURE__ */ new Date(),
2111
- summary: summary ?? entry.execution.summary
2120
+ summary: summary ?? entry.execution.summary,
2121
+ error: error2 ?? entry.execution.error
2112
2122
  };
2113
2123
  entry.execution = next;
2114
2124
  this.notify(next);
@@ -2204,7 +2214,7 @@ async function selectProject(message = "Select project:") {
2204
2214
  default: true
2205
2215
  });
2206
2216
  if (create) {
2207
- const { projectAddCommand } = await import("./add-YVXM34RP.mjs");
2217
+ const { projectAddCommand } = await import("./add-67UFUI54.mjs");
2208
2218
  await projectAddCommand({ interactive: true });
2209
2219
  const updated = await listProjects();
2210
2220
  if (updated.length === 0) return null;
package/dist/cli.mjs CHANGED
@@ -41,10 +41,10 @@ import {
41
41
  ticketRefineCommand,
42
42
  ticketRemoveCommand,
43
43
  ticketShowCommand
44
- } from "./chunk-XPLYLRIM.mjs";
44
+ } from "./chunk-ZE2BRQA2.mjs";
45
45
  import {
46
46
  projectAddCommand
47
- } from "./chunk-PYZEQ2VK.mjs";
47
+ } from "./chunk-BT5FKIZX.mjs";
48
48
  import {
49
49
  sprintCreateCommand
50
50
  } from "./chunk-FNAAA32W.mjs";
@@ -56,8 +56,8 @@ import {
56
56
  executePipeline,
57
57
  getTasks,
58
58
  sprintStartCommand
59
- } from "./chunk-OFILN7QL.mjs";
60
- import "./chunk-ZLWSPLWI.mjs";
59
+ } from "./chunk-D6QZNEYN.mjs";
60
+ import "./chunk-62HYDA7L.mjs";
61
61
  import {
62
62
  truncate
63
63
  } from "./chunk-GQ2WFKBN.mjs";
@@ -756,7 +756,7 @@ async function main() {
756
756
  const isBare = argv.length <= 2;
757
757
  const isInteractive = argv[2] === "interactive";
758
758
  if (isBare || isInteractive) {
759
- const { mountInkApp } = await import("./mount-H2IH3MWE.mjs");
759
+ const { mountInkApp } = await import("./mount-NCYR22SN.mjs");
760
760
  const { fallback } = await mountInkApp({ initialView: "repl" });
761
761
  if (!fallback) return;
762
762
  printBanner();
@@ -767,10 +767,10 @@ async function main() {
767
767
  return;
768
768
  }
769
769
  if (argv[2] === "sprint" && argv[3] === "start") {
770
- const { parseSprintStartArgs } = await import("./start-2WH4BTDB.mjs");
770
+ const { parseSprintStartArgs } = await import("./start-T34NI3LF.mjs");
771
771
  const parsed = parseSprintStartArgs(argv.slice(4));
772
772
  if (parsed.ok) {
773
- const { mountInkApp } = await import("./mount-H2IH3MWE.mjs");
773
+ const { mountInkApp } = await import("./mount-NCYR22SN.mjs");
774
774
  const { getSharedDeps: getSharedDeps2 } = await import("./bootstrap-FMHG6DRY.mjs");
775
775
  let sprintId;
776
776
  try {
@@ -62,7 +62,7 @@ import {
62
62
  ticketRemoveCommand,
63
63
  ticketShowCommand,
64
64
  useCurrentPrompt
65
- } from "./chunk-XPLYLRIM.mjs";
65
+ } from "./chunk-ZE2BRQA2.mjs";
66
66
  import {
67
67
  PromptCancelledError,
68
68
  detectCheckScriptCandidates,
@@ -73,7 +73,7 @@ import {
73
73
  projectAddCommand,
74
74
  suggestCheckScript,
75
75
  validateConfigValue
76
- } from "./chunk-PYZEQ2VK.mjs";
76
+ } from "./chunk-BT5FKIZX.mjs";
77
77
  import {
78
78
  sprintCreateCommand
79
79
  } from "./chunk-FNAAA32W.mjs";
@@ -99,7 +99,7 @@ import {
99
99
  reorderTask,
100
100
  sprintStartCommand,
101
101
  updateTaskStatus
102
- } from "./chunk-OFILN7QL.mjs";
102
+ } from "./chunk-D6QZNEYN.mjs";
103
103
  import {
104
104
  ProviderAiSessionAdapter,
105
105
  SignalParser,
@@ -107,7 +107,7 @@ import {
107
107
  exitAltScreen,
108
108
  registerTuiInstance,
109
109
  withSuspendedTui
110
- } from "./chunk-ZLWSPLWI.mjs";
110
+ } from "./chunk-62HYDA7L.mjs";
111
111
  import {
112
112
  addTicket,
113
113
  allRequirementsApproved,
@@ -189,7 +189,7 @@ import { render } from "ink";
189
189
 
190
190
  // src/integration/ui/tui/views/app.tsx
191
191
  import { useEffect as useEffect37, useState as useState38 } from "react";
192
- import { Box as Box37, useStdout } from "ink";
192
+ import { Box as Box37, useStdout as useStdout2 } from "ink";
193
193
 
194
194
  // src/integration/ui/tui/views/view-router.tsx
195
195
  import { useCallback as useCallback12, useMemo as useMemo34, useState as useState37 } from "react";
@@ -1606,7 +1606,7 @@ function SettingsView() {
1606
1606
 
1607
1607
  // src/integration/ui/tui/views/execute-view.tsx
1608
1608
  import { useEffect as useEffect8, useMemo as useMemo6, useRef as useRef2, useState as useState8 } from "react";
1609
- import { Box as Box19, Text as Text18, useInput as useInput6 } from "ink";
1609
+ import { Box as Box19, Text as Text18, useInput as useInput6, useStdout } from "ink";
1610
1610
 
1611
1611
  // src/integration/ui/tui/runtime/hooks.ts
1612
1612
  import { useCallback as useCallback4, useEffect as useEffect6, useRef, useState as useState6 } from "react";
@@ -1908,8 +1908,12 @@ function renderLine(event, index, isActiveSpinner) {
1908
1908
  }
1909
1909
  }
1910
1910
  }
1911
- function LogTail({ events, limit = 8 }) {
1912
- const tail = events.slice(-limit);
1911
+ function LogTail({ events, visibleLines = 8, scrollOffset = 0 }) {
1912
+ const maxOffset = Math.max(0, events.length - visibleLines);
1913
+ const clampedOffset = Math.min(Math.max(0, scrollOffset), maxOffset);
1914
+ const end = events.length - clampedOffset;
1915
+ const start = Math.max(0, end - visibleLines);
1916
+ const window = events.slice(start, end);
1913
1917
  const resolvedIds = useMemo5(() => {
1914
1918
  const ids = /* @__PURE__ */ new Set();
1915
1919
  for (const ev of events) {
@@ -1919,9 +1923,29 @@ function LogTail({ events, limit = 8 }) {
1919
1923
  }
1920
1924
  return ids;
1921
1925
  }, [events]);
1926
+ const scrolledUp = clampedOffset > 0;
1927
+ const hiddenAbove = start;
1928
+ const hiddenBelow = events.length - end;
1922
1929
  return /* @__PURE__ */ jsxs14(Box15, { flexDirection: "column", children: [
1923
- /* @__PURE__ */ jsx17(Text14, { dimColor: true, children: "\u2500\u2500 Log \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }),
1924
- tail.length === 0 ? /* @__PURE__ */ jsx17(Text14, { dimColor: true, children: "(no activity yet)" }) : tail.map((event, i) => {
1930
+ /* @__PURE__ */ jsxs14(Box15, { children: [
1931
+ /* @__PURE__ */ jsx17(Text14, { dimColor: true, children: "\u2500\u2500 Log \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }),
1932
+ scrolledUp ? /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
1933
+ " ",
1934
+ glyphs.arrowRight,
1935
+ " ",
1936
+ hiddenAbove,
1937
+ " line",
1938
+ hiddenAbove !== 1 ? "s" : "",
1939
+ " above",
1940
+ hiddenBelow > 0 ? ` \xB7 ${String(hiddenBelow)} below` : ""
1941
+ ] }) : events.length > visibleLines ? /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
1942
+ " ",
1943
+ "(",
1944
+ events.length - visibleLines,
1945
+ " hidden)"
1946
+ ] }) : null
1947
+ ] }),
1948
+ window.length === 0 ? /* @__PURE__ */ jsx17(Text14, { dimColor: true, children: "(no activity yet)" }) : window.map((event, i) => {
1925
1949
  const active = event.kind === "spinner-start" && !resolvedIds.has(event.id);
1926
1950
  return renderLine(event, i, active);
1927
1951
  })
@@ -1998,9 +2022,13 @@ function ResultCard({ kind, title, fields, nextSteps, lines }) {
1998
2022
  }
1999
2023
 
2000
2024
  // src/integration/ui/tui/views/execute-view.tsx
2001
- import { jsx as jsx20, jsxs as jsxs18 } from "react/jsx-runtime";
2025
+ import { Fragment, jsx as jsx20, jsxs as jsxs18 } from "react/jsx-runtime";
2002
2026
  var EXECUTE_HINTS_RUNNING = [{ key: "c", action: "cancel" }];
2003
2027
  var EXECUTE_HINTS_TERMINAL = [{ key: "Enter", action: "back" }];
2028
+ var LOG_TAIL_FIXED_OVERHEAD = 20;
2029
+ var LOG_TAIL_MIN_LINES = 3;
2030
+ var LOG_TAIL_DEFAULT_LINES = 8;
2031
+ var ERROR_MESSAGE_MAX_LINES = 20;
2004
2032
  function initialState() {
2005
2033
  return {
2006
2034
  sprint: null,
@@ -2026,6 +2054,7 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2026
2054
  const registryEvents = useRegistryEvents(registry);
2027
2055
  const [attach, setAttach] = useState8(initialAttach);
2028
2056
  const [state, setState] = useState8(initialState);
2057
+ const { stdout } = useStdout();
2029
2058
  const processedCountRef = useRef2(0);
2030
2059
  const startedRef = useRef2(false);
2031
2060
  const attachedId = attach.execution?.id ?? null;
@@ -2122,6 +2151,7 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2122
2151
  })();
2123
2152
  }
2124
2153
  }, [signalEvents, shared, sprintId]);
2154
+ const logVisibleLines = stdout.rows ? Math.max(LOG_TAIL_MIN_LINES, stdout.rows - LOG_TAIL_FIXED_OVERHEAD) : LOG_TAIL_DEFAULT_LINES;
2125
2155
  const status = liveExecution?.status ?? "running";
2126
2156
  const terminal = status === "completed" || status === "failed" || status === "cancelled";
2127
2157
  const [closePromptRun, setClosePromptRun] = useState8(false);
@@ -2190,6 +2220,7 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2190
2220
  if (attach.kind === "attaching") {
2191
2221
  return /* @__PURE__ */ jsx20(ViewShell, { title: "Execute", children: /* @__PURE__ */ jsx20(Spinner, { label: "Attaching to execution\u2026" }) });
2192
2222
  }
2223
+ const errorCard = terminal && liveExecution?.status === "failed" && liveExecution.error ? buildErrorCard(liveExecution.error) : null;
2193
2224
  return /* @__PURE__ */ jsxs18(ViewShell, { title: "Execute", children: [
2194
2225
  /* @__PURE__ */ jsxs18(Box19, { children: [
2195
2226
  /* @__PURE__ */ jsx20(Text18, { bold: true, color: inkColors.primary, children: state.sprint?.name ?? "Sprint" }),
@@ -2216,8 +2247,8 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2216
2247
  const taskName = task?.name ?? taskId.slice(0, 8);
2217
2248
  return /* @__PURE__ */ jsx20(Spinner, { label: `${taskName} ${glyphs.emDash} ${label}` }, taskId);
2218
2249
  }) }) : null,
2219
- /* @__PURE__ */ jsx20(Box19, { marginTop: spacing.section, children: /* @__PURE__ */ jsx20(LogTail, { events: logEvents }) }),
2220
- terminal && liveExecution ? /* @__PURE__ */ jsxs18(Box19, { marginTop: spacing.section, flexDirection: "column", children: [
2250
+ /* @__PURE__ */ jsx20(Box19, { marginTop: spacing.section, children: /* @__PURE__ */ jsx20(LogTail, { events: logEvents, visibleLines: logVisibleLines, scrollOffset: 0 }) }),
2251
+ terminal && liveExecution ? /* @__PURE__ */ jsx20(Box19, { marginTop: spacing.section, flexDirection: "column", children: errorCard ? /* @__PURE__ */ jsx20(ResultCard, { kind: "error", title: "Execution failed", fields: errorCard.fields, lines: errorCard.lines }) : /* @__PURE__ */ jsxs18(Fragment, { children: [
2221
2252
  /* @__PURE__ */ jsxs18(Text18, { color: terminalColor(liveExecution.status), bold: true, children: [
2222
2253
  terminalGlyph(liveExecution.status),
2223
2254
  " Execution ",
@@ -2229,8 +2260,8 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2229
2260
  glyphs.inlineDot,
2230
2261
  " ",
2231
2262
  liveExecution.summary.remaining,
2232
- " remaining",
2233
2263
  " ",
2264
+ "remaining ",
2234
2265
  glyphs.inlineDot,
2235
2266
  " ",
2236
2267
  liveExecution.summary.blocked,
@@ -2239,7 +2270,7 @@ function ExecuteView({ sprintId, executionId, executionOptions }) {
2239
2270
  liveExecution.summary.stopReason,
2240
2271
  ")"
2241
2272
  ] }) : null
2242
- ] }) : null
2273
+ ] }) }) : null
2243
2274
  ] });
2244
2275
  }
2245
2276
  function terminalColor(status) {
@@ -2252,6 +2283,19 @@ function terminalGlyph(status) {
2252
2283
  if (status === "failed") return glyphs.cross;
2253
2284
  return glyphs.warningGlyph;
2254
2285
  }
2286
+ function buildErrorCard(error) {
2287
+ const fields = error.stepName ? [["Step", error.stepName]] : void 0;
2288
+ const rawLines = error.message.split("\n");
2289
+ if (rawLines.length <= ERROR_MESSAGE_MAX_LINES) {
2290
+ return { fields, lines: rawLines };
2291
+ }
2292
+ const hidden = rawLines.length - ERROR_MESSAGE_MAX_LINES;
2293
+ const visibleLines = [
2294
+ `(${String(hidden)} earlier line${hidden !== 1 ? "s" : ""} omitted)`,
2295
+ ...rawLines.slice(-ERROR_MESSAGE_MAX_LINES)
2296
+ ];
2297
+ return { fields, lines: visibleLines };
2298
+ }
2255
2299
  function CollisionRedirect({ registry, collisionId, fallbackSprintId }) {
2256
2300
  const router = useRouter();
2257
2301
  useInput6((_input, key) => {
@@ -7311,7 +7355,7 @@ function buildInitialStack(initialView, mountOptions) {
7311
7355
  return [{ id: "home" }];
7312
7356
  }
7313
7357
  function useTerminalWidth() {
7314
- const { stdout } = useStdout();
7358
+ const { stdout } = useStdout2();
7315
7359
  const [width, setWidth] = useState38(stdout.columns);
7316
7360
  useEffect37(() => {
7317
7361
  const onResize = () => {
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  parseSprintStartArgs,
4
4
  sprintStartCommand
5
- } from "./chunk-OFILN7QL.mjs";
6
- import "./chunk-ZLWSPLWI.mjs";
5
+ } from "./chunk-D6QZNEYN.mjs";
6
+ import "./chunk-62HYDA7L.mjs";
7
7
  import "./chunk-GQ2WFKBN.mjs";
8
8
  import "./chunk-CFUVE2BP.mjs";
9
9
  import "./chunk-747KW2RW.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralphctl",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Agent harness for long-running AI coding tasks — orchestrates Claude Code & GitHub Copilot across repositories",
5
5
  "homepage": "https://github.com/lukas-grigis/ralphctl",
6
6
  "type": "module",