ralphctl 0.3.1 → 0.4.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.
@@ -1681,6 +1681,37 @@ function collectRepoIds(tasks) {
1681
1681
 
1682
1682
  // src/business/usecases/execute.ts
1683
1683
  import { basename } from "path";
1684
+
1685
+ // src/business/usecases/recover-dirty-tree.ts
1686
+ async function recoverDirtyTree(deps, params) {
1687
+ const { external, logger, signalBus } = deps;
1688
+ const { sprintId, taskId, taskName, repoPath } = params;
1689
+ if (!external.hasUncommittedChanges(repoPath)) return;
1690
+ logger.warn(
1691
+ `Dirty tree after "${taskName}" \u2014 auto-committing on the harness's behalf. The agent should commit its own work; see prompt guidance.`,
1692
+ { taskId, projectPath: repoPath }
1693
+ );
1694
+ signalBus.emit({
1695
+ type: "signal",
1696
+ signal: {
1697
+ type: "note",
1698
+ text: `harness auto-commit: dirty tree after task "${taskName}" settlement`,
1699
+ timestamp: /* @__PURE__ */ new Date()
1700
+ },
1701
+ ctx: { sprintId, taskId, projectPath: repoPath }
1702
+ });
1703
+ const message = `chore(harness): auto-commit leftover changes from "${taskName}"`;
1704
+ try {
1705
+ await external.autoCommit(repoPath, message);
1706
+ } catch (err) {
1707
+ logger.error(`Auto-commit failed in ${repoPath}: ${err instanceof Error ? err.message : String(err)}`, {
1708
+ taskId,
1709
+ projectPath: repoPath
1710
+ });
1711
+ }
1712
+ }
1713
+
1714
+ // src/business/usecases/execute.ts
1684
1715
  var ExecuteTasksUseCase = class {
1685
1716
  constructor(persistence, aiSession, promptBuilder, parser, ui, logger, external, fs, signalParser2, signalHandler, signalBus) {
1686
1717
  this.persistence = persistence;
@@ -1940,6 +1971,10 @@ ${instructions}`;
1940
1971
  );
1941
1972
  if (finishStatus === "done") finishStatus = "failed";
1942
1973
  }
1974
+ await recoverDirtyTree(
1975
+ { external: this.external, logger: this.logger, signalBus: this.signalBus },
1976
+ { sprintId: sprint.id, taskId: syntheticTask.id, taskName: syntheticTask.name, repoPath }
1977
+ );
1943
1978
  this.signalBus.emit({
1944
1979
  type: "task-finished",
1945
1980
  sprintId: sprint.id,
@@ -2760,6 +2795,19 @@ function evaluateTask(deps) {
2760
2795
  });
2761
2796
  }
2762
2797
 
2798
+ // src/business/pipelines/execute/steps/recover-dirty-tree.ts
2799
+ function recoverDirtyTree2(deps) {
2800
+ return step("recover-dirty-tree", async (ctx) => {
2801
+ const { task, sprint } = ctx;
2802
+ const repoPath = await deps.persistence.resolveRepoPath(task.repoId);
2803
+ await recoverDirtyTree(
2804
+ { external: deps.external, logger: deps.logger, signalBus: deps.signalBus },
2805
+ { sprintId: sprint.id, taskId: task.id, taskName: task.name, repoPath }
2806
+ );
2807
+ return Result.ok({});
2808
+ });
2809
+ }
2810
+
2763
2811
  // src/business/pipelines/execute/steps/mark-done.ts
2764
2812
  function markDone(deps) {
2765
2813
  return step("mark-done", async (ctx) => {
@@ -2801,28 +2849,71 @@ function markDone(deps) {
2801
2849
 
2802
2850
  // src/business/pipelines/execute/per-task-pipeline.ts
2803
2851
  function createPerTaskPipeline(deps, useCase, options = {}) {
2852
+ const trace = withStepTrace(deps.signalBus);
2804
2853
  return pipeline("per-task", [
2805
- branchPreflight({ external: deps.external, persistence: deps.persistence }),
2806
- contractNegotiate({ persistence: deps.persistence, fs: deps.fs }),
2807
- markInProgress({ persistence: deps.persistence, signalBus: deps.signalBus }),
2808
- executeTask({ useCase, options, taskSessionIds: deps.taskSessionIds, logger: deps.logger }),
2809
- storeVerification({ persistence: deps.persistence, logger: deps.logger }),
2810
- postTaskCheck({ useCase }),
2811
- evaluateTask({
2812
- persistence: deps.persistence,
2813
- fs: deps.fs,
2814
- aiSession: deps.aiSession,
2815
- promptBuilder: deps.promptBuilder,
2816
- parser: deps.parser,
2817
- ui: deps.ui,
2818
- logger: deps.logger,
2819
- external: deps.external,
2820
- useCase,
2821
- options
2822
- }),
2823
- markDone({ persistence: deps.persistence, logger: deps.logger, signalBus: deps.signalBus })
2854
+ trace(branchPreflight({ external: deps.external, persistence: deps.persistence })),
2855
+ trace(contractNegotiate({ persistence: deps.persistence, fs: deps.fs })),
2856
+ trace(markInProgress({ persistence: deps.persistence, signalBus: deps.signalBus })),
2857
+ trace(executeTask({ useCase, options, taskSessionIds: deps.taskSessionIds, logger: deps.logger })),
2858
+ trace(storeVerification({ persistence: deps.persistence, logger: deps.logger })),
2859
+ trace(postTaskCheck({ useCase })),
2860
+ trace(
2861
+ evaluateTask({
2862
+ persistence: deps.persistence,
2863
+ fs: deps.fs,
2864
+ aiSession: deps.aiSession,
2865
+ promptBuilder: deps.promptBuilder,
2866
+ parser: deps.parser,
2867
+ ui: deps.ui,
2868
+ logger: deps.logger,
2869
+ external: deps.external,
2870
+ useCase,
2871
+ options
2872
+ })
2873
+ ),
2874
+ trace(
2875
+ recoverDirtyTree2({
2876
+ persistence: deps.persistence,
2877
+ external: deps.external,
2878
+ logger: deps.logger,
2879
+ signalBus: deps.signalBus
2880
+ })
2881
+ ),
2882
+ trace(markDone({ persistence: deps.persistence, logger: deps.logger, signalBus: deps.signalBus }))
2824
2883
  ]);
2825
2884
  }
2885
+ function withStepTrace(signalBus) {
2886
+ return (inner) => ({
2887
+ name: inner.name,
2888
+ execute: inner.execute,
2889
+ hooks: {
2890
+ pre: async (ctx) => {
2891
+ signalBus.emit({
2892
+ type: "task-step",
2893
+ sprintId: ctx.sprint.id,
2894
+ taskId: ctx.task.id,
2895
+ stepName: inner.name,
2896
+ phase: "start",
2897
+ timestamp: /* @__PURE__ */ new Date()
2898
+ });
2899
+ const prior = await inner.hooks?.pre?.(ctx);
2900
+ return prior ?? Result.ok(ctx);
2901
+ },
2902
+ post: async (ctx, result) => {
2903
+ const prior = await inner.hooks?.post?.(ctx, result);
2904
+ signalBus.emit({
2905
+ type: "task-step",
2906
+ sprintId: ctx.sprint.id,
2907
+ taskId: ctx.task.id,
2908
+ stepName: inner.name,
2909
+ phase: "finish",
2910
+ timestamp: /* @__PURE__ */ new Date()
2911
+ });
2912
+ return prior ?? Result.ok({});
2913
+ }
2914
+ }
2915
+ });
2916
+ }
2826
2917
 
2827
2918
  // src/business/pipelines/execute.ts
2828
2919
  var EXIT_SUCCESS = 0;
@@ -3260,7 +3351,6 @@ function executeTasksStep(deps, options) {
3260
3351
  onSettle: (task, result) => {
3261
3352
  if (result === "success") {
3262
3353
  taskSessionIds.delete(task.id);
3263
- deps.logger.success(`Completed: ${task.name}`);
3264
3354
  }
3265
3355
  }
3266
3356
  },
@@ -5183,6 +5273,25 @@ function hasUncommittedChanges(cwd) {
5183
5273
  }
5184
5274
  return result.stdout.trim().length > 0;
5185
5275
  }
5276
+ function autoCommit(cwd, message) {
5277
+ assertSafeCwd(cwd);
5278
+ const add = spawnSync3("git", ["add", "-A"], {
5279
+ cwd,
5280
+ encoding: "utf-8",
5281
+ stdio: ["pipe", "pipe", "pipe"]
5282
+ });
5283
+ if (add.status !== 0) {
5284
+ throw new Error(`Failed to stage changes in ${cwd}: ${add.stderr.trim()}`);
5285
+ }
5286
+ const commit = spawnSync3("git", ["commit", "-m", message], {
5287
+ cwd,
5288
+ encoding: "utf-8",
5289
+ stdio: ["pipe", "pipe", "pipe"]
5290
+ });
5291
+ if (commit.status !== 0) {
5292
+ throw new Error(`Failed to commit in ${cwd}: ${commit.stderr.trim() || commit.stdout.trim()}`);
5293
+ }
5294
+ }
5186
5295
  function generateBranchName(sprintId) {
5187
5296
  return `ralphctl/${sprintId}`;
5188
5297
  }
@@ -5242,6 +5351,10 @@ var DefaultExternalAdapter = class {
5242
5351
  hasUncommittedChanges(projectPath) {
5243
5352
  return hasUncommittedChanges(projectPath);
5244
5353
  }
5354
+ autoCommit(projectPath, message) {
5355
+ autoCommit(projectPath, message);
5356
+ return Promise.resolve();
5357
+ }
5245
5358
  createAndCheckoutBranch(projectPath, branchName) {
5246
5359
  createAndCheckoutBranch(projectPath, branchName);
5247
5360
  }
@@ -52,7 +52,7 @@ import {
52
52
  updateTask,
53
53
  updateTaskStatus,
54
54
  validateImportTasks
55
- } from "./chunk-CSC4TBJB.mjs";
55
+ } from "./chunk-3HJNVQ7N.mjs";
56
56
  import {
57
57
  fetchIssueFromUrl,
58
58
  formatIssueContext,
@@ -177,7 +177,7 @@ import {
177
177
  // package.json
178
178
  var package_default = {
179
179
  name: "ralphctl",
180
- version: "0.3.1",
180
+ version: "0.4.1",
181
181
  description: "Agent harness for long-running AI coding tasks \u2014 orchestrates Claude Code & GitHub Copilot across repositories",
182
182
  homepage: "https://github.com/lukas-grigis/ralphctl",
183
183
  type: "module",
@@ -422,8 +422,8 @@ function useCurrentPrompt() {
422
422
  }
423
423
 
424
424
  // src/integration/ui/prompts/confirm-prompt.tsx
425
- import "react";
426
- import { Box, Text } from "ink";
425
+ import { useEffect as useEffect2, useMemo, useState as useState2 } from "react";
426
+ import { Box, Text, useInput, useStdout } from "ink";
427
427
  import { ConfirmInput } from "@inkjs/ui";
428
428
 
429
429
  // src/integration/ui/theme/tokens.ts
@@ -493,11 +493,43 @@ var FIELD_LABEL_WIDTH = 12;
493
493
 
494
494
  // src/integration/ui/prompts/confirm-prompt.tsx
495
495
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
496
+ var RESERVED_ROWS = 10;
497
+ var MIN_VIEWPORT = 6;
498
+ var MAX_VIEWPORT = 40;
499
+ function useTerminalRows() {
500
+ const { stdout } = useStdout();
501
+ const [rows, setRows] = useState2(stdout.rows);
502
+ useEffect2(() => {
503
+ const onResize = () => {
504
+ setRows(stdout.rows);
505
+ };
506
+ stdout.on("resize", onResize);
507
+ return () => {
508
+ stdout.off("resize", onResize);
509
+ };
510
+ }, [stdout]);
511
+ return rows;
512
+ }
496
513
  function ConfirmPrompt({ options, onSubmit }) {
497
514
  const hint = options.default === false ? "(y/N)" : "(Y/n)";
498
515
  const details = options.details?.trim();
516
+ const lines = useMemo(() => details ? details.split("\n") : [], [details]);
517
+ const terminalRows = useTerminalRows();
518
+ const viewport = Math.max(MIN_VIEWPORT, Math.min(MAX_VIEWPORT, terminalRows - RESERVED_ROWS));
519
+ const total = lines.length;
520
+ const maxOffset = Math.max(0, total - viewport);
521
+ const scrollable = total > viewport;
522
+ const [offset, setOffset] = useState2(0);
523
+ useInput((_input, key) => {
524
+ if (!scrollable) return;
525
+ if (key.upArrow) setOffset((o) => Math.max(0, o - 1));
526
+ else if (key.downArrow) setOffset((o) => Math.min(maxOffset, o + 1));
527
+ else if (key.pageUp) setOffset((o) => Math.max(0, o - viewport));
528
+ else if (key.pageDown) setOffset((o) => Math.min(maxOffset, o + viewport));
529
+ });
530
+ const visibleLines = scrollable ? lines.slice(offset, offset + viewport) : lines;
499
531
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
500
- details ? /* @__PURE__ */ jsx(
532
+ details ? /* @__PURE__ */ jsxs(
501
533
  Box,
502
534
  {
503
535
  flexDirection: "column",
@@ -505,13 +537,30 @@ function ConfirmPrompt({ options, onSubmit }) {
505
537
  borderColor: inkColors.muted,
506
538
  paddingX: spacing.gutter,
507
539
  marginBottom: spacing.section,
508
- children: details.split("\n").map((line, idx) => /* @__PURE__ */ jsx(Text, { children: line.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
509
- /* @__PURE__ */ jsxs(Text, { color: inkColors.muted, children: [
510
- glyphs.quoteRail,
511
- " "
512
- ] }),
513
- line
514
- ] }) : " " }, idx))
540
+ children: [
541
+ visibleLines.map((line, idx) => /* @__PURE__ */ jsx(Text, { children: line.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
542
+ /* @__PURE__ */ jsxs(Text, { color: inkColors.muted, children: [
543
+ glyphs.quoteRail,
544
+ " "
545
+ ] }),
546
+ line
547
+ ] }) : " " }, idx)),
548
+ scrollable ? /* @__PURE__ */ jsxs(Text, { color: inkColors.muted, children: [
549
+ glyphs.inlineDot,
550
+ " lines ",
551
+ String(offset + 1),
552
+ "\u2013",
553
+ String(Math.min(offset + viewport, total)),
554
+ " of",
555
+ " ",
556
+ String(total),
557
+ " ",
558
+ glyphs.inlineDot,
559
+ " \u2191/\u2193 scroll ",
560
+ glyphs.inlineDot,
561
+ " PgUp/PgDn page"
562
+ ] }) : null
563
+ ]
515
564
  }
516
565
  ) : null,
517
566
  /* @__PURE__ */ jsxs(Box, { children: [
@@ -542,13 +591,13 @@ function ConfirmPrompt({ options, onSubmit }) {
542
591
  }
543
592
 
544
593
  // src/integration/ui/prompts/input-prompt.tsx
545
- import { useState as useState2 } from "react";
546
- import { Box as Box2, Text as Text2, useInput } from "ink";
594
+ import { useState as useState3 } from "react";
595
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
547
596
  import { TextInput } from "@inkjs/ui";
548
597
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
549
598
  function InputPrompt({ options, onSubmit, onCancel }) {
550
- const [error2, setError] = useState2(null);
551
- useInput((_input, key) => {
599
+ const [error2, setError] = useState3(null);
600
+ useInput2((_input, key) => {
552
601
  if (key.escape) onCancel();
553
602
  });
554
603
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
@@ -586,13 +635,13 @@ function InputPrompt({ options, onSubmit, onCancel }) {
586
635
  }
587
636
 
588
637
  // src/integration/ui/prompts/select-prompt.tsx
589
- import { useState as useState3 } from "react";
590
- import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
638
+ import { useState as useState4 } from "react";
639
+ import { Box as Box3, Text as Text3, useInput as useInput3 } from "ink";
591
640
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
592
641
  function SelectPrompt({ options, onSubmit, onCancel }) {
593
642
  const initialIdx = findInitialIdx(options);
594
- const [focusedIdx, setFocusedIdx] = useState3(initialIdx);
595
- useInput2((_input, key) => {
643
+ const [focusedIdx, setFocusedIdx] = useState4(initialIdx);
644
+ useInput3((_input, key) => {
596
645
  if (key.escape) {
597
646
  onCancel();
598
647
  return;
@@ -655,14 +704,14 @@ function stepFocus(choices, from, delta) {
655
704
  }
656
705
 
657
706
  // src/integration/ui/prompts/checkbox-prompt.tsx
658
- import { useState as useState4 } from "react";
659
- import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
707
+ import { useState as useState5 } from "react";
708
+ import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
660
709
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
661
710
  function CheckboxPrompt({ options, onSubmit, onCancel }) {
662
711
  const initialFocus = options.choices.findIndex((c) => !isDisabled2(c));
663
- const [focusedIdx, setFocusedIdx] = useState4(initialFocus >= 0 ? initialFocus : 0);
664
- const [checked, setChecked] = useState4(() => seedCheckedSet(options));
665
- useInput3((input, key) => {
712
+ const [focusedIdx, setFocusedIdx] = useState5(initialFocus >= 0 ? initialFocus : 0);
713
+ const [checked, setChecked] = useState5(() => seedCheckedSet(options));
714
+ useInput4((input, key) => {
666
715
  if (key.escape) {
667
716
  onCancel();
668
717
  return;
@@ -738,8 +787,8 @@ function stepFocus2(choices, from, delta) {
738
787
  }
739
788
 
740
789
  // src/integration/ui/prompts/editor-prompt.tsx
741
- import { useState as useState5, useMemo } from "react";
742
- import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
790
+ import { useState as useState6, useMemo as useMemo2 } from "react";
791
+ import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
743
792
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
744
793
  var MIN_EDIT_ROWS = 8;
745
794
  function splitLines(text) {
@@ -755,13 +804,13 @@ function clampCursor(lines, cursor) {
755
804
  return { row, col };
756
805
  }
757
806
  function EditorPrompt({ options, onSubmit, onCancel }) {
758
- const [lines, setLines] = useState5(() => splitLines(options.default ?? ""));
759
- const [cursor, setCursor] = useState5(() => {
807
+ const [lines, setLines] = useState6(() => splitLines(options.default ?? ""));
808
+ const [cursor, setCursor] = useState6(() => {
760
809
  const init = splitLines(options.default ?? "");
761
810
  const lastRow = init.length - 1;
762
811
  return { row: lastRow, col: (init[lastRow] ?? "").length };
763
812
  });
764
- useInput4((input, key) => {
813
+ useInput5((input, key) => {
765
814
  if (key.escape || key.ctrl && input === "c") {
766
815
  onCancel();
767
816
  return;
@@ -857,7 +906,7 @@ function EditorPrompt({ options, onSubmit, onCancel }) {
857
906
  });
858
907
  }
859
908
  });
860
- const renderedLines = useMemo(() => {
909
+ const renderedLines = useMemo2(() => {
861
910
  const padCount = Math.max(0, MIN_EDIT_ROWS - lines.length);
862
911
  const padded = lines.map((line, i) => {
863
912
  if (i !== cursor.row) return line.length > 0 ? line : " ";
@@ -904,11 +953,11 @@ function EditorPrompt({ options, onSubmit, onCancel }) {
904
953
  }
905
954
 
906
955
  // src/integration/ui/prompts/file-browser-prompt.tsx
907
- import { useEffect as useEffect2, useMemo as useMemo2, useState as useState6 } from "react";
956
+ import { useEffect as useEffect3, useMemo as useMemo3, useState as useState7 } from "react";
908
957
  import { readdirSync, statSync } from "fs";
909
958
  import { homedir } from "os";
910
959
  import { dirname, join, resolve } from "path";
911
- import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
960
+ import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
912
961
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
913
962
  function listDirectories(dirPath) {
914
963
  try {
@@ -926,20 +975,20 @@ function isGitRepo(dirPath) {
926
975
  }
927
976
  var PAGE_SIZE = 12;
928
977
  function FileBrowserPrompt({ options, onSubmit, onCancel }) {
929
- const [currentPath, setCurrentPath] = useState6(
978
+ const [currentPath, setCurrentPath] = useState7(
930
979
  () => options.startPath ? resolve(options.startPath) : homedir()
931
980
  );
932
- const [dirs, setDirs] = useState6([]);
933
- const [cursor, setCursor] = useState6(0);
934
- const [offset, setOffset] = useState6(0);
935
- useEffect2(() => {
981
+ const [dirs, setDirs] = useState7([]);
982
+ const [cursor, setCursor] = useState7(0);
983
+ const [offset, setOffset] = useState7(0);
984
+ useEffect3(() => {
936
985
  setDirs(listDirectories(currentPath));
937
986
  setCursor(0);
938
987
  setOffset(0);
939
988
  }, [currentPath]);
940
989
  const message = options.message ?? "Browse to directory:";
941
990
  const parent = dirname(currentPath);
942
- useInput5((input, key) => {
991
+ useInput6((input, key) => {
943
992
  if (key.escape) {
944
993
  onCancel();
945
994
  return;
@@ -973,11 +1022,11 @@ function FileBrowserPrompt({ options, onSubmit, onCancel }) {
973
1022
  return;
974
1023
  }
975
1024
  });
976
- useEffect2(() => {
1025
+ useEffect3(() => {
977
1026
  if (cursor < offset) setOffset(cursor);
978
1027
  else if (cursor >= offset + PAGE_SIZE) setOffset(cursor - PAGE_SIZE + 1);
979
1028
  }, [cursor, offset]);
980
- const visible = useMemo2(() => dirs.slice(offset, offset + PAGE_SIZE), [dirs, offset]);
1029
+ const visible = useMemo3(() => dirs.slice(offset, offset + PAGE_SIZE), [dirs, offset]);
981
1030
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
982
1031
  /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs6(Text6, { children: [
983
1032
  emoji.donut,
@@ -2709,6 +2758,8 @@ function parseArgs2(args) {
2709
2758
  if (arg === "--project") {
2710
2759
  options.project = nextArg;
2711
2760
  i++;
2761
+ } else if (arg === "--auto") {
2762
+ options.auto = true;
2712
2763
  } else if (!arg?.startsWith("-")) {
2713
2764
  sprintId = arg;
2714
2765
  }
@@ -2731,7 +2782,7 @@ async function sprintRefineCommand(args) {
2731
2782
  console.log(field("ID", sprint.id));
2732
2783
  log.newline();
2733
2784
  const shared = getSharedDeps();
2734
- const pipeline = createRefinePipeline(shared, { project: options.project });
2785
+ const pipeline = createRefinePipeline(shared, { project: options.project, auto: options.auto });
2735
2786
  const result = await executePipeline(pipeline, { sprintId: id });
2736
2787
  if (!result.ok) {
2737
2788
  showError(result.error.message);
@@ -3914,6 +3965,100 @@ async function taskImportCommand(args) {
3914
3965
  }
3915
3966
  }
3916
3967
 
3968
+ // src/integration/cli/commands/task/why.ts
3969
+ function collectBlockers(root, byId) {
3970
+ const out = [];
3971
+ const visited = /* @__PURE__ */ new Set([root.id]);
3972
+ function walk(task, depth) {
3973
+ for (const blockerId of task.blockedBy) {
3974
+ const blocker = byId.get(blockerId);
3975
+ if (!blocker) {
3976
+ out.push({
3977
+ task: { id: blockerId, name: `(missing ${blockerId})`, status: "todo" },
3978
+ depth,
3979
+ missing: true
3980
+ });
3981
+ continue;
3982
+ }
3983
+ if (visited.has(blocker.id)) continue;
3984
+ visited.add(blocker.id);
3985
+ out.push({ task: blocker, depth, missing: false });
3986
+ if (blocker.status !== "done") walk(blocker, depth + 1);
3987
+ }
3988
+ }
3989
+ walk(root, 0);
3990
+ return out;
3991
+ }
3992
+ function renderBlockerLine(node) {
3993
+ const indent = " " + " ".repeat(node.depth);
3994
+ const connector = colors.muted(node.depth === 0 ? "\u251C\u2500" : "\u21B3");
3995
+ const idPart = colors.muted(node.task.id);
3996
+ if (node.missing) {
3997
+ return `${indent}${connector} ${colors.error(icons.error)} ${idPart} ${colors.error("(referenced but missing)")}`;
3998
+ }
3999
+ const status = formatTaskStatus(node.task.status);
4000
+ const marker = node.task.status === "done" ? colors.success(icons.success) : colors.warning(icons.warning);
4001
+ return `${indent}${connector} ${marker} ${idPart} ${node.task.name} ${status}`;
4002
+ }
4003
+ async function taskWhyCommand(taskId) {
4004
+ let id = taskId;
4005
+ if (!id) {
4006
+ const selected = await selectTask("Which task is blocked?");
4007
+ if (!selected) return;
4008
+ id = selected;
4009
+ }
4010
+ const r = await wrapAsync(() => getTasks(), ensureError);
4011
+ if (!r.ok) {
4012
+ showError(r.error.message);
4013
+ return;
4014
+ }
4015
+ const tasks = r.value;
4016
+ const byId = new Map(tasks.map((t) => [t.id, t]));
4017
+ const root = byId.get(id);
4018
+ if (!root) {
4019
+ showError(new TaskNotFoundError(id).message);
4020
+ return;
4021
+ }
4022
+ printHeader("Why blocked?");
4023
+ log.newline();
4024
+ console.log(` ${icons.task} ${colors.highlight(root.name)} ${formatTaskStatus(root.status)}`);
4025
+ console.log(` ${colors.muted("id:")} ${colors.muted(root.id)}`);
4026
+ log.newline();
4027
+ if (root.status === "done") {
4028
+ console.log(` ${colors.success(icons.success)} ${colors.success("Task is done \u2014 nothing blocking it.")}`);
4029
+ log.newline();
4030
+ return;
4031
+ }
4032
+ if (root.blockedBy.length === 0) {
4033
+ console.log(` ${colors.success(icons.success)} ${colors.success("No blockers \u2014 ready to execute.")}`);
4034
+ log.newline();
4035
+ showNextStep(`ralphctl task status ${root.id} in_progress`, "Start working on this task");
4036
+ log.newline();
4037
+ return;
4038
+ }
4039
+ const nodes = collectBlockers(root, byId);
4040
+ const unmet = nodes.filter((n) => !n.missing && n.task.status !== "done");
4041
+ const leafUnmet = unmet.filter((n) => n.task.blockedBy.every((bid) => byId.get(bid)?.status === "done"));
4042
+ console.log(` ${colors.muted("Dependency chain:")}`);
4043
+ for (const node of nodes) console.log(renderBlockerLine(node));
4044
+ log.newline();
4045
+ if (unmet.length === 0) {
4046
+ console.log(` ${colors.success(icons.success)} ${colors.success("All blockers are done \u2014 ready to execute.")}`);
4047
+ log.newline();
4048
+ showNextStep(`ralphctl task status ${root.id} in_progress`, "Start working on this task");
4049
+ log.newline();
4050
+ return;
4051
+ }
4052
+ const actionable = leafUnmet.length > 0 ? leafUnmet : unmet;
4053
+ console.log(
4054
+ ` ${colors.warning(icons.warning)} ${colors.warning(`Unblock by completing ${String(actionable.length)} task${actionable.length !== 1 ? "s" : ""} first:`)}`
4055
+ );
4056
+ for (const node of actionable) {
4057
+ console.log(` ${colors.muted("\u2192")} ${colors.highlight(node.task.id)} ${node.task.name}`);
4058
+ }
4059
+ log.newline();
4060
+ }
4061
+
3917
4062
  // src/integration/cli/commands/ticket/edit.ts
3918
4063
  function validateUrl(url) {
3919
4064
  try {
@@ -5069,6 +5214,7 @@ export {
5069
5214
  taskNextCommand,
5070
5215
  taskReorderCommand,
5071
5216
  taskImportCommand,
5217
+ taskWhyCommand,
5072
5218
  ticketEditCommand,
5073
5219
  ticketListCommand,
5074
5220
  ticketShowCommand,
package/dist/cli.mjs CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  configShowCommand,
6
6
  createSharedDeps,
7
7
  doctorCommand,
8
+ getNextAction,
9
+ loadDashboardData,
8
10
  progressLogCommand,
9
11
  progressShowCommand,
10
12
  projectListCommand,
@@ -33,12 +35,13 @@ import {
33
35
  taskReorderCommand,
34
36
  taskShowCommand,
35
37
  taskStatusCommand,
38
+ taskWhyCommand,
36
39
  ticketEditCommand,
37
40
  ticketListCommand,
38
41
  ticketRefineCommand,
39
42
  ticketRemoveCommand,
40
43
  ticketShowCommand
41
- } from "./chunk-EPDR6VO5.mjs";
44
+ } from "./chunk-SM4GGZSU.mjs";
42
45
  import {
43
46
  projectAddCommand
44
47
  } from "./chunk-D2YGPLIV.mjs";
@@ -52,7 +55,7 @@ import "./chunk-NUYQK5MN.mjs";
52
55
  import {
53
56
  getTasks,
54
57
  sprintStartCommand
55
- } from "./chunk-CSC4TBJB.mjs";
58
+ } from "./chunk-3HJNVQ7N.mjs";
56
59
  import {
57
60
  truncate
58
61
  } from "./chunk-JOQO4HMM.mjs";
@@ -70,6 +73,7 @@ import {
70
73
  import {
71
74
  colors,
72
75
  error,
76
+ formatSprintStatus,
73
77
  icons,
74
78
  log,
75
79
  printBanner,
@@ -283,10 +287,11 @@ Examples:
283
287
  sprint.command("switch").description("Quick sprint switcher (opens selector)").action(async () => {
284
288
  await sprintSwitchCommand();
285
289
  });
286
- sprint.command("refine [id]").description("Refine ticket specifications").option("--project <name>", "Only refine tickets for specific project").action(async (id, opts) => {
290
+ sprint.command("refine [id]").description("Refine ticket specifications").option("--project <name>", "Only refine tickets for specific project").option("--auto", "Run without approval prompts (AI drafts requirements autonomously)").action(async (id, opts) => {
287
291
  const args = [];
288
292
  if (id) args.push(id);
289
293
  if (opts?.project) args.push("--project", opts.project);
294
+ if (opts?.auto) args.push("--auto");
290
295
  await sprintRefineCommand(args);
291
296
  });
292
297
  sprint.command("ideate [id]").description("Quick idea to tasks (refine + plan in one session)").option("--auto", "Run without user interaction (AI decides autonomously)").option("--all-paths", "Explore all project repositories instead of prompting for selection").option("--project <name>", "Pre-select project (skip interactive selection)").action(async (id, opts) => {
@@ -426,6 +431,9 @@ Examples:
426
431
  });
427
432
  });
428
433
  task.command("next").description("Get next task").action(taskNextCommand);
434
+ task.command("why [id]").description("Explain why a task is blocked (walks the dependency chain)").action(async (id) => {
435
+ await taskWhyCommand(id);
436
+ });
429
437
  task.command("reorder [id] [position]").description("Change task priority").action(async (id, position) => {
430
438
  const args = [];
431
439
  if (id) args.push(id);
@@ -578,6 +586,85 @@ Checks performed:
578
586
  });
579
587
  }
580
588
 
589
+ // src/integration/cli/commands/next/next.ts
590
+ function toCommand(action) {
591
+ return `ralphctl ${action.group} ${action.subCommand}`;
592
+ }
593
+ function computePayload(data) {
594
+ if (!data) {
595
+ return { sprint: null, action: null, reason: "no-sprint" };
596
+ }
597
+ const sprint = { id: data.sprint.id, name: data.sprint.name, status: data.sprint.status };
598
+ if (data.sprint.status === "closed") {
599
+ return { sprint, action: null, reason: "sprint-closed" };
600
+ }
601
+ const next = getNextAction(data);
602
+ if (!next) {
603
+ return { sprint, action: null, reason: "all-done" };
604
+ }
605
+ return {
606
+ sprint,
607
+ action: { command: toCommand(next), label: next.label, description: next.description },
608
+ reason: "action-ready"
609
+ };
610
+ }
611
+ function renderPorcelain(payload) {
612
+ if (payload.action) {
613
+ console.log(payload.action.command);
614
+ return;
615
+ }
616
+ console.log("");
617
+ }
618
+ function renderHuman(payload) {
619
+ log.newline();
620
+ if (payload.reason === "no-sprint") {
621
+ console.log(` ${colors.muted(icons.inactive)} ${colors.muted("No current sprint.")}`);
622
+ console.log(` ${colors.muted(icons.tip)} ${colors.muted("Create one to get started:")}`);
623
+ console.log(` ${colors.highlight("ralphctl sprint create")}`);
624
+ log.newline();
625
+ return;
626
+ }
627
+ if (!payload.sprint) return;
628
+ const sprintLine = `${icons.sprint} ${colors.highlight(payload.sprint.name)} ${formatSprintStatus(payload.sprint.status)}`;
629
+ console.log(` ${sprintLine}`);
630
+ if (payload.reason === "sprint-closed") {
631
+ console.log(` ${colors.muted(icons.info)} ${colors.muted("Sprint is closed. Start a new one:")}`);
632
+ console.log(` ${colors.highlight("ralphctl sprint create")}`);
633
+ log.newline();
634
+ return;
635
+ }
636
+ if (payload.reason === "all-done") {
637
+ console.log(` ${colors.success(icons.success)} ${colors.success("Nothing left to do.")}`);
638
+ log.newline();
639
+ return;
640
+ }
641
+ const action = payload.action;
642
+ if (!action) return;
643
+ console.log(` ${colors.muted(icons.tip)} ${colors.muted(action.label + ":")} ${colors.muted(action.description)}`);
644
+ console.log(` ${colors.highlight(action.command)}`);
645
+ log.newline();
646
+ }
647
+ async function nextCommand(options = {}) {
648
+ const data = await loadDashboardData();
649
+ const payload = computePayload(data);
650
+ if (options.json) {
651
+ console.log(JSON.stringify(payload));
652
+ return;
653
+ }
654
+ if (options.porcelain) {
655
+ renderPorcelain(payload);
656
+ return;
657
+ }
658
+ renderHuman(payload);
659
+ }
660
+
661
+ // src/integration/cli/commands/next/register.ts
662
+ function registerNextCommands(program2) {
663
+ program2.command("next").description("Suggest the next workflow action for the current sprint").option("--porcelain", "Print only the suggested command (for shell/tmux integration)").option("--json", "Emit a machine-readable JSON payload").action(async (options) => {
664
+ await nextCommand(options);
665
+ });
666
+ }
667
+
581
668
  // src/application/entrypoint.ts
582
669
  setSharedDeps(createSharedDeps());
583
670
  var program = new Command();
@@ -603,6 +690,12 @@ registerDashboardCommands(program);
603
690
  registerConfigCommands(program);
604
691
  registerCompletionCommands(program);
605
692
  registerDoctorCommands(program);
693
+ registerNextCommands(program);
694
+ function isQuietCommand(argv) {
695
+ const cmd = argv[2];
696
+ if (cmd === "next" && (argv.includes("--porcelain") || argv.includes("--json"))) return true;
697
+ return false;
698
+ }
606
699
  async function main() {
607
700
  if (process.env["COMP_CWORD"] && process.env["COMP_POINT"] && process.env["COMP_LINE"]) {
608
701
  const { handleCompletionRequest } = await import("./handle-BBAZJ44Y.mjs");
@@ -612,7 +705,7 @@ async function main() {
612
705
  const isBare = argv.length <= 2;
613
706
  const isInteractive = argv[2] === "interactive";
614
707
  if (isBare || isInteractive) {
615
- const { mountInkApp } = await import("./mount-U7QXVB5Q.mjs");
708
+ const { mountInkApp } = await import("./mount-2N6H5CWA.mjs");
616
709
  const { fallback } = await mountInkApp({ initialView: "repl" });
617
710
  if (!fallback) return;
618
711
  printBanner();
@@ -623,10 +716,10 @@ async function main() {
623
716
  return;
624
717
  }
625
718
  if (argv[2] === "sprint" && argv[3] === "start") {
626
- const { parseSprintStartArgs } = await import("./start-WG7VMEB2.mjs");
719
+ const { parseSprintStartArgs } = await import("./start-IUDCXIEA.mjs");
627
720
  const parsed = parseSprintStartArgs(argv.slice(4));
628
721
  if (parsed.ok) {
629
- const { mountInkApp } = await import("./mount-U7QXVB5Q.mjs");
722
+ const { mountInkApp } = await import("./mount-2N6H5CWA.mjs");
630
723
  const { getSharedDeps } = await import("./bootstrap-FMHG6DRY.mjs");
631
724
  let sprintId;
632
725
  try {
@@ -644,7 +737,7 @@ async function main() {
644
737
  }
645
738
  }
646
739
  }
647
- printBanner();
740
+ if (!isQuietCommand(argv)) printBanner();
648
741
  await program.parseAsync(argv);
649
742
  }
650
743
  main().catch((err) => {
@@ -54,6 +54,7 @@ import {
54
54
  taskReorderCommand,
55
55
  taskShowCommand,
56
56
  taskStatusCommand,
57
+ taskWhyCommand,
57
58
  ticketEditCommand,
58
59
  ticketListCommand,
59
60
  ticketRefineCommand,
@@ -61,7 +62,7 @@ import {
61
62
  ticketShowCommand,
62
63
  useCurrentPrompt,
63
64
  validateConfigValue
64
- } from "./chunk-EPDR6VO5.mjs";
65
+ } from "./chunk-SM4GGZSU.mjs";
65
66
  import {
66
67
  PromptCancelledError,
67
68
  projectAddCommand
@@ -108,7 +109,7 @@ import {
108
109
  sprintStartCommand,
109
110
  updateTaskStatus,
110
111
  withSuspendedTui
111
- } from "./chunk-CSC4TBJB.mjs";
112
+ } from "./chunk-3HJNVQ7N.mjs";
112
113
  import {
113
114
  addTicket,
114
115
  allRequirementsApproved,
@@ -554,6 +555,7 @@ function buildTaskSubMenu(ctx) {
554
555
  items.push(titled("VIEW"));
555
556
  items.push({ name: "List", value: "list", description: "List all tasks" });
556
557
  items.push({ name: "Next", value: "next", description: "Get next task" });
558
+ items.push({ name: "Why Blocked?", value: "why", description: "Explain why a task is blocked" });
557
559
  items.push(blank());
558
560
  items.push(titled("MANAGE"));
559
561
  items.push({ name: "Add", value: "add", description: "Add a new task" });
@@ -1035,9 +1037,27 @@ var commandMap = {
1035
1037
  show: () => sprintShowCommand([]),
1036
1038
  context: () => sprintContextCommand([]),
1037
1039
  current: () => sprintCurrentCommand(["-"]),
1038
- refine: () => sprintRefineCommand([]),
1040
+ refine: async () => {
1041
+ const mode = await getPrompt().select({
1042
+ message: "How should refinement run?",
1043
+ choices: [
1044
+ { label: "Interactive \u2014 approve requirements for each ticket", value: "interactive" },
1045
+ { label: "Auto \u2014 AI drafts requirements without prompts", value: "auto" }
1046
+ ]
1047
+ });
1048
+ await sprintRefineCommand(mode === "auto" ? ["--auto"] : []);
1049
+ },
1039
1050
  ideate: () => sprintIdeateCommand([]),
1040
- plan: () => sprintPlanCommand([]),
1051
+ plan: async () => {
1052
+ const mode = await getPrompt().select({
1053
+ message: "How should planning run?",
1054
+ choices: [
1055
+ { label: "Interactive \u2014 pick affected repos manually", value: "interactive" },
1056
+ { label: "Auto \u2014 AI explores all repos autonomously", value: "auto" }
1057
+ ]
1058
+ });
1059
+ await sprintPlanCommand(mode === "auto" ? ["--auto", "--all-paths"] : []);
1060
+ },
1041
1061
  start: () => sprintStartCommand([]),
1042
1062
  requirements: () => sprintRequirementsCommand([]),
1043
1063
  health: () => sprintHealthCommand(),
@@ -1062,6 +1082,7 @@ var commandMap = {
1062
1082
  show: () => taskShowCommand([]),
1063
1083
  status: () => taskStatusCommand([]),
1064
1084
  next: () => taskNextCommand(),
1085
+ why: () => taskWhyCommand(),
1065
1086
  reorder: () => taskReorderCommand([]),
1066
1087
  remove: () => taskRemoveCommand([])
1067
1088
  },
@@ -1977,6 +1998,7 @@ function initialState() {
1977
1998
  running: /* @__PURE__ */ new Set(),
1978
1999
  blocked: /* @__PURE__ */ new Set(),
1979
2000
  activity: /* @__PURE__ */ new Map(),
2001
+ currentStep: /* @__PURE__ */ new Map(),
1980
2002
  summary: null,
1981
2003
  error: null,
1982
2004
  rateLimit: null
@@ -2038,7 +2060,16 @@ function ExecuteView({ sprintId, executionOptions }) {
2038
2060
  const fresh = signalEvents.slice(processedCountRef.current);
2039
2061
  processedCountRef.current = signalEvents.length;
2040
2062
  setState((prev) => reduceEvents(prev, fresh));
2041
- }, [signalEvents]);
2063
+ if (fresh.some((e) => e.type === "task-finished")) {
2064
+ void (async () => {
2065
+ try {
2066
+ const tasks = await shared.persistence.getTasks(sprintId);
2067
+ setState((s) => ({ ...s, tasks }));
2068
+ } catch {
2069
+ }
2070
+ })();
2071
+ }
2072
+ }, [signalEvents, shared, sprintId]);
2042
2073
  const [closePromptRun, setClosePromptRun] = useState8(false);
2043
2074
  useEffect8(() => {
2044
2075
  if (!done) return;
@@ -2092,6 +2123,11 @@ function ExecuteView({ sprintId, executionOptions }) {
2092
2123
  activityByTask: state.activity
2093
2124
  }
2094
2125
  ) }),
2126
+ !done && state.currentStep.size > 0 ? /* @__PURE__ */ jsx18(Box17, { marginTop: spacing.section, flexDirection: "column", children: Array.from(state.currentStep.entries()).map(([taskId, label]) => {
2127
+ const task = state.tasks.find((t) => t.id === taskId);
2128
+ const taskName = task?.name ?? taskId.slice(0, 8);
2129
+ return /* @__PURE__ */ jsx18(Spinner, { label: `${taskName} ${glyphs.emDash} ${label}` }, taskId);
2130
+ }) }) : null,
2095
2131
  /* @__PURE__ */ jsx18(Box17, { marginTop: spacing.section, children: /* @__PURE__ */ jsx18(LogTail, { events: logEvents }) }),
2096
2132
  state.error ? /* @__PURE__ */ jsx18(Box17, { marginTop: spacing.section, children: /* @__PURE__ */ jsxs16(Text16, { color: inkColors.error, children: [
2097
2133
  glyphs.cross,
@@ -2123,10 +2159,24 @@ function ExecuteView({ sprintId, executionOptions }) {
2123
2159
  ] }) : null
2124
2160
  ] });
2125
2161
  }
2162
+ var STEP_LABELS = {
2163
+ "branch-preflight": "Verifying branch\u2026",
2164
+ "contract-negotiate": "Writing contract\u2026",
2165
+ "mark-in-progress": "Starting\u2026",
2166
+ "execute-task": "Running Claude\u2026",
2167
+ "store-verification": "Storing verification\u2026",
2168
+ "post-task-check": "Running post-task check\u2026",
2169
+ "evaluate-task": "Evaluating\u2026",
2170
+ "mark-done": "Finalizing\u2026"
2171
+ };
2172
+ function labelForStep(stepName) {
2173
+ return STEP_LABELS[stepName] ?? stepName;
2174
+ }
2126
2175
  function reduceEvents(state, events) {
2127
2176
  const running = new Set(state.running);
2128
2177
  const blocked = new Set(state.blocked);
2129
2178
  const activity = new Map(state.activity);
2179
+ const currentStep = new Map(state.currentStep);
2130
2180
  let rateLimit = state.rateLimit;
2131
2181
  for (const event of events) {
2132
2182
  switch (event.type) {
@@ -2135,10 +2185,22 @@ function reduceEvents(state, events) {
2135
2185
  break;
2136
2186
  case "task-finished":
2137
2187
  running.delete(event.taskId);
2188
+ activity.delete(event.taskId);
2189
+ currentStep.delete(event.taskId);
2138
2190
  if (event.status === "blocked" || event.status === "failed") {
2139
2191
  blocked.add(event.taskId);
2140
2192
  }
2141
2193
  break;
2194
+ case "task-step":
2195
+ if (event.phase === "start") {
2196
+ activity.set(event.taskId, labelForStep(event.stepName));
2197
+ currentStep.set(event.taskId, labelForStep(event.stepName));
2198
+ } else {
2199
+ if (currentStep.get(event.taskId) === labelForStep(event.stepName)) {
2200
+ currentStep.delete(event.taskId);
2201
+ }
2202
+ }
2203
+ break;
2142
2204
  case "rate-limit-paused":
2143
2205
  rateLimit = { pausedSince: event.timestamp, delayMs: event.delayMs };
2144
2206
  break;
@@ -2161,7 +2223,7 @@ function reduceEvents(state, events) {
2161
2223
  }
2162
2224
  }
2163
2225
  }
2164
- return { ...state, running, blocked, activity, rateLimit };
2226
+ return { ...state, running, blocked, activity, currentStep, rateLimit };
2165
2227
  }
2166
2228
 
2167
2229
  // src/integration/ui/tui/views/dashboard-view.tsx
@@ -2436,6 +2498,15 @@ function RefinePhaseView({ sprintId }) {
2436
2498
  useEffect10(() => {
2437
2499
  void loadSprint();
2438
2500
  }, [loadSprint]);
2501
+ useEffect10(() => {
2502
+ if (!state.running) return;
2503
+ const handle = setInterval(() => {
2504
+ void loadSprint();
2505
+ }, 1e3);
2506
+ return () => {
2507
+ clearInterval(handle);
2508
+ };
2509
+ }, [state.running, loadSprint]);
2439
2510
  const runRefine = useCallback5(async () => {
2440
2511
  setState((s) => ({ ...s, running: true, error: null, records: [] }));
2441
2512
  try {
@@ -3470,7 +3541,8 @@ var STEP_LABEL = {
3470
3541
  fetching: "Fetching issue data\u2026",
3471
3542
  title: "Awaiting ticket title\u2026",
3472
3543
  description: "Awaiting ticket description\u2026",
3473
- saving: "Saving ticket\u2026"
3544
+ saving: "Saving ticket\u2026",
3545
+ another: "Add another ticket?"
3474
3546
  };
3475
3547
  function isValidUrl(value) {
3476
3548
  try {
@@ -3505,40 +3577,52 @@ function TicketAddView() {
3505
3577
  setPhase({ kind: "no-project" });
3506
3578
  return;
3507
3579
  }
3508
- setPhase({ kind: "running", step: "link" });
3509
- const link = await prompt.input({
3510
- message: "Issue link (optional):",
3511
- validate: (v) => {
3512
- const trimmed = v.trim();
3513
- if (trimmed.length === 0) return true;
3514
- return isValidUrl(trimmed) ? true : "Must be a valid URL (or leave blank)";
3580
+ let count = 0;
3581
+ while (true) {
3582
+ setPhase({ kind: "running", step: "link" });
3583
+ const link = await prompt.input({
3584
+ message: "Issue link (optional):",
3585
+ validate: (v) => {
3586
+ const trimmed = v.trim();
3587
+ if (trimmed.length === 0) return true;
3588
+ return isValidUrl(trimmed) ? true : "Must be a valid URL (or leave blank)";
3589
+ }
3590
+ });
3591
+ const trimmedLink = link.trim();
3592
+ let prefill = null;
3593
+ if (trimmedLink.length > 0) {
3594
+ setPhase({ kind: "running", step: "fetching" });
3595
+ prefill = tryFetchIssue(trimmedLink);
3596
+ }
3597
+ setPhase({ kind: "running", step: "title" });
3598
+ const title = await prompt.input({
3599
+ message: "Title:",
3600
+ default: prefill?.title,
3601
+ validate: (v) => v.trim().length > 0 ? true : "Title is required"
3602
+ });
3603
+ setPhase({ kind: "running", step: "description" });
3604
+ const description = await prompt.editor({
3605
+ message: "Description (recommended)",
3606
+ default: prefill?.body
3607
+ });
3608
+ setPhase({ kind: "running", step: "saving" });
3609
+ const trimmedDescription = description?.trim() ?? "";
3610
+ const ticket = await addTicket({
3611
+ title: title.trim(),
3612
+ description: trimmedDescription.length > 0 ? trimmedDescription : void 0,
3613
+ link: trimmedLink.length > 0 ? trimmedLink : void 0
3614
+ });
3615
+ count++;
3616
+ setPhase({ kind: "running", step: "another" });
3617
+ const another = await prompt.confirm({
3618
+ message: `Add another ticket? (${String(count)} added)`,
3619
+ default: true
3620
+ });
3621
+ if (!another) {
3622
+ setPhase({ kind: "done", ticket, project, prefilled: prefill !== null, count });
3623
+ return;
3515
3624
  }
3516
- });
3517
- const trimmedLink = link.trim();
3518
- let prefill = null;
3519
- if (trimmedLink.length > 0) {
3520
- setPhase({ kind: "running", step: "fetching" });
3521
- prefill = tryFetchIssue(trimmedLink);
3522
3625
  }
3523
- setPhase({ kind: "running", step: "title" });
3524
- const title = await prompt.input({
3525
- message: "Title:",
3526
- default: prefill?.title,
3527
- validate: (v) => v.trim().length > 0 ? true : "Title is required"
3528
- });
3529
- setPhase({ kind: "running", step: "description" });
3530
- const description = await prompt.editor({
3531
- message: "Description (recommended)",
3532
- default: prefill?.body
3533
- });
3534
- setPhase({ kind: "running", step: "saving" });
3535
- const trimmedDescription = description?.trim() ?? "";
3536
- const ticket = await addTicket({
3537
- title: title.trim(),
3538
- description: trimmedDescription.length > 0 ? trimmedDescription : void 0,
3539
- link: trimmedLink.length > 0 ? trimmedLink : void 0
3540
- });
3541
- setPhase({ kind: "done", ticket, project, prefilled: prefill !== null });
3542
3626
  }
3543
3627
  });
3544
3628
  const hints = useMemo12(() => phase.kind === "running" ? HINTS_RUNNING6 : HINTS_DONE6, [phase.kind]);
@@ -3570,21 +3654,23 @@ function renderBody6(phase) {
3570
3654
  );
3571
3655
  case "error":
3572
3656
  return /* @__PURE__ */ jsx31(ResultCard, { kind: "error", title: "Could not add ticket", lines: [phase.message] });
3573
- case "done":
3657
+ case "done": {
3658
+ const title = phase.count > 1 ? `${String(phase.count)} tickets added` : phase.prefilled ? "Ticket added (prefilled from issue)" : "Ticket added";
3574
3659
  return /* @__PURE__ */ jsx31(
3575
3660
  ResultCard,
3576
3661
  {
3577
3662
  kind: "success",
3578
- title: phase.prefilled ? "Ticket added (prefilled from issue)" : "Ticket added",
3663
+ title,
3579
3664
  fields: [
3580
- ["ID", phase.ticket.id],
3581
- ["Title", phase.ticket.title],
3665
+ ["Last ID", phase.ticket.id],
3666
+ ["Last title", phase.ticket.title],
3582
3667
  ["Project", `${phase.project.displayName} (${phase.project.name})`],
3583
3668
  ["Status", `requirement: ${phase.ticket.requirementStatus}`]
3584
3669
  ],
3585
3670
  nextSteps: [{ action: "Refine requirements", description: "Home \u2192 Next: Refine Requirements" }]
3586
3671
  }
3587
3672
  );
3673
+ }
3588
3674
  }
3589
3675
  }
3590
3676
 
@@ -31,7 +31,10 @@ something entirely new (create a file, add a feature, tweak a script), do exactl
31
31
  it passes. If no check script is configured, skip this step.
32
32
  4. **Output verification results** — Wrap any verification output in `<task-verified>...</task-verified>`. If you
33
33
  skipped step 3, emit `<task-verified>no check script configured; change applied</task-verified>`.
34
- 5. **Signal completion** — Output `<task-complete>` once the change is applied and verification (if any) passed.
34
+ 5. **Commit your work** — Stage the modified files and create a git commit with a descriptive message summarising the
35
+ feedback you implemented. The harness refuses to mark the task done with a dirty working tree.
36
+ 6. **Signal completion** — Output `<task-complete>` once the change is applied, verification (if any) passed, and the
37
+ commit has landed.
35
38
 
36
39
  Only signal `<task-blocked>reason</task-blocked>` if the feedback is literally impossible to carry out (e.g., asks
37
40
  you to edit a file in a repository you don't have access to). Ambiguity is **not** a blocker — make a reasonable
@@ -42,6 +45,8 @@ interpretation and proceed.
42
45
  - **The feedback is the authoritative instruction** — implement it even if it seems unrelated to the completed tasks.
43
46
  - **Do the smallest change that fully satisfies the feedback** — no speculative refactors, no adjacent cleanup.
44
47
  - **Make the edits — don't just describe them** — the harness does not apply edits for you; you must write the files.
48
+ - **Must commit** — Create a git commit before signaling completion. Uncommitted changes leave the sprint branch dirty
49
+ and block sprint close.
45
50
 
46
51
  </constraints>
47
52
 
@@ -21,6 +21,11 @@ These verification criteria are the pre-agreed definition of "done" — your pri
21
21
 
22
22
  ## Review Protocol
23
23
 
24
+ **You are a reviewer — do not edit files.** If you believe a fix is needed, emit `<evaluation-failed>` with a concrete
25
+ critique; the harness will resume the generator to apply the fix. Do not run `git stash`, do not edit tests, do not
26
+ create commits. Your tools are read-only: `git status`, `git log`, `git diff`, file reads, and running existing check
27
+ scripts. Any write operation is a protocol violation.
28
+
24
29
  You are working in this project directory:
25
30
 
26
31
  ```
@@ -37,7 +42,8 @@ Run deterministic checks first — these are cheap, fast, and authoritative.
37
42
 
38
43
  1. **Run the check script** (if provided above) — this is the same gate the harness uses post-task. If it fails, the
39
44
  implementation fails regardless of how good the code looks. Record the output.
40
- 2. **Run `git status`** — uncommitted changes may indicate incomplete work
45
+ 2. **Run `git status`** — the tree MUST be clean. Uncommitted changes from the generator are a Completeness failure;
46
+ uncommitted changes from you are a protocol violation.
41
47
  3. **Run `git log --oneline -10`** — identify which commits belong to this task
42
48
 
43
49
  Computational results are ground truth. If the check script fails, stop early — the implementation does not pass.
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  parseSprintStartArgs,
4
4
  sprintStartCommand
5
- } from "./chunk-CSC4TBJB.mjs";
5
+ } from "./chunk-3HJNVQ7N.mjs";
6
6
  import "./chunk-JOQO4HMM.mjs";
7
7
  import "./chunk-CFUVE2BP.mjs";
8
8
  import "./chunk-747KW2RW.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralphctl",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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",