@ordewell/cli 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +232 -58
  2. package/package.json +3 -3
package/dist/main.js CHANGED
@@ -109,7 +109,7 @@ Planning:
109
109
  ordewell fork Continue in a copy of the conversation and its tasks
110
110
  ordewell compact Condense the conversation into a summary (tasks are kept)
111
111
  ordewell rewind [<n>] Fork the conversation from before message <n> (no <n>: list them)
112
- ordewell handoff [action] Review, merge, discard or clean up a finished isolated run
112
+ ordewell handoff [action] Review, merge, discard or clean up an isolated run
113
113
 
114
114
  Tasks (<id> is an order number or a task ID):
115
115
  ordewell add-task --title "..." Add a task to the current plan
@@ -117,7 +117,7 @@ Tasks (<id> is an order number or a task ID):
117
117
  ordewell complete <id> Mark a task complete (alias: mark-complete)
118
118
  ordewell uncomplete <id> Mark a completed task not done
119
119
  ordewell skip <id> Skip a task (marks it complete so dependents can run)
120
- ordewell force-start <id> Start a task now, ignoring dependencies
120
+ ordewell force-start <id> Start a task now, ignoring dependencies (asks past a merge gate)
121
121
  ordewell run-task <id> Run only one task
122
122
  ordewell retry <id> Re-run a failed task
123
123
  ordewell continue <id> <msg> Continue a finished task in its saved runner session
@@ -130,6 +130,7 @@ Per-task assignment (omit the value to list the options):
130
130
  ordewell task-effort <id> [level] Set a task's thinking effort ("default" to clear)
131
131
  ordewell task-mode <id> [mode] Set a task's runner mode
132
132
  ordewell task-deps <id> [a,b|none] Set which earlier tasks a task waits for
133
+ ordewell task-ops <id> [on|off] Make a task an ops task (runs in your checkout once its dependencies are merged) or a change task
133
134
 
134
135
  Planner, models and runners:
135
136
  ordewell planner [<provider>] Choose who plans \u2014 an API provider or a coding agent (no API key)
@@ -412,6 +413,8 @@ var ApiClient = class _ApiClient {
412
413
  static defaultWorkspace;
413
414
  port;
414
415
  workspace;
416
+ /** A session's open execution stream, so a new one can end it: one run is reported once. */
417
+ executionStreams = /* @__PURE__ */ new Map();
415
418
  constructor(port, workspace) {
416
419
  this.port = port || DEFAULT_PORT;
417
420
  this.workspace = workspace ?? _ApiClient.defaultWorkspace ?? process.cwd();
@@ -713,6 +716,17 @@ var ApiClient = class _ApiClient {
713
716
  }
714
717
  return { plan: res.data.plan, summary: res.data.summary, keptMessages: res.data.keptMessages };
715
718
  }
719
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when nothing gates it. */
720
+ async getMergeGate(sessionId, taskId) {
721
+ const res = await this.httpRequest(
722
+ "GET",
723
+ `/api/plans/${sessionId}/tasks/${taskId}/merge-gate`
724
+ );
725
+ if (res.status !== 200) {
726
+ throw new Error(res.data?.error || "Could not read the merge gate");
727
+ }
728
+ return res.data.mergeGate ?? [];
729
+ }
716
730
  async reviewRunDiff(sessionId) {
717
731
  const res = await this.httpRequest("GET", `/api/plans/${sessionId}/isolation/diff`);
718
732
  if (res.status !== 200) {
@@ -837,26 +851,36 @@ var ApiClient = class _ApiClient {
837
851
  }
838
852
  streamExecution(sessionId, onEvent, onReady) {
839
853
  return new Promise((resolve3, reject) => {
854
+ this.executionStreams.get(sessionId)?.();
840
855
  const socket = this.openSessionSocket(sessionId);
841
856
  let resolved = false;
842
857
  let opened = false;
858
+ const forget = () => {
859
+ if (this.executionStreams.get(sessionId) === end) this.executionStreams.delete(sessionId);
860
+ };
861
+ const end = () => {
862
+ forget();
863
+ if (resolved) return;
864
+ resolved = true;
865
+ socket.close();
866
+ resolve3();
867
+ };
868
+ this.executionStreams.set(sessionId, end);
843
869
  socket.on("open", () => {
844
870
  opened = true;
845
871
  onReady?.();
846
872
  });
847
873
  socket.on("message", (data) => {
874
+ if (resolved) return;
848
875
  try {
849
876
  const event = JSON.parse(data.toString());
850
877
  onEvent(event);
851
- if ((event.type === "execution_complete" || event.type === "execution_stopped" || event.type === "isolation_blocked") && !resolved) {
852
- resolved = true;
853
- socket.close();
854
- resolve3();
855
- }
878
+ if (event.type === "execution_complete" || event.type === "execution_stopped") end();
856
879
  } catch {
857
880
  }
858
881
  });
859
882
  socket.on("error", (err) => {
883
+ forget();
860
884
  if (!opened) onReady?.(err);
861
885
  if (!resolved) {
862
886
  resolved = true;
@@ -864,6 +888,7 @@ var ApiClient = class _ApiClient {
864
888
  }
865
889
  });
866
890
  socket.on("close", () => {
891
+ forget();
867
892
  if (!opened) onReady?.(new Error("Execution stream closed before connecting"));
868
893
  if (!resolved) {
869
894
  resolved = true;
@@ -1252,7 +1277,7 @@ Plan: ${tasks.length} task${tasks.length === 1 ? "" : "s"} (${aiCount} AI, ${man
1252
1277
  console.log(`Session: ${sessionId}
1253
1278
  `);
1254
1279
  for (const t of tasks) {
1255
- const typeIcon = t.type === "user" ? "[MAN]" : "[ AI]";
1280
+ const typeIcon = t.type === "user" ? "[MAN]" : t.ops ? "[OPS]" : "[ AI]";
1256
1281
  const deps = t.dependencies?.length ? ` \u2190 ${t.dependencies.join(", ")}` : "";
1257
1282
  console.log(` ${(0, import_core7.taskOrderLabel)(t).padStart(2)}. ${typeIcon} ${t.title}${modelSuffix(t)}${deps}`);
1258
1283
  for (const sub of t.subtasks || []) {
@@ -1264,6 +1289,9 @@ Plan: ${tasks.length} task${tasks.length === 1 ? "" : "s"} (${aiCount} AI, ${man
1264
1289
  if (manCount > 0) {
1265
1290
  console.log("\n [MAN] = manual step \u2014 run `ordewell tui` to work through it");
1266
1291
  }
1292
+ if (tasks.some((t) => t.ops)) {
1293
+ console.log(" [OPS] = ops task \u2014 runs in your checkout once the work it depends on is merged (`ordewell handoff merge`)");
1294
+ }
1267
1295
  }
1268
1296
  var PLANNER_ENV = ["AI_PROVIDER", "ORCHESTRATOR_MODEL", "ORDEWELL_PLANNER_EFFORT"];
1269
1297
  async function forwardPlannerEnv(api) {
@@ -1456,7 +1484,7 @@ function isolationOfPlan(plan) {
1456
1484
  // `tasksStatus` update fills it in once the stream catches up.
1457
1485
  ...repairedFiles ? { repairedFiles } : {}
1458
1486
  };
1459
- if (state === "integrated") {
1487
+ if (state === "integrated" && record.inHead !== true) {
1460
1488
  landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed, ...repairedFiles ? { repairedFiles } : {} });
1461
1489
  }
1462
1490
  }
@@ -1531,6 +1559,9 @@ function iconFor(status) {
1531
1559
  // src/commands/run.ts
1532
1560
  var import_core9 = require("@ordewell/core");
1533
1561
 
1562
+ // src/commands/shared.ts
1563
+ var import_readline2 = require("readline");
1564
+
1534
1565
  // src/catalog.ts
1535
1566
  function normalizeCatalog(result) {
1536
1567
  const runnersByModel = /* @__PURE__ */ new Map();
@@ -1586,6 +1617,16 @@ function fail(...lines) {
1586
1617
  for (const line of lines) console.error(line);
1587
1618
  process.exit(1);
1588
1619
  }
1620
+ async function askYesNo(question) {
1621
+ if (!process.stdin.isTTY) return false;
1622
+ const rl = (0, import_readline2.createInterface)({ input: process.stdin, output: process.stderr });
1623
+ try {
1624
+ const answer = await new Promise((resolve3) => rl.question(`${question} [y/N] `, resolve3));
1625
+ return /^y(es)?$/i.test(answer.trim());
1626
+ } finally {
1627
+ rl.close();
1628
+ }
1629
+ }
1589
1630
  function writeEnv(env) {
1590
1631
  const envFile = findEnvFile();
1591
1632
  for (const [key, value] of Object.entries(env)) {
@@ -1611,7 +1652,8 @@ function toTaskView(task) {
1611
1652
  assignedRunner: task.assignedRunner || void 0,
1612
1653
  taskMode: task.taskMode || void 0,
1613
1654
  assignedModel: task.assignedModel ?? void 0,
1614
- subtasks: [...task.subtasks ?? []].sort((a, b) => a.order - b.order).map(toTaskView)
1655
+ subtasks: [...task.subtasks ?? []].sort((a, b) => a.order - b.order).map(toTaskView),
1656
+ ...task.ops ? { ops: true } : {}
1615
1657
  };
1616
1658
  }
1617
1659
  function taskViews(plan) {
@@ -1658,6 +1700,12 @@ async function followExecution(api, sessionId, start, onBlocked) {
1658
1700
  lastPrinted = output;
1659
1701
  }
1660
1702
  }
1703
+ let pausedAtGate = false;
1704
+ let noteBlocked = () => {
1705
+ };
1706
+ const blockedSeen = new Promise((resolve3) => {
1707
+ noteBlocked = resolve3;
1708
+ });
1661
1709
  let settleReady = () => {
1662
1710
  };
1663
1711
  const streamReady = new Promise((resolve3, reject) => {
@@ -1674,6 +1722,9 @@ async function followExecution(api, sessionId, start, onBlocked) {
1674
1722
  }
1675
1723
  if (event.type === "status_update" && event.tasks) {
1676
1724
  printStatus(event.tasks);
1725
+ const paused = event.gate?.paused === true;
1726
+ if (paused && !pausedAtGate) process.stderr.write("\xB7 Paused for Merge all \u2014 `ordewell handoff merge` merges what has landed, and the waiting tasks start.\n");
1727
+ pausedAtGate = paused;
1677
1728
  }
1678
1729
  if (event.type === "review_needed") {
1679
1730
  console.log("\nPlan needs your sign-off \u2014 run `ordewell approve` to continue.");
@@ -1693,6 +1744,7 @@ Done. ${summary.completed || 0} completed, ${summary.failed || 0} failed, ${summ
1693
1744
  if (event.type === "isolation_blocked") {
1694
1745
  blocked = event.message;
1695
1746
  dirtyRepos = event.repos ?? [];
1747
+ noteBlocked();
1696
1748
  }
1697
1749
  if (event.type === "isolation_handoff") {
1698
1750
  const n = event.landed.length;
@@ -1710,7 +1762,7 @@ Run finished on ${handoffBranch(event)}${where} \u2014 ${n} task${n === 1 ? "" :
1710
1762
  void stream.catch(settleReady);
1711
1763
  await streamReady.catch(streamFailed);
1712
1764
  await start();
1713
- await stream.catch(streamFailed);
1765
+ await Promise.race([stream, blockedSeen]).catch(streamFailed);
1714
1766
  if (blocked === null) return;
1715
1767
  if (!onBlocked) {
1716
1768
  await api.stopExecution(sessionId);
@@ -2330,13 +2382,23 @@ async function withResolvedTask(subArgs, usage, injectedApi, run) {
2330
2382
  }
2331
2383
  await run(api, sessionId, taskId, plan);
2332
2384
  }
2385
+ var PASSES_GATE = /* @__PURE__ */ new Set(["run", "force-start"]);
2386
+ async function confirmPastGate(subArgs, api, sessionId, taskId, confirm) {
2387
+ if (hasFlag(subArgs, "--yes")) return;
2388
+ const unmerged = await api.getMergeGate(sessionId, taskId).catch(() => []);
2389
+ if (unmerged.length === 0) return;
2390
+ const question = `This task waits for Merge all: the work of ${unmerged.map((d) => `#${d.order} ${d.title}`).join(", ")} is not merged into your branch yet, so it would act without it. Starting it now is kept on the task. Start it anyway?`;
2391
+ if (!await confirm(question)) fail("Not started \u2014 nothing was changed. Pass --yes to start it without a prompt.");
2392
+ }
2333
2393
  function makeHandler(action, command = action) {
2334
- return async function handle(subArgs, injectedApi) {
2394
+ const yes = PASSES_GATE.has(action) ? " [--yes]" : "";
2395
+ return async function handle(subArgs, injectedApi, confirm = askYesNo) {
2335
2396
  await withResolvedTask(
2336
2397
  subArgs,
2337
- `Usage: ordewell ${command} <task-id-or-order> [--session-id <id>] [--workspace /path]`,
2398
+ `Usage: ordewell ${command} <task-id-or-order> [--session-id <id>] [--workspace /path]${yes}`,
2338
2399
  injectedApi,
2339
2400
  async (api, sessionId, taskId) => {
2401
+ if (PASSES_GATE.has(action)) await confirmPastGate(subArgs, api, sessionId, taskId, confirm);
2340
2402
  try {
2341
2403
  await api.taskControl(sessionId, taskId, action);
2342
2404
  console.log(PAST_TENSE[action]);
@@ -2613,9 +2675,6 @@ async function handleRewind(subArgs, injectedApi) {
2613
2675
  console.log(fork.rewoundMessage);
2614
2676
  }
2615
2677
 
2616
- // src/commands/handoff.ts
2617
- var import_readline2 = require("readline");
2618
-
2619
2678
  // src/tui/transcript.ts
2620
2679
  var import_core15 = require("@ordewell/core");
2621
2680
  function show(state, { view: conversation, gate: turnGate }) {
@@ -2808,8 +2867,12 @@ var reposOf2 = (handoff) => handoff.repos.map((r) => r.path).join(", ");
2808
2867
  var HANDOFF_USAGE = `/handoff [${HANDOFF_ACTIONS.map((a) => a.id).join("|")}]`;
2809
2868
  var diffRoom = (rows) => Math.max(1, rows - 3);
2810
2869
  var fail2 = (state, content) => ({ state: say(state, "error", content), effects: [] });
2870
+ function currentHandoff(state) {
2871
+ return state.gate?.handoff ?? state.handoff;
2872
+ }
2873
+ var midRun = (state) => state.status === "executing";
2811
2874
  function handoffCommand(state, arg) {
2812
- if (!state.sessionId || !state.handoff) return fail2(state, "No isolated run to hand off \u2014 a plan that ran in worktrees leaves one here.");
2875
+ if (!state.sessionId || !currentHandoff(state)) return fail2(state, "No isolated run to hand off \u2014 a plan that ran in worktrees leaves one here.");
2813
2876
  if (arg === void 0) return { state: { ...state, overlay: { kind: "handoff", index: 0, diff: null } }, effects: [] };
2814
2877
  const action = HANDOFF_ACTIONS.find((a) => a.id === arg.toLowerCase());
2815
2878
  if (!action) return fail2(state, `Usage: ${HANDOFF_USAGE}`);
@@ -2826,7 +2889,8 @@ function handoffArrived(state, handoff) {
2826
2889
  return state.overlay ? told : { ...told, overlay: { kind: "handoff", index: 0, diff: null } };
2827
2890
  }
2828
2891
  function runHandoffAction(state, id) {
2829
- const { sessionId, handoff } = state;
2892
+ const { sessionId } = state;
2893
+ const handoff = currentHandoff(state);
2830
2894
  if (!sessionId || !handoff) return fail2(state, "No isolated run to hand off.");
2831
2895
  const closed = { ...state, overlay: null };
2832
2896
  switch (id) {
@@ -2835,6 +2899,20 @@ function runHandoffAction(state, id) {
2835
2899
  case "cleanup":
2836
2900
  return { state: closed, effects: [{ type: "isolationCleanup", sessionId, branch: handoffBranch(handoff) }] };
2837
2901
  case "merge":
2902
+ if (midRun(state)) {
2903
+ return {
2904
+ state: {
2905
+ ...state,
2906
+ overlay: {
2907
+ kind: "confirm",
2908
+ title: isRepoGroup(handoff) ? "Merge what has landed into your branches?" : "Merge what has landed into your branch?",
2909
+ message: `Merge ${handoffBranch(handoff)} as it stands now into whatever ${isRepoGroup(handoff) ? `each of ${reposWithWork(handoff).join(", ")} has` : "you have"} checked out. Ordewell never does this on its own. The run goes on: tasks waiting for Merge all start once it has merged, and later tasks land on the same branch for the next Merge all. A merge that conflicts is aborted, and your ${isRepoGroup(handoff) ? "trees stay" : "tree stays"} as ${isRepoGroup(handoff) ? "they were" : "it was"}.${repairedNotice(handoff)}`,
2910
+ action: { kind: "merge-run" }
2911
+ }
2912
+ },
2913
+ effects: []
2914
+ };
2915
+ }
2838
2916
  return {
2839
2917
  state: {
2840
2918
  ...state,
@@ -2869,20 +2947,23 @@ function runHandoffAction(state, id) {
2869
2947
  }
2870
2948
  function confirmedHandoff(state, kind) {
2871
2949
  const closed = { ...state, overlay: null };
2872
- if (!state.sessionId || !state.handoff) return { state: closed, effects: [] };
2873
- const { sessionId, handoff } = state;
2950
+ const handoff = currentHandoff(state);
2951
+ if (!state.sessionId || !handoff) return { state: closed, effects: [] };
2952
+ const { sessionId } = state;
2874
2953
  const repaired = repairedLanded(handoff);
2875
2954
  const effect = kind === "merge-run" ? {
2876
2955
  type: "isolationMerge",
2877
2956
  sessionId,
2878
2957
  branch: handoffBranch(handoff),
2879
2958
  ...isRepoGroup(handoff) ? { group: true } : {},
2880
- ...repaired.length > 0 ? { repaired } : {}
2959
+ ...repaired.length > 0 ? { repaired } : {},
2960
+ ...midRun(state) ? { midRun: true } : {}
2881
2961
  } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2882
2962
  return { state: closed, effects: [effect] };
2883
2963
  }
2884
2964
  function showDiff(state, diff) {
2885
- if (diff.trim() === "") return say(state, "system", `Nothing differs from ${state.handoff ? handoffBase(state.handoff, 8) : "the base commit"}.`);
2965
+ const handoff = currentHandoff(state);
2966
+ if (diff.trim() === "") return say(state, "system", `Nothing differs from ${handoff ? handoffBase(handoff, 8) : "the base commit"}.`);
2886
2967
  const index = state.overlay?.kind === "handoff" ? state.overlay.index : 0;
2887
2968
  const lines = sanitize(diff.replace(/\t/g, " ")).split("\n");
2888
2969
  return { ...state, overlay: { kind: "handoff", index, diff: { lines, scroll: 0 } } };
@@ -2955,6 +3036,7 @@ function clearIsolation(state) {
2955
3036
  return {
2956
3037
  ...state,
2957
3038
  handoff: null,
3039
+ gate: null,
2958
3040
  tasks: mapTasks(state.tasks, ({ isolation: _dropped, ...task }) => task)
2959
3041
  };
2960
3042
  }
@@ -2972,16 +3054,6 @@ function isolationForPlan(tasks, record, previous) {
2972
3054
 
2973
3055
  // src/commands/handoff.ts
2974
3056
  var USAGE4 = "Usage: ordewell handoff [review|merge|discard|cleanup] [--session-id <id>] [--workspace /path] [--yes]";
2975
- async function askYesNo(question) {
2976
- if (!process.stdin.isTTY) return false;
2977
- const rl = (0, import_readline2.createInterface)({ input: process.stdin, output: process.stderr });
2978
- try {
2979
- const answer = await new Promise((resolve3) => rl.question(`${question} [y/N] `, resolve3));
2980
- return /^y(es)?$/i.test(answer.trim());
2981
- } finally {
2982
- rl.close();
2983
- }
2984
- }
2985
3057
  async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
2986
3058
  const action = positionals(subArgs)[0]?.toLowerCase();
2987
3059
  if (action === void 0) {
@@ -3016,7 +3088,7 @@ async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
3016
3088
  return;
3017
3089
  }
3018
3090
  case "merge": {
3019
- await confirmed((group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it; once all have, the run's worktrees and branches are removed.` : `Merge ${branch} into the branch you have checked out? Once merged, the run's worktrees and branches are removed.`) + repairedNotice(handoff));
3091
+ await confirmed((group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it; once a finished run has merged everything, its worktrees and branches are removed.` : `Merge ${branch} into the branch you have checked out? Once a finished run has merged everything, its worktrees and branches are removed.`) + repairedNotice(handoff));
3020
3092
  const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group, repairedLanded(handoff));
3021
3093
  if (ok) console.log(message);
3022
3094
  else fail(message);
@@ -3600,6 +3672,24 @@ Dependencies \xB7 #${task.order} ${task.title}
3600
3672
  console.log(dependencies.length > 0 ? `Task #${task.order} now depends on ${dependencies.length} task${dependencies.length === 1 ? "" : "s"}.` : `Task #${task.order} no longer depends on anything.`);
3601
3673
  });
3602
3674
  }
3675
+ var OPS_USAGE = "Usage: ordewell task-ops <task-id-or-order> [on|off] [--session-id <id>]";
3676
+ async function handleTaskOps(subArgs, injectedApi) {
3677
+ await withTask(subArgs, OPS_USAGE, injectedApi, async (api, sessionId, task, tasks, value) => {
3678
+ if (!value) {
3679
+ console.log(`
3680
+ #${task.order} ${task.title} is ${task.ops ? "an ops task: it runs in your checkout once the work it depends on is merged" : "a change task: it runs in its own worktree"}.`);
3681
+ console.log(`
3682
+ ${OPS_USAGE}`);
3683
+ return;
3684
+ }
3685
+ const to = value.toLowerCase();
3686
+ if (to !== "on" && to !== "off") fail(OPS_USAGE);
3687
+ if (task.type !== "ai") fail("Only an AI task can be an ops task \u2014 a manual task already runs outside any worktree.");
3688
+ if (!tasks.some((t) => t.id === task.id)) fail("A subtask runs with its parent; make the parent an ops task instead.");
3689
+ await api.updateTask(sessionId, task.id, { ops: to === "on" });
3690
+ console.log(to === "on" ? `Task #${task.order} is an ops task: it runs in your checkout once the work it depends on is merged.` : `Task #${task.order} is a change task: it runs in its own worktree.`);
3691
+ });
3692
+ }
3603
3693
 
3604
3694
  // src/tui/index.ts
3605
3695
  var import_core33 = require("@ordewell/core");
@@ -3757,6 +3847,7 @@ var SLASH_COMMANDS = [
3757
3847
  { name: "task-effort", usage: "/task-effort <id> [level]", description: "Choose or set a task's thinking effort", category: "tasks" },
3758
3848
  { name: "task-mode", usage: "/task-mode <id> [mode]", description: "Choose or set a task's runner mode", category: "tasks" },
3759
3849
  { name: "task-deps", usage: "/task-deps <id>", description: "Edit which earlier tasks a task waits for", category: "tasks" },
3850
+ { name: "task-ops", usage: "/task-ops <id> [on|off]", description: "Make a task an ops task \u2014 run in your checkout once its dependencies are merged \u2014 or a change task", category: "tasks" },
3760
3851
  // Models & providers
3761
3852
  { name: "planner", usage: "/planner [<provider>]", description: "Choose who plans \u2014 an API provider or a coding agent (no API key)", category: "models" },
3762
3853
  { name: "model", usage: "/model [set <id>]", description: "Show or set the orchestrator model", category: "models" },
@@ -4271,6 +4362,7 @@ function initialState(overrides = {}) {
4271
4362
  selection: null,
4272
4363
  workspace: process.cwd(),
4273
4364
  handoff: null,
4365
+ gate: null,
4274
4366
  overlay: null,
4275
4367
  pendingApprovals: [],
4276
4368
  queuedPrompts: import_core22.EMPTY_HOLD,
@@ -4346,6 +4438,7 @@ function footerHints(state) {
4346
4438
  "e effort",
4347
4439
  "M mode",
4348
4440
  "D deps",
4441
+ "O ops",
4349
4442
  "f start",
4350
4443
  "E run plan",
4351
4444
  "S stop",
@@ -4577,7 +4670,8 @@ var STATUS_ICON = {
4577
4670
  var AWAITING_LABEL = {
4578
4671
  input: "waiting for your input",
4579
4672
  checkpoint: "checkpoint",
4580
- conflict: "merge conflict"
4673
+ conflict: "merge conflict",
4674
+ "files-changed": "changed tracked files"
4581
4675
  };
4582
4676
  function approvalLabel(count) {
4583
4677
  return count > 1 ? `waiting for approval (${count})` : "waiting for approval";
@@ -4656,7 +4750,7 @@ function taskLines(state, row, index, cols) {
4656
4750
  const paint = idle ? style.cyan : STATUS_MARK[task.status] ?? ((t) => t);
4657
4751
  const rawIcon = idle ? IDLE_ICON : running ? SPINNER[state.spinnerFrame % SPINNER.length] : STATUS_ICON[task.status] ?? "\xB7";
4658
4752
  const icon = paint(rawIcon);
4659
- const kind = running ? style.yellow("RUN") : task.type === "user" ? style.yellow("MAN") : style.grey(" AI");
4753
+ const kind = running ? style.yellow("RUN") : task.type === "user" ? style.yellow("MAN") : task.ops ? style.magenta("OPS") : style.grey(" AI");
4660
4754
  const caret = selected ? style.cyan("\u276F") : " ";
4661
4755
  const orderLabel = parent ? (0, import_core23.taskOrderLabel)(task, parent) : String(task.order).padStart(2);
4662
4756
  const indent = parent ? " " : "";
@@ -4696,6 +4790,16 @@ function taskLines(state, row, index, cols) {
4696
4790
  const attempt = task.isolation.repair ? ` (attempt ${task.isolation.repair.attempt}/${task.isolation.repair.limit})` : "";
4697
4791
  lines.push(style.yellow(truncate(`${bodyPad}\u21BB repairing conflict${files}${attempt}`, cols)));
4698
4792
  }
4793
+ if (task.mergeGate?.length) {
4794
+ const deps = task.mergeGate.map((id) => {
4795
+ const dep = state.tasks.find((t) => t.id === id);
4796
+ return dep ? `#${dep.order}` : id;
4797
+ });
4798
+ lines.push(style.yellow(truncate(`${bodyPad}\u23F8 waits for Merge all \u2014 ${deps.join(", ")} not merged into your branch yet`, cols)));
4799
+ }
4800
+ if (task.status === "awaiting_user" && task.awaitingReason === "files-changed") {
4801
+ lines.push(style.yellow(truncate(`${bodyPad}\u26A0 an ops task changed tracked files \u2014 check them, then m done or /retry`, cols)));
4802
+ }
4699
4803
  if (task.isolation?.state === "integrated" && task.isolation.repairedFiles?.length) {
4700
4804
  lines.push(style.grey(truncate(`${bodyPad}\u21BB landed after repairing conflict in ${(0, import_core23.capConflictFiles)(task.isolation.repairedFiles)}`, cols)));
4701
4805
  }
@@ -4710,6 +4814,12 @@ function taskLines(state, row, index, cols) {
4710
4814
  lines.push(...taskText("Worktree", task.isolation.worktree, cols, bodyPad));
4711
4815
  }
4712
4816
  }
4817
+ if (task.ops) {
4818
+ lines.push(...taskText("Ops", "Runs in your checkout, not a worktree, once the work it depends on is merged. O makes it a change task.", cols, bodyPad));
4819
+ }
4820
+ if (task.forcedPastGate?.length) {
4821
+ lines.push(...taskText("Forced", `Started before the work of ${task.forcedPastGate.join(", ")} was merged into your branch.`, cols, bodyPad));
4822
+ }
4713
4823
  if (modeInfo?.autonomous) {
4714
4824
  lines.push(...taskText("Autonomy", "Runs without permission prompts (Full auto). Change the level with /auto.", cols, bodyPad));
4715
4825
  }
@@ -5040,7 +5150,7 @@ function renderOverlay(state, rows, cols) {
5040
5150
  return renderPicker(overlay.picker, rows, cols);
5041
5151
  }
5042
5152
  function renderHandoff(state, overlay, rows, cols) {
5043
- const handoff = state.handoff;
5153
+ const handoff = currentHandoff(state);
5044
5154
  if (!handoff) return frame("Handoff", [style.grey("Nothing to hand off.")], rows, cols);
5045
5155
  if (overlay.diff) {
5046
5156
  const room = diffRoom(rows);
@@ -5596,6 +5706,34 @@ function confirmRemoveTask(state, task) {
5596
5706
  }
5597
5707
  });
5598
5708
  }
5709
+ function toggleTaskOps(state, task, subtask, to = !task.ops) {
5710
+ if (!state.sessionId) return fail3(state, "No active plan.");
5711
+ if (task.type !== "ai") return fail3(state, "Only an AI task can be an ops task \u2014 a manual task already runs outside any worktree.");
5712
+ if (subtask) return fail3(state, "A subtask runs with its parent; make the parent an ops task instead.");
5713
+ if (!!task.ops === to) return fail3(state, `#${task.order} is already ${to ? "an ops" : "a change"} task.`);
5714
+ return step(state, [{
5715
+ type: "updateTask",
5716
+ sessionId: state.sessionId,
5717
+ taskId: task.id,
5718
+ changes: { ops: to },
5719
+ message: to ? `Task #${task.order} is an ops task: it runs in your checkout once the work it depends on is merged.` : `Task #${task.order} is a change task: it runs in its own worktree.`
5720
+ }]);
5721
+ }
5722
+ function confirmForceStartPastGate(state, task) {
5723
+ const named = (task.mergeGate ?? []).map((id) => {
5724
+ const dep = findTask(state.tasks, id);
5725
+ return dep ? `#${dep.order} ${dep.title}` : id;
5726
+ });
5727
+ return step({
5728
+ ...state,
5729
+ overlay: {
5730
+ kind: "confirm",
5731
+ title: `Force start #${task.order} ${task.title}?`,
5732
+ message: `It waits for Merge all: the work of ${named.join(", ")} is not merged into your branch yet, so it would act without it. Starting it now is kept on the task.`,
5733
+ action: { kind: "force-start-gated", taskId: task.id }
5734
+ }
5735
+ });
5736
+ }
5599
5737
  function openTaskDepsPicker(state, task) {
5600
5738
  const action = { kind: "set-task-deps", taskId: task.id };
5601
5739
  const items = pickerItemsFor(state, action);
@@ -5752,6 +5890,15 @@ function taskEffortCommand(state, args) {
5752
5890
  return assignTaskEffort(state, sessionId, task, value);
5753
5891
  });
5754
5892
  }
5893
+ function taskOpsCommand(state, args) {
5894
+ return taskCommand(state, args[0], (_sessionId, taskId) => {
5895
+ const task = findTask(state.tasks, taskId);
5896
+ const subtask = !state.tasks.some((t) => t.id === taskId);
5897
+ const value = args[1]?.toLowerCase();
5898
+ if (value !== void 0 && value !== "on" && value !== "off") return fail3(state, "Usage: /task-ops <id> [on|off]");
5899
+ return toggleTaskOps(state, task, subtask, value === void 0 ? !task.ops : value === "on");
5900
+ });
5901
+ }
5755
5902
  function addTask(state, title) {
5756
5903
  if (!state.sessionId) {
5757
5904
  return fail3(state, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
@@ -5832,9 +5979,11 @@ function handlePlanKey(state, key) {
5832
5979
  if (key.char === "E") return step(state, [{ type: "execute", sessionId: state.sessionId }]);
5833
5980
  if (key.char === "S") return step(state, [{ type: "stopExecution", sessionId: state.sessionId }]);
5834
5981
  const action = key.char === "m" ? markAction(task) : PLAN_SHORTCUTS[key.char ?? ""];
5982
+ if (action === "force-start" && task.mergeGate?.length) return confirmForceStartPastGate(state, task);
5835
5983
  if (action) {
5836
5984
  return step(state, [taskActionEffect(state, state.sessionId, task.id, action)]);
5837
5985
  }
5986
+ if (key.char === "O") return toggleTaskOps(state, task, row.parent !== null);
5838
5987
  if (key.char === "d") return confirmRemoveTask(state, task);
5839
5988
  if (key.char === "t") return openTaskTerminalOrView(state, state.sessionId, task.id);
5840
5989
  if (key.char === "x" && task.isolation?.state === "conflict") {
@@ -6009,6 +6158,8 @@ function runCommand(state, { name, args }) {
6009
6158
  return taskEffortCommand(state, args);
6010
6159
  case "task-mode":
6011
6160
  return taskModeCommand(state, args);
6161
+ case "task-ops":
6162
+ return taskOpsCommand(state, args);
6012
6163
  case "task-deps":
6013
6164
  return taskCommand(
6014
6165
  state,
@@ -6021,11 +6172,11 @@ function runCommand(state, { name, args }) {
6021
6172
  case "retry":
6022
6173
  case "cancel":
6023
6174
  case "force-start":
6024
- return taskCommand(
6025
- state,
6026
- args[0],
6027
- (sessionId, taskId) => step(state, [taskActionEffect(state, sessionId, taskId, name)])
6028
- );
6175
+ return taskCommand(state, args[0], (sessionId, taskId) => {
6176
+ const task = findTask(state.tasks, taskId);
6177
+ if (name === "force-start" && task?.mergeGate?.length) return confirmForceStartPastGate(state, task);
6178
+ return step(state, [taskActionEffect(state, sessionId, taskId, name)]);
6179
+ });
6029
6180
  }
6030
6181
  return fail3(state, `/${name} is not wired up yet.`);
6031
6182
  }
@@ -6346,6 +6497,10 @@ function runConfirm(state, overlay) {
6346
6497
  if (!state.sessionId) return step(closed);
6347
6498
  return step(closed, [{ type: "removeTask", sessionId: state.sessionId, taskId: overlay.action.taskId }]);
6348
6499
  }
6500
+ if (overlay.action.kind === "force-start-gated") {
6501
+ if (!state.sessionId) return step(closed);
6502
+ return step(closed, [taskActionEffect(closed, state.sessionId, overlay.action.taskId, "force-start")]);
6503
+ }
6349
6504
  if (overlay.action.kind === "merge-run" || overlay.action.kind === "discard-run") {
6350
6505
  return confirmedHandoff(state, overlay.action.kind);
6351
6506
  }
@@ -6551,7 +6706,9 @@ function toTaskView2(t, index) {
6551
6706
  thinkingEffort: typeof model.thinkingEffort === "string" ? planLabel(model.thinkingEffort) : typeof t.thinkingEffort === "string" ? planLabel(t.thinkingEffort) : void 0,
6552
6707
  availableVariants: Array.isArray(model.availableVariants) ? model.availableVariants.map((variant) => planLabel(variant)) : void 0
6553
6708
  } : void 0,
6554
- subtasks: Array.isArray(t.subtasks) ? [...t.subtasks].sort((a, b) => wireOrder(a) - wireOrder(b)).map((s, i) => toTaskView2(asRecord(s), i)) : void 0
6709
+ subtasks: Array.isArray(t.subtasks) ? [...t.subtasks].sort((a, b) => wireOrder(a) - wireOrder(b)).map((s, i) => toTaskView2(asRecord(s), i)) : void 0,
6710
+ ...t.ops === true ? { ops: true } : {},
6711
+ ...Array.isArray(t.forcedPastGate) && t.forcedPastGate.length > 0 ? { forcedPastGate: t.forcedPastGate.map((title) => planLabel(title)) } : {}
6555
6712
  };
6556
6713
  }
6557
6714
  var enabledFlag = (value) => typeof value?.enabled === "boolean" ? value.enabled : void 0;
@@ -6576,7 +6733,7 @@ function applySettings(state, settings) {
6576
6733
  allowlist: settings.modelAllowlist && typeof settings.modelAllowlist === "object" ? settings.modelAllowlist : state.allowlist
6577
6734
  };
6578
6735
  }
6579
- function runLabel(tasks) {
6736
+ function runLabel(tasks, gate = null) {
6580
6737
  const running = tasks.filter(isTaskRunning);
6581
6738
  if (running.length > 0) {
6582
6739
  const [first] = running;
@@ -6584,8 +6741,8 @@ function runLabel(tasks) {
6584
6741
  return running.length > 1 ? `${name} (+${running.length - 1} more)` : name;
6585
6742
  }
6586
6743
  const waiting = tasks.filter((t) => t.status === "awaiting_user").length;
6587
- if (waiting === 0) return "";
6588
- return waiting === 1 ? "1 task waits for you" : `${waiting} tasks wait for you`;
6744
+ if (waiting > 0) return waiting === 1 ? "1 task waits for you" : `${waiting} tasks wait for you`;
6745
+ return gate?.paused ? "paused for Merge all \u2014 /handoff merge" : "";
6589
6746
  }
6590
6747
  var statusText = (text) => sanitize(text).replace(/\s+/g, " ").trim();
6591
6748
  function inFlight(blocks) {
@@ -6705,7 +6862,7 @@ function reduceAction(state, action) {
6705
6862
  if (stale(state, action.sessionId) || !state.tasks.some((t) => t.id === action.taskId)) return step(state);
6706
6863
  const tasks = state.tasks.map((t) => t.id === action.taskId ? { ...t, status: action.status, awaitingReason: void 0 } : t);
6707
6864
  const status = runStatus(state, tasks);
6708
- return step({ ...state, status, tasks, busyLabel: status === "executing" ? runLabel(tasks) : state.busyLabel });
6865
+ return step({ ...state, status, tasks, busyLabel: status === "executing" ? runLabel(tasks, state.gate) : state.busyLabel });
6709
6866
  }
6710
6867
  case "tasksStatus": {
6711
6868
  if (stale(state, action.sessionId)) return step(state);
@@ -6720,15 +6877,20 @@ function reduceAction(state, action) {
6720
6877
  const awaitingReason = update.awaitingReason;
6721
6878
  const continuable = update.continuable === true;
6722
6879
  const awaitingApproval = update.awaitingApproval;
6723
- if (update.status !== t.status || idleSince !== (t.idleSince ?? null) || !sameIsolation(isolation, t.isolation) || !sameTransport(transport, t.transport) || awaitingReason !== t.awaitingReason || continuable !== (t.continuable ?? false) || awaitingApproval !== t.awaitingApproval) {
6880
+ const mergeGate = update.mergeGate;
6881
+ const forcedPastGate = update.forcedPastGate;
6882
+ if (update.status !== t.status || idleSince !== (t.idleSince ?? null) || !sameIsolation(isolation, t.isolation) || !sameTransport(transport, t.transport) || awaitingReason !== t.awaitingReason || continuable !== (t.continuable ?? false) || awaitingApproval !== t.awaitingApproval || !sameList(mergeGate, t.mergeGate) || !sameList(forcedPastGate, t.forcedPastGate)) {
6724
6883
  changed = true;
6725
- return { ...t, status: update.status, idleSince, isolation, transport, awaitingReason, continuable, awaitingApproval };
6884
+ return { ...t, status: update.status, idleSince, isolation, transport, awaitingReason, continuable, awaitingApproval, mergeGate, forcedPastGate };
6726
6885
  }
6727
6886
  return t;
6728
6887
  });
6729
- if (!changed) return step(state);
6730
- const status = runStatus(state, tasks);
6731
- return step({ ...state, status, tasks, busyLabel: status === "executing" ? runLabel(tasks) : state.busyLabel });
6888
+ const gate = action.gate === void 0 ? state.gate : action.gate;
6889
+ const gateChanged = !sameGate(gate, state.gate);
6890
+ if (!changed && !gateChanged) return step(state);
6891
+ const next = { ...state, tasks, gate };
6892
+ const status = runStatus(next, tasks);
6893
+ return step({ ...next, status, busyLabel: status === "executing" ? runLabel(tasks, gate) : state.busyLabel });
6732
6894
  }
6733
6895
  case "queueReady": {
6734
6896
  if (stale(state, action.sessionId)) return step(state);
@@ -6861,9 +7023,16 @@ function reduceAction(state, action) {
6861
7023
  function sameTransport(a, b) {
6862
7024
  return a?.kind === b?.kind && a?.fallback === b?.fallback;
6863
7025
  }
7026
+ function sameList(a, b) {
7027
+ return (a ?? []).join("\0") === (b ?? []).join("\0");
7028
+ }
7029
+ function sameGate(a, b) {
7030
+ if (!a || !b) return a === b;
7031
+ return a.paused === b.paused && JSON.stringify(a.handoff) === JSON.stringify(b.handoff);
7032
+ }
6864
7033
  function runStatus(state, tasks) {
6865
7034
  if (tasks.some(isTaskRunning)) return "executing";
6866
- if (tasks.some((t) => t.status === "awaiting_user")) return "executing";
7035
+ if (tasks.some((t) => t.status === "awaiting_user") || state.gate?.paused) return "executing";
6867
7036
  return plannerInFlight(state) ? state.status : "idle";
6868
7037
  }
6869
7038
  function interrupt(state) {
@@ -7211,10 +7380,13 @@ function dispatchLifecycle(dispatch, event, sessionId) {
7211
7380
  transport: task.transport,
7212
7381
  awaitingReason: (0, import_core31.isAwaitingReason)(task.awaitingReason) ? task.awaitingReason : void 0,
7213
7382
  ...task.continuable ? { continuable: true } : {},
7214
- awaitingApproval: typeof task.awaitingApproval === "number" && task.awaitingApproval > 0 ? task.awaitingApproval : void 0
7383
+ awaitingApproval: typeof task.awaitingApproval === "number" && task.awaitingApproval > 0 ? task.awaitingApproval : void 0,
7384
+ ...task.mergeGate?.length ? { mergeGate: task.mergeGate.map(String) } : {},
7385
+ ...task.forcedPastGate?.length ? { forcedPastGate: task.forcedPastGate.map(String) } : {}
7215
7386
  };
7216
7387
  }
7217
- dispatch({ type: "tasksStatus", updates, sessionId });
7388
+ const gate = event.gate ? { paused: event.gate.paused === true, handoff: { repos: event.gate.repos, landed: event.gate.landed } } : null;
7389
+ dispatch({ type: "tasksStatus", updates, gate, sessionId });
7218
7390
  return;
7219
7391
  }
7220
7392
  case "task_started":
@@ -7626,10 +7798,11 @@ async function perform(effect, deps) {
7626
7798
  return;
7627
7799
  // A conflict or a refusal is an answer, not a fault: either way the user's
7628
7800
  // tree is exactly as it was, and the words say what to do next. A full
7629
- // merge leaves nothing to hand over — the daemon has cleared the run up.
7801
+ // merge of a settled run leaves nothing to hand over — the daemon has
7802
+ // cleared the run up; one mid-run leaves the run going (ADR-0020).
7630
7803
  case "isolationMerge": {
7631
7804
  const { ok, message } = mergeOutcome(await api.mergeRun(effect.sessionId), effect.branch, effect.group === true, effect.repaired ?? []);
7632
- if (ok) dispatch({ type: "runCleared", sessionId: effect.sessionId });
7805
+ if (ok && !effect.midRun) dispatch({ type: "runCleared", sessionId: effect.sessionId });
7633
7806
  dispatch({ type: ok ? "notice" : "failed", message });
7634
7807
  return;
7635
7808
  }
@@ -7643,8 +7816,8 @@ async function perform(effect, deps) {
7643
7816
  await refreshPlan(deps, effect.sessionId);
7644
7817
  dispatch({ type: "notice", message: `Removed the run's worktrees and task branches; ${effect.branch} is kept.` });
7645
7818
  return;
7646
- // Spawns runners again, so it needs the stream a run needs — the blocked
7647
- // one closed itself when nothing started.
7819
+ // Spawns runners again, so it needs the stream a run needs. Opening it ends
7820
+ // the blocked run's, which stayed open to show the ops tasks still running.
7648
7821
  case "isolationContinue": {
7649
7822
  const { sessionId } = effect;
7650
7823
  await withExecutionStream(deps, sessionId, () => effect.mode === "stash" ? api.continueWithStash(sessionId) : api.continueWithoutIsolation(sessionId));
@@ -8202,6 +8375,7 @@ var COMMANDS = {
8202
8375
  "task-effort": handleTaskEffort,
8203
8376
  "task-mode": handleTaskMode,
8204
8377
  "task-deps": handleTaskDeps,
8378
+ "task-ops": handleTaskOps,
8205
8379
  terminal: handleTerminal,
8206
8380
  "sessions": handleSessions,
8207
8381
  fork: handleFork,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordewell/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Ordewell CLI and TUI — plan, run and verify coding-agent tasks from the terminal.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alessandro Costanzo Ciano",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ordewell/core": "^0.6.0",
53
- "@ordewell/web": "^0.6.0",
52
+ "@ordewell/core": "^0.6.2",
53
+ "@ordewell/web": "^0.6.2",
54
54
  "string-width": "^7.2.0",
55
55
  "ws": "^8.18.0"
56
56
  },