@ordewell/cli 0.6.0 → 0.6.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.
Files changed (2) hide show
  1. package/dist/main.js +205 -50
  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)
@@ -713,6 +714,17 @@ var ApiClient = class _ApiClient {
713
714
  }
714
715
  return { plan: res.data.plan, summary: res.data.summary, keptMessages: res.data.keptMessages };
715
716
  }
717
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when nothing gates it. */
718
+ async getMergeGate(sessionId, taskId) {
719
+ const res = await this.httpRequest(
720
+ "GET",
721
+ `/api/plans/${sessionId}/tasks/${taskId}/merge-gate`
722
+ );
723
+ if (res.status !== 200) {
724
+ throw new Error(res.data?.error || "Could not read the merge gate");
725
+ }
726
+ return res.data.mergeGate ?? [];
727
+ }
716
728
  async reviewRunDiff(sessionId) {
717
729
  const res = await this.httpRequest("GET", `/api/plans/${sessionId}/isolation/diff`);
718
730
  if (res.status !== 200) {
@@ -1252,7 +1264,7 @@ Plan: ${tasks.length} task${tasks.length === 1 ? "" : "s"} (${aiCount} AI, ${man
1252
1264
  console.log(`Session: ${sessionId}
1253
1265
  `);
1254
1266
  for (const t of tasks) {
1255
- const typeIcon = t.type === "user" ? "[MAN]" : "[ AI]";
1267
+ const typeIcon = t.type === "user" ? "[MAN]" : t.ops ? "[OPS]" : "[ AI]";
1256
1268
  const deps = t.dependencies?.length ? ` \u2190 ${t.dependencies.join(", ")}` : "";
1257
1269
  console.log(` ${(0, import_core7.taskOrderLabel)(t).padStart(2)}. ${typeIcon} ${t.title}${modelSuffix(t)}${deps}`);
1258
1270
  for (const sub of t.subtasks || []) {
@@ -1264,6 +1276,9 @@ Plan: ${tasks.length} task${tasks.length === 1 ? "" : "s"} (${aiCount} AI, ${man
1264
1276
  if (manCount > 0) {
1265
1277
  console.log("\n [MAN] = manual step \u2014 run `ordewell tui` to work through it");
1266
1278
  }
1279
+ if (tasks.some((t) => t.ops)) {
1280
+ console.log(" [OPS] = ops task \u2014 runs in your checkout once the work it depends on is merged (`ordewell handoff merge`)");
1281
+ }
1267
1282
  }
1268
1283
  var PLANNER_ENV = ["AI_PROVIDER", "ORCHESTRATOR_MODEL", "ORDEWELL_PLANNER_EFFORT"];
1269
1284
  async function forwardPlannerEnv(api) {
@@ -1456,7 +1471,7 @@ function isolationOfPlan(plan) {
1456
1471
  // `tasksStatus` update fills it in once the stream catches up.
1457
1472
  ...repairedFiles ? { repairedFiles } : {}
1458
1473
  };
1459
- if (state === "integrated") {
1474
+ if (state === "integrated" && record.inHead !== true) {
1460
1475
  landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed, ...repairedFiles ? { repairedFiles } : {} });
1461
1476
  }
1462
1477
  }
@@ -1531,6 +1546,9 @@ function iconFor(status) {
1531
1546
  // src/commands/run.ts
1532
1547
  var import_core9 = require("@ordewell/core");
1533
1548
 
1549
+ // src/commands/shared.ts
1550
+ var import_readline2 = require("readline");
1551
+
1534
1552
  // src/catalog.ts
1535
1553
  function normalizeCatalog(result) {
1536
1554
  const runnersByModel = /* @__PURE__ */ new Map();
@@ -1586,6 +1604,16 @@ function fail(...lines) {
1586
1604
  for (const line of lines) console.error(line);
1587
1605
  process.exit(1);
1588
1606
  }
1607
+ async function askYesNo(question) {
1608
+ if (!process.stdin.isTTY) return false;
1609
+ const rl = (0, import_readline2.createInterface)({ input: process.stdin, output: process.stderr });
1610
+ try {
1611
+ const answer = await new Promise((resolve3) => rl.question(`${question} [y/N] `, resolve3));
1612
+ return /^y(es)?$/i.test(answer.trim());
1613
+ } finally {
1614
+ rl.close();
1615
+ }
1616
+ }
1589
1617
  function writeEnv(env) {
1590
1618
  const envFile = findEnvFile();
1591
1619
  for (const [key, value] of Object.entries(env)) {
@@ -1611,7 +1639,8 @@ function toTaskView(task) {
1611
1639
  assignedRunner: task.assignedRunner || void 0,
1612
1640
  taskMode: task.taskMode || void 0,
1613
1641
  assignedModel: task.assignedModel ?? void 0,
1614
- subtasks: [...task.subtasks ?? []].sort((a, b) => a.order - b.order).map(toTaskView)
1642
+ subtasks: [...task.subtasks ?? []].sort((a, b) => a.order - b.order).map(toTaskView),
1643
+ ...task.ops ? { ops: true } : {}
1615
1644
  };
1616
1645
  }
1617
1646
  function taskViews(plan) {
@@ -1658,6 +1687,7 @@ async function followExecution(api, sessionId, start, onBlocked) {
1658
1687
  lastPrinted = output;
1659
1688
  }
1660
1689
  }
1690
+ let pausedAtGate = false;
1661
1691
  let settleReady = () => {
1662
1692
  };
1663
1693
  const streamReady = new Promise((resolve3, reject) => {
@@ -1674,6 +1704,9 @@ async function followExecution(api, sessionId, start, onBlocked) {
1674
1704
  }
1675
1705
  if (event.type === "status_update" && event.tasks) {
1676
1706
  printStatus(event.tasks);
1707
+ const paused = event.gate?.paused === true;
1708
+ 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");
1709
+ pausedAtGate = paused;
1677
1710
  }
1678
1711
  if (event.type === "review_needed") {
1679
1712
  console.log("\nPlan needs your sign-off \u2014 run `ordewell approve` to continue.");
@@ -2330,13 +2363,23 @@ async function withResolvedTask(subArgs, usage, injectedApi, run) {
2330
2363
  }
2331
2364
  await run(api, sessionId, taskId, plan);
2332
2365
  }
2366
+ var PASSES_GATE = /* @__PURE__ */ new Set(["run", "force-start"]);
2367
+ async function confirmPastGate(subArgs, api, sessionId, taskId, confirm) {
2368
+ if (hasFlag(subArgs, "--yes")) return;
2369
+ const unmerged = await api.getMergeGate(sessionId, taskId).catch(() => []);
2370
+ if (unmerged.length === 0) return;
2371
+ 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?`;
2372
+ if (!await confirm(question)) fail("Not started \u2014 nothing was changed. Pass --yes to start it without a prompt.");
2373
+ }
2333
2374
  function makeHandler(action, command = action) {
2334
- return async function handle(subArgs, injectedApi) {
2375
+ const yes = PASSES_GATE.has(action) ? " [--yes]" : "";
2376
+ return async function handle(subArgs, injectedApi, confirm = askYesNo) {
2335
2377
  await withResolvedTask(
2336
2378
  subArgs,
2337
- `Usage: ordewell ${command} <task-id-or-order> [--session-id <id>] [--workspace /path]`,
2379
+ `Usage: ordewell ${command} <task-id-or-order> [--session-id <id>] [--workspace /path]${yes}`,
2338
2380
  injectedApi,
2339
2381
  async (api, sessionId, taskId) => {
2382
+ if (PASSES_GATE.has(action)) await confirmPastGate(subArgs, api, sessionId, taskId, confirm);
2340
2383
  try {
2341
2384
  await api.taskControl(sessionId, taskId, action);
2342
2385
  console.log(PAST_TENSE[action]);
@@ -2613,9 +2656,6 @@ async function handleRewind(subArgs, injectedApi) {
2613
2656
  console.log(fork.rewoundMessage);
2614
2657
  }
2615
2658
 
2616
- // src/commands/handoff.ts
2617
- var import_readline2 = require("readline");
2618
-
2619
2659
  // src/tui/transcript.ts
2620
2660
  var import_core15 = require("@ordewell/core");
2621
2661
  function show(state, { view: conversation, gate: turnGate }) {
@@ -2808,8 +2848,12 @@ var reposOf2 = (handoff) => handoff.repos.map((r) => r.path).join(", ");
2808
2848
  var HANDOFF_USAGE = `/handoff [${HANDOFF_ACTIONS.map((a) => a.id).join("|")}]`;
2809
2849
  var diffRoom = (rows) => Math.max(1, rows - 3);
2810
2850
  var fail2 = (state, content) => ({ state: say(state, "error", content), effects: [] });
2851
+ function currentHandoff(state) {
2852
+ return state.gate?.handoff ?? state.handoff;
2853
+ }
2854
+ var midRun = (state) => state.status === "executing";
2811
2855
  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.");
2856
+ 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
2857
  if (arg === void 0) return { state: { ...state, overlay: { kind: "handoff", index: 0, diff: null } }, effects: [] };
2814
2858
  const action = HANDOFF_ACTIONS.find((a) => a.id === arg.toLowerCase());
2815
2859
  if (!action) return fail2(state, `Usage: ${HANDOFF_USAGE}`);
@@ -2826,7 +2870,8 @@ function handoffArrived(state, handoff) {
2826
2870
  return state.overlay ? told : { ...told, overlay: { kind: "handoff", index: 0, diff: null } };
2827
2871
  }
2828
2872
  function runHandoffAction(state, id) {
2829
- const { sessionId, handoff } = state;
2873
+ const { sessionId } = state;
2874
+ const handoff = currentHandoff(state);
2830
2875
  if (!sessionId || !handoff) return fail2(state, "No isolated run to hand off.");
2831
2876
  const closed = { ...state, overlay: null };
2832
2877
  switch (id) {
@@ -2835,6 +2880,20 @@ function runHandoffAction(state, id) {
2835
2880
  case "cleanup":
2836
2881
  return { state: closed, effects: [{ type: "isolationCleanup", sessionId, branch: handoffBranch(handoff) }] };
2837
2882
  case "merge":
2883
+ if (midRun(state)) {
2884
+ return {
2885
+ state: {
2886
+ ...state,
2887
+ overlay: {
2888
+ kind: "confirm",
2889
+ title: isRepoGroup(handoff) ? "Merge what has landed into your branches?" : "Merge what has landed into your branch?",
2890
+ 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)}`,
2891
+ action: { kind: "merge-run" }
2892
+ }
2893
+ },
2894
+ effects: []
2895
+ };
2896
+ }
2838
2897
  return {
2839
2898
  state: {
2840
2899
  ...state,
@@ -2869,20 +2928,23 @@ function runHandoffAction(state, id) {
2869
2928
  }
2870
2929
  function confirmedHandoff(state, kind) {
2871
2930
  const closed = { ...state, overlay: null };
2872
- if (!state.sessionId || !state.handoff) return { state: closed, effects: [] };
2873
- const { sessionId, handoff } = state;
2931
+ const handoff = currentHandoff(state);
2932
+ if (!state.sessionId || !handoff) return { state: closed, effects: [] };
2933
+ const { sessionId } = state;
2874
2934
  const repaired = repairedLanded(handoff);
2875
2935
  const effect = kind === "merge-run" ? {
2876
2936
  type: "isolationMerge",
2877
2937
  sessionId,
2878
2938
  branch: handoffBranch(handoff),
2879
2939
  ...isRepoGroup(handoff) ? { group: true } : {},
2880
- ...repaired.length > 0 ? { repaired } : {}
2940
+ ...repaired.length > 0 ? { repaired } : {},
2941
+ ...midRun(state) ? { midRun: true } : {}
2881
2942
  } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2882
2943
  return { state: closed, effects: [effect] };
2883
2944
  }
2884
2945
  function showDiff(state, diff) {
2885
- if (diff.trim() === "") return say(state, "system", `Nothing differs from ${state.handoff ? handoffBase(state.handoff, 8) : "the base commit"}.`);
2946
+ const handoff = currentHandoff(state);
2947
+ if (diff.trim() === "") return say(state, "system", `Nothing differs from ${handoff ? handoffBase(handoff, 8) : "the base commit"}.`);
2886
2948
  const index = state.overlay?.kind === "handoff" ? state.overlay.index : 0;
2887
2949
  const lines = sanitize(diff.replace(/\t/g, " ")).split("\n");
2888
2950
  return { ...state, overlay: { kind: "handoff", index, diff: { lines, scroll: 0 } } };
@@ -2955,6 +3017,7 @@ function clearIsolation(state) {
2955
3017
  return {
2956
3018
  ...state,
2957
3019
  handoff: null,
3020
+ gate: null,
2958
3021
  tasks: mapTasks(state.tasks, ({ isolation: _dropped, ...task }) => task)
2959
3022
  };
2960
3023
  }
@@ -2972,16 +3035,6 @@ function isolationForPlan(tasks, record, previous) {
2972
3035
 
2973
3036
  // src/commands/handoff.ts
2974
3037
  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
3038
  async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
2986
3039
  const action = positionals(subArgs)[0]?.toLowerCase();
2987
3040
  if (action === void 0) {
@@ -3016,7 +3069,7 @@ async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
3016
3069
  return;
3017
3070
  }
3018
3071
  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));
3072
+ 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
3073
  const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group, repairedLanded(handoff));
3021
3074
  if (ok) console.log(message);
3022
3075
  else fail(message);
@@ -3600,6 +3653,24 @@ Dependencies \xB7 #${task.order} ${task.title}
3600
3653
  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
3654
  });
3602
3655
  }
3656
+ var OPS_USAGE = "Usage: ordewell task-ops <task-id-or-order> [on|off] [--session-id <id>]";
3657
+ async function handleTaskOps(subArgs, injectedApi) {
3658
+ await withTask(subArgs, OPS_USAGE, injectedApi, async (api, sessionId, task, tasks, value) => {
3659
+ if (!value) {
3660
+ console.log(`
3661
+ #${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"}.`);
3662
+ console.log(`
3663
+ ${OPS_USAGE}`);
3664
+ return;
3665
+ }
3666
+ const to = value.toLowerCase();
3667
+ if (to !== "on" && to !== "off") fail(OPS_USAGE);
3668
+ if (task.type !== "ai") fail("Only an AI task can be an ops task \u2014 a manual task already runs outside any worktree.");
3669
+ if (!tasks.some((t) => t.id === task.id)) fail("A subtask runs with its parent; make the parent an ops task instead.");
3670
+ await api.updateTask(sessionId, task.id, { ops: to === "on" });
3671
+ 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.`);
3672
+ });
3673
+ }
3603
3674
 
3604
3675
  // src/tui/index.ts
3605
3676
  var import_core33 = require("@ordewell/core");
@@ -3757,6 +3828,7 @@ var SLASH_COMMANDS = [
3757
3828
  { name: "task-effort", usage: "/task-effort <id> [level]", description: "Choose or set a task's thinking effort", category: "tasks" },
3758
3829
  { name: "task-mode", usage: "/task-mode <id> [mode]", description: "Choose or set a task's runner mode", category: "tasks" },
3759
3830
  { name: "task-deps", usage: "/task-deps <id>", description: "Edit which earlier tasks a task waits for", category: "tasks" },
3831
+ { 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
3832
  // Models & providers
3761
3833
  { name: "planner", usage: "/planner [<provider>]", description: "Choose who plans \u2014 an API provider or a coding agent (no API key)", category: "models" },
3762
3834
  { name: "model", usage: "/model [set <id>]", description: "Show or set the orchestrator model", category: "models" },
@@ -4271,6 +4343,7 @@ function initialState(overrides = {}) {
4271
4343
  selection: null,
4272
4344
  workspace: process.cwd(),
4273
4345
  handoff: null,
4346
+ gate: null,
4274
4347
  overlay: null,
4275
4348
  pendingApprovals: [],
4276
4349
  queuedPrompts: import_core22.EMPTY_HOLD,
@@ -4346,6 +4419,7 @@ function footerHints(state) {
4346
4419
  "e effort",
4347
4420
  "M mode",
4348
4421
  "D deps",
4422
+ "O ops",
4349
4423
  "f start",
4350
4424
  "E run plan",
4351
4425
  "S stop",
@@ -4577,7 +4651,8 @@ var STATUS_ICON = {
4577
4651
  var AWAITING_LABEL = {
4578
4652
  input: "waiting for your input",
4579
4653
  checkpoint: "checkpoint",
4580
- conflict: "merge conflict"
4654
+ conflict: "merge conflict",
4655
+ "files-changed": "changed tracked files"
4581
4656
  };
4582
4657
  function approvalLabel(count) {
4583
4658
  return count > 1 ? `waiting for approval (${count})` : "waiting for approval";
@@ -4656,7 +4731,7 @@ function taskLines(state, row, index, cols) {
4656
4731
  const paint = idle ? style.cyan : STATUS_MARK[task.status] ?? ((t) => t);
4657
4732
  const rawIcon = idle ? IDLE_ICON : running ? SPINNER[state.spinnerFrame % SPINNER.length] : STATUS_ICON[task.status] ?? "\xB7";
4658
4733
  const icon = paint(rawIcon);
4659
- const kind = running ? style.yellow("RUN") : task.type === "user" ? style.yellow("MAN") : style.grey(" AI");
4734
+ const kind = running ? style.yellow("RUN") : task.type === "user" ? style.yellow("MAN") : task.ops ? style.magenta("OPS") : style.grey(" AI");
4660
4735
  const caret = selected ? style.cyan("\u276F") : " ";
4661
4736
  const orderLabel = parent ? (0, import_core23.taskOrderLabel)(task, parent) : String(task.order).padStart(2);
4662
4737
  const indent = parent ? " " : "";
@@ -4696,6 +4771,16 @@ function taskLines(state, row, index, cols) {
4696
4771
  const attempt = task.isolation.repair ? ` (attempt ${task.isolation.repair.attempt}/${task.isolation.repair.limit})` : "";
4697
4772
  lines.push(style.yellow(truncate(`${bodyPad}\u21BB repairing conflict${files}${attempt}`, cols)));
4698
4773
  }
4774
+ if (task.mergeGate?.length) {
4775
+ const deps = task.mergeGate.map((id) => {
4776
+ const dep = state.tasks.find((t) => t.id === id);
4777
+ return dep ? `#${dep.order}` : id;
4778
+ });
4779
+ lines.push(style.yellow(truncate(`${bodyPad}\u23F8 waits for Merge all \u2014 ${deps.join(", ")} not merged into your branch yet`, cols)));
4780
+ }
4781
+ if (task.status === "awaiting_user" && task.awaitingReason === "files-changed") {
4782
+ lines.push(style.yellow(truncate(`${bodyPad}\u26A0 an ops task changed tracked files \u2014 check them, then m done or /retry`, cols)));
4783
+ }
4699
4784
  if (task.isolation?.state === "integrated" && task.isolation.repairedFiles?.length) {
4700
4785
  lines.push(style.grey(truncate(`${bodyPad}\u21BB landed after repairing conflict in ${(0, import_core23.capConflictFiles)(task.isolation.repairedFiles)}`, cols)));
4701
4786
  }
@@ -4710,6 +4795,12 @@ function taskLines(state, row, index, cols) {
4710
4795
  lines.push(...taskText("Worktree", task.isolation.worktree, cols, bodyPad));
4711
4796
  }
4712
4797
  }
4798
+ if (task.ops) {
4799
+ 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));
4800
+ }
4801
+ if (task.forcedPastGate?.length) {
4802
+ lines.push(...taskText("Forced", `Started before the work of ${task.forcedPastGate.join(", ")} was merged into your branch.`, cols, bodyPad));
4803
+ }
4713
4804
  if (modeInfo?.autonomous) {
4714
4805
  lines.push(...taskText("Autonomy", "Runs without permission prompts (Full auto). Change the level with /auto.", cols, bodyPad));
4715
4806
  }
@@ -5040,7 +5131,7 @@ function renderOverlay(state, rows, cols) {
5040
5131
  return renderPicker(overlay.picker, rows, cols);
5041
5132
  }
5042
5133
  function renderHandoff(state, overlay, rows, cols) {
5043
- const handoff = state.handoff;
5134
+ const handoff = currentHandoff(state);
5044
5135
  if (!handoff) return frame("Handoff", [style.grey("Nothing to hand off.")], rows, cols);
5045
5136
  if (overlay.diff) {
5046
5137
  const room = diffRoom(rows);
@@ -5596,6 +5687,34 @@ function confirmRemoveTask(state, task) {
5596
5687
  }
5597
5688
  });
5598
5689
  }
5690
+ function toggleTaskOps(state, task, subtask, to = !task.ops) {
5691
+ if (!state.sessionId) return fail3(state, "No active plan.");
5692
+ 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.");
5693
+ if (subtask) return fail3(state, "A subtask runs with its parent; make the parent an ops task instead.");
5694
+ if (!!task.ops === to) return fail3(state, `#${task.order} is already ${to ? "an ops" : "a change"} task.`);
5695
+ return step(state, [{
5696
+ type: "updateTask",
5697
+ sessionId: state.sessionId,
5698
+ taskId: task.id,
5699
+ changes: { ops: to },
5700
+ 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.`
5701
+ }]);
5702
+ }
5703
+ function confirmForceStartPastGate(state, task) {
5704
+ const named = (task.mergeGate ?? []).map((id) => {
5705
+ const dep = findTask(state.tasks, id);
5706
+ return dep ? `#${dep.order} ${dep.title}` : id;
5707
+ });
5708
+ return step({
5709
+ ...state,
5710
+ overlay: {
5711
+ kind: "confirm",
5712
+ title: `Force start #${task.order} ${task.title}?`,
5713
+ 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.`,
5714
+ action: { kind: "force-start-gated", taskId: task.id }
5715
+ }
5716
+ });
5717
+ }
5599
5718
  function openTaskDepsPicker(state, task) {
5600
5719
  const action = { kind: "set-task-deps", taskId: task.id };
5601
5720
  const items = pickerItemsFor(state, action);
@@ -5752,6 +5871,15 @@ function taskEffortCommand(state, args) {
5752
5871
  return assignTaskEffort(state, sessionId, task, value);
5753
5872
  });
5754
5873
  }
5874
+ function taskOpsCommand(state, args) {
5875
+ return taskCommand(state, args[0], (_sessionId, taskId) => {
5876
+ const task = findTask(state.tasks, taskId);
5877
+ const subtask = !state.tasks.some((t) => t.id === taskId);
5878
+ const value = args[1]?.toLowerCase();
5879
+ if (value !== void 0 && value !== "on" && value !== "off") return fail3(state, "Usage: /task-ops <id> [on|off]");
5880
+ return toggleTaskOps(state, task, subtask, value === void 0 ? !task.ops : value === "on");
5881
+ });
5882
+ }
5755
5883
  function addTask(state, title) {
5756
5884
  if (!state.sessionId) {
5757
5885
  return fail3(state, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
@@ -5832,9 +5960,11 @@ function handlePlanKey(state, key) {
5832
5960
  if (key.char === "E") return step(state, [{ type: "execute", sessionId: state.sessionId }]);
5833
5961
  if (key.char === "S") return step(state, [{ type: "stopExecution", sessionId: state.sessionId }]);
5834
5962
  const action = key.char === "m" ? markAction(task) : PLAN_SHORTCUTS[key.char ?? ""];
5963
+ if (action === "force-start" && task.mergeGate?.length) return confirmForceStartPastGate(state, task);
5835
5964
  if (action) {
5836
5965
  return step(state, [taskActionEffect(state, state.sessionId, task.id, action)]);
5837
5966
  }
5967
+ if (key.char === "O") return toggleTaskOps(state, task, row.parent !== null);
5838
5968
  if (key.char === "d") return confirmRemoveTask(state, task);
5839
5969
  if (key.char === "t") return openTaskTerminalOrView(state, state.sessionId, task.id);
5840
5970
  if (key.char === "x" && task.isolation?.state === "conflict") {
@@ -6009,6 +6139,8 @@ function runCommand(state, { name, args }) {
6009
6139
  return taskEffortCommand(state, args);
6010
6140
  case "task-mode":
6011
6141
  return taskModeCommand(state, args);
6142
+ case "task-ops":
6143
+ return taskOpsCommand(state, args);
6012
6144
  case "task-deps":
6013
6145
  return taskCommand(
6014
6146
  state,
@@ -6021,11 +6153,11 @@ function runCommand(state, { name, args }) {
6021
6153
  case "retry":
6022
6154
  case "cancel":
6023
6155
  case "force-start":
6024
- return taskCommand(
6025
- state,
6026
- args[0],
6027
- (sessionId, taskId) => step(state, [taskActionEffect(state, sessionId, taskId, name)])
6028
- );
6156
+ return taskCommand(state, args[0], (sessionId, taskId) => {
6157
+ const task = findTask(state.tasks, taskId);
6158
+ if (name === "force-start" && task?.mergeGate?.length) return confirmForceStartPastGate(state, task);
6159
+ return step(state, [taskActionEffect(state, sessionId, taskId, name)]);
6160
+ });
6029
6161
  }
6030
6162
  return fail3(state, `/${name} is not wired up yet.`);
6031
6163
  }
@@ -6346,6 +6478,10 @@ function runConfirm(state, overlay) {
6346
6478
  if (!state.sessionId) return step(closed);
6347
6479
  return step(closed, [{ type: "removeTask", sessionId: state.sessionId, taskId: overlay.action.taskId }]);
6348
6480
  }
6481
+ if (overlay.action.kind === "force-start-gated") {
6482
+ if (!state.sessionId) return step(closed);
6483
+ return step(closed, [taskActionEffect(closed, state.sessionId, overlay.action.taskId, "force-start")]);
6484
+ }
6349
6485
  if (overlay.action.kind === "merge-run" || overlay.action.kind === "discard-run") {
6350
6486
  return confirmedHandoff(state, overlay.action.kind);
6351
6487
  }
@@ -6551,7 +6687,9 @@ function toTaskView2(t, index) {
6551
6687
  thinkingEffort: typeof model.thinkingEffort === "string" ? planLabel(model.thinkingEffort) : typeof t.thinkingEffort === "string" ? planLabel(t.thinkingEffort) : void 0,
6552
6688
  availableVariants: Array.isArray(model.availableVariants) ? model.availableVariants.map((variant) => planLabel(variant)) : void 0
6553
6689
  } : 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
6690
+ subtasks: Array.isArray(t.subtasks) ? [...t.subtasks].sort((a, b) => wireOrder(a) - wireOrder(b)).map((s, i) => toTaskView2(asRecord(s), i)) : void 0,
6691
+ ...t.ops === true ? { ops: true } : {},
6692
+ ...Array.isArray(t.forcedPastGate) && t.forcedPastGate.length > 0 ? { forcedPastGate: t.forcedPastGate.map((title) => planLabel(title)) } : {}
6555
6693
  };
6556
6694
  }
6557
6695
  var enabledFlag = (value) => typeof value?.enabled === "boolean" ? value.enabled : void 0;
@@ -6576,7 +6714,7 @@ function applySettings(state, settings) {
6576
6714
  allowlist: settings.modelAllowlist && typeof settings.modelAllowlist === "object" ? settings.modelAllowlist : state.allowlist
6577
6715
  };
6578
6716
  }
6579
- function runLabel(tasks) {
6717
+ function runLabel(tasks, gate = null) {
6580
6718
  const running = tasks.filter(isTaskRunning);
6581
6719
  if (running.length > 0) {
6582
6720
  const [first] = running;
@@ -6584,8 +6722,8 @@ function runLabel(tasks) {
6584
6722
  return running.length > 1 ? `${name} (+${running.length - 1} more)` : name;
6585
6723
  }
6586
6724
  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`;
6725
+ if (waiting > 0) return waiting === 1 ? "1 task waits for you" : `${waiting} tasks wait for you`;
6726
+ return gate?.paused ? "paused for Merge all \u2014 /handoff merge" : "";
6589
6727
  }
6590
6728
  var statusText = (text) => sanitize(text).replace(/\s+/g, " ").trim();
6591
6729
  function inFlight(blocks) {
@@ -6705,7 +6843,7 @@ function reduceAction(state, action) {
6705
6843
  if (stale(state, action.sessionId) || !state.tasks.some((t) => t.id === action.taskId)) return step(state);
6706
6844
  const tasks = state.tasks.map((t) => t.id === action.taskId ? { ...t, status: action.status, awaitingReason: void 0 } : t);
6707
6845
  const status = runStatus(state, tasks);
6708
- return step({ ...state, status, tasks, busyLabel: status === "executing" ? runLabel(tasks) : state.busyLabel });
6846
+ return step({ ...state, status, tasks, busyLabel: status === "executing" ? runLabel(tasks, state.gate) : state.busyLabel });
6709
6847
  }
6710
6848
  case "tasksStatus": {
6711
6849
  if (stale(state, action.sessionId)) return step(state);
@@ -6720,15 +6858,20 @@ function reduceAction(state, action) {
6720
6858
  const awaitingReason = update.awaitingReason;
6721
6859
  const continuable = update.continuable === true;
6722
6860
  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) {
6861
+ const mergeGate = update.mergeGate;
6862
+ const forcedPastGate = update.forcedPastGate;
6863
+ 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
6864
  changed = true;
6725
- return { ...t, status: update.status, idleSince, isolation, transport, awaitingReason, continuable, awaitingApproval };
6865
+ return { ...t, status: update.status, idleSince, isolation, transport, awaitingReason, continuable, awaitingApproval, mergeGate, forcedPastGate };
6726
6866
  }
6727
6867
  return t;
6728
6868
  });
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 });
6869
+ const gate = action.gate === void 0 ? state.gate : action.gate;
6870
+ const gateChanged = !sameGate(gate, state.gate);
6871
+ if (!changed && !gateChanged) return step(state);
6872
+ const next = { ...state, tasks, gate };
6873
+ const status = runStatus(next, tasks);
6874
+ return step({ ...next, status, busyLabel: status === "executing" ? runLabel(tasks, gate) : state.busyLabel });
6732
6875
  }
6733
6876
  case "queueReady": {
6734
6877
  if (stale(state, action.sessionId)) return step(state);
@@ -6861,9 +7004,16 @@ function reduceAction(state, action) {
6861
7004
  function sameTransport(a, b) {
6862
7005
  return a?.kind === b?.kind && a?.fallback === b?.fallback;
6863
7006
  }
7007
+ function sameList(a, b) {
7008
+ return (a ?? []).join("\0") === (b ?? []).join("\0");
7009
+ }
7010
+ function sameGate(a, b) {
7011
+ if (!a || !b) return a === b;
7012
+ return a.paused === b.paused && JSON.stringify(a.handoff) === JSON.stringify(b.handoff);
7013
+ }
6864
7014
  function runStatus(state, tasks) {
6865
7015
  if (tasks.some(isTaskRunning)) return "executing";
6866
- if (tasks.some((t) => t.status === "awaiting_user")) return "executing";
7016
+ if (tasks.some((t) => t.status === "awaiting_user") || state.gate?.paused) return "executing";
6867
7017
  return plannerInFlight(state) ? state.status : "idle";
6868
7018
  }
6869
7019
  function interrupt(state) {
@@ -7211,10 +7361,13 @@ function dispatchLifecycle(dispatch, event, sessionId) {
7211
7361
  transport: task.transport,
7212
7362
  awaitingReason: (0, import_core31.isAwaitingReason)(task.awaitingReason) ? task.awaitingReason : void 0,
7213
7363
  ...task.continuable ? { continuable: true } : {},
7214
- awaitingApproval: typeof task.awaitingApproval === "number" && task.awaitingApproval > 0 ? task.awaitingApproval : void 0
7364
+ awaitingApproval: typeof task.awaitingApproval === "number" && task.awaitingApproval > 0 ? task.awaitingApproval : void 0,
7365
+ ...task.mergeGate?.length ? { mergeGate: task.mergeGate.map(String) } : {},
7366
+ ...task.forcedPastGate?.length ? { forcedPastGate: task.forcedPastGate.map(String) } : {}
7215
7367
  };
7216
7368
  }
7217
- dispatch({ type: "tasksStatus", updates, sessionId });
7369
+ const gate = event.gate ? { paused: event.gate.paused === true, handoff: { repos: event.gate.repos, landed: event.gate.landed } } : null;
7370
+ dispatch({ type: "tasksStatus", updates, gate, sessionId });
7218
7371
  return;
7219
7372
  }
7220
7373
  case "task_started":
@@ -7626,10 +7779,11 @@ async function perform(effect, deps) {
7626
7779
  return;
7627
7780
  // A conflict or a refusal is an answer, not a fault: either way the user's
7628
7781
  // 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.
7782
+ // merge of a settled run leaves nothing to hand over — the daemon has
7783
+ // cleared the run up; one mid-run leaves the run going (ADR-0020).
7630
7784
  case "isolationMerge": {
7631
7785
  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 });
7786
+ if (ok && !effect.midRun) dispatch({ type: "runCleared", sessionId: effect.sessionId });
7633
7787
  dispatch({ type: ok ? "notice" : "failed", message });
7634
7788
  return;
7635
7789
  }
@@ -8202,6 +8356,7 @@ var COMMANDS = {
8202
8356
  "task-effort": handleTaskEffort,
8203
8357
  "task-mode": handleTaskMode,
8204
8358
  "task-deps": handleTaskDeps,
8359
+ "task-ops": handleTaskOps,
8205
8360
  terminal: handleTerminal,
8206
8361
  "sessions": handleSessions,
8207
8362
  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.1",
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.1",
53
+ "@ordewell/web": "^0.6.1",
54
54
  "string-width": "^7.2.0",
55
55
  "ws": "^8.18.0"
56
56
  },