@ordewell/core 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 (46) hide show
  1. package/dist/{IFileSystem-D4dAl2r8.d.ts → IFileSystem-DuEiG_kA.d.ts} +1 -1
  2. package/dist/{IFileSystem-CFYf4yR2.d.mts → IFileSystem-d_SV7jAH.d.mts} +1 -1
  3. package/dist/{ModeResolver-a4xJFlEp.d.mts → ModeResolver-DIReYAOq.d.mts} +1 -1
  4. package/dist/{ModeResolver-DVYVRbJp.d.ts → ModeResolver-DXxV4n_V.d.ts} +1 -1
  5. package/dist/{Task-CZJr6rmz.d.mts → Task-ib_U0YP7.d.mts} +56 -5
  6. package/dist/{Task-CZJr6rmz.d.ts → Task-ib_U0YP7.d.ts} +56 -5
  7. package/dist/{chunk-2SOVKO7P.mjs → chunk-I6U3PVHL.mjs} +39 -16
  8. package/dist/chunk-I6U3PVHL.mjs.map +1 -0
  9. package/dist/{chunk-EDGUFCIR.mjs → chunk-JRZPSNRO.mjs} +7 -3
  10. package/dist/chunk-JRZPSNRO.mjs.map +1 -0
  11. package/dist/{chunk-OM4D274O.mjs → chunk-M4KXBEFJ.mjs} +5 -3
  12. package/dist/chunk-M4KXBEFJ.mjs.map +1 -0
  13. package/dist/{chunk-C44UWIAD.mjs → chunk-N66ZHFGN.mjs} +2 -2
  14. package/dist/chunk-N66ZHFGN.mjs.map +1 -0
  15. package/dist/index.d.mts +129 -33
  16. package/dist/index.d.ts +129 -33
  17. package/dist/index.js +445 -74
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +402 -60
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/order-labels.d.mts +1 -1
  22. package/dist/order-labels.d.ts +1 -1
  23. package/dist/{parsing-Bd6-b644.d.ts → parsing-C8Z22twN.d.ts} +2 -2
  24. package/dist/{parsing-DIaGZmiQ.d.mts → parsing-DjRJJX-y.d.mts} +2 -2
  25. package/dist/parsing.d.mts +3 -3
  26. package/dist/parsing.d.ts +3 -3
  27. package/dist/parsing.js +5 -2
  28. package/dist/parsing.js.map +1 -1
  29. package/dist/parsing.mjs +2 -2
  30. package/dist/{plan-utils-Ba-l8EZS.d.mts → plan-utils-D4ioaM8q.d.mts} +25 -4
  31. package/dist/{plan-utils-_T5S7Mnk.d.ts → plan-utils-DvHPzAfV.d.ts} +25 -4
  32. package/dist/plan-utils.d.mts +3 -3
  33. package/dist/plan-utils.d.ts +3 -3
  34. package/dist/plan-utils.js.map +1 -1
  35. package/dist/plan-utils.mjs +2 -2
  36. package/dist/testing.d.mts +14 -5
  37. package/dist/testing.d.ts +14 -5
  38. package/dist/testing.js +30 -3
  39. package/dist/testing.js.map +1 -1
  40. package/dist/testing.mjs +30 -3
  41. package/dist/testing.mjs.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/chunk-2SOVKO7P.mjs.map +0 -1
  44. package/dist/chunk-C44UWIAD.mjs.map +0 -1
  45. package/dist/chunk-EDGUFCIR.mjs.map +0 -1
  46. package/dist/chunk-OM4D274O.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  resolveTaskMode,
16
16
  runnerModesFrom,
17
17
  validatePlanModification
18
- } from "./chunk-OM4D274O.mjs";
18
+ } from "./chunk-M4KXBEFJ.mjs";
19
19
  import {
20
20
  CHECKPOINT_TRUNCATE_LENGTH,
21
21
  EMPTY_CONVERSATION,
@@ -76,7 +76,7 @@ import {
76
76
  unsendLatest,
77
77
  usageLine,
78
78
  validateTaskEdit
79
- } from "./chunk-2SOVKO7P.mjs";
79
+ } from "./chunk-I6U3PVHL.mjs";
80
80
  import {
81
81
  PLAN_ENVELOPE_KEY,
82
82
  PlanParseError,
@@ -105,7 +105,7 @@ import {
105
105
  updateTaskInPlan,
106
106
  validateModifiedPlan,
107
107
  warningsText
108
- } from "./chunk-EDGUFCIR.mjs";
108
+ } from "./chunk-JRZPSNRO.mjs";
109
109
  import {
110
110
  resolveOrderLabel,
111
111
  taskOrderLabel
@@ -121,7 +121,7 @@ import {
121
121
  migratePlanStateIsolation,
122
122
  repoRootOf,
123
123
  taskIsolationOf
124
- } from "./chunk-C44UWIAD.mjs";
124
+ } from "./chunk-N66ZHFGN.mjs";
125
125
 
126
126
  // src/models/TaskLog.ts
127
127
  var HEAD_LINES = 60;
@@ -5143,6 +5143,18 @@ function leftoverMarkerFiles(check) {
5143
5143
  const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
5144
5144
  return [...new Set(files)];
5145
5145
  }
5146
+ function rawDiffStates(raw) {
5147
+ const parts = raw.split("\0");
5148
+ const states = {};
5149
+ for (let i = 0; i + 1 < parts.length; i += 2) {
5150
+ const meta = parts[i].trim();
5151
+ const file = parts[i + 1];
5152
+ if (!meta.startsWith(":") || !file) continue;
5153
+ const [, mode, , blob] = meta.slice(1).split(" ");
5154
+ states[file] = `${mode}:${blob}`;
5155
+ }
5156
+ return states;
5157
+ }
5146
5158
  function settleStatus(record, status) {
5147
5159
  record.status = status;
5148
5160
  delete record.repairBase;
@@ -5242,6 +5254,8 @@ var GitWorktreeIsolation = class {
5242
5254
  adminChains = /* @__PURE__ */ new Map();
5243
5255
  waiting = [];
5244
5256
  draining = false;
5257
+ /** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
5258
+ turns = Promise.resolve();
5245
5259
  async isActive(workspaceRoot) {
5246
5260
  if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
5247
5261
  const version = await this.tryGit(void 0, ["--version"]);
@@ -5598,6 +5612,62 @@ var GitWorktreeIsolation = class {
5598
5612
  return !linked.has(rel) && !linked.has(rel.split("/")[0]);
5599
5613
  });
5600
5614
  }
5615
+ async findInHead(run) {
5616
+ const found = [];
5617
+ for (const record of Object.values(run.tasks)) {
5618
+ if (record.status !== "merged" || record.inHead) continue;
5619
+ if (!await this.headContains(run, record)) continue;
5620
+ record.inHead = true;
5621
+ found.push(record.taskId);
5622
+ }
5623
+ return found;
5624
+ }
5625
+ async headContains(run, record) {
5626
+ for (const repo of run.repos) {
5627
+ const entry = record.repos[repo.path];
5628
+ if (!entry?.changed) continue;
5629
+ const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
5630
+ if (!merged.ok) return false;
5631
+ }
5632
+ return true;
5633
+ }
5634
+ async snapshotTree(workspaceRoot, exclude) {
5635
+ const scan = await this.scanGroup(workspaceRoot);
5636
+ if ("refused" in scan) return null;
5637
+ const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
5638
+ if (repos.length === 0) return null;
5639
+ const snapshot = {};
5640
+ for (const repoPath of repos) {
5641
+ const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
5642
+ if (states) snapshot[repoPath] = states;
5643
+ }
5644
+ return snapshot;
5645
+ }
5646
+ async changedSince(workspaceRoot, snapshot) {
5647
+ const changed = [];
5648
+ for (const [repoPath, before] of Object.entries(snapshot)) {
5649
+ const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
5650
+ if (!now) continue;
5651
+ for (const [file, state] of Object.entries(now)) {
5652
+ if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
5653
+ }
5654
+ }
5655
+ return changed.sort();
5656
+ }
5657
+ /**
5658
+ * Each tracked file that differs from HEAD, with what it holds. `git stash
5659
+ * create` records the working tree as a commit without touching the tree,
5660
+ * the index or the stash, so a file changed again is told apart from one
5661
+ * left as it was. Null when git cannot tell — a merge in progress, say.
5662
+ */
5663
+ async trackedStates(root) {
5664
+ const stash = await this.tryGit(root, ["stash", "create"]);
5665
+ if (!stash.ok) return null;
5666
+ const commit = stash.stdout.trim();
5667
+ if (!commit) return {};
5668
+ const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
5669
+ return diff.ok ? rawDiffStates(diff.stdout) : null;
5670
+ }
5601
5671
  /**
5602
5672
  * One section per repo with changes. A repo below the workspace root gets a
5603
5673
  * header and paths prefixed with its own, so the whole reads as one patch
@@ -5615,7 +5685,10 @@ var GitWorktreeIsolation = class {
5615
5685
  }
5616
5686
  return diff;
5617
5687
  }
5618
- async mergeIntoCheckedOut(run) {
5688
+ mergeIntoCheckedOut(run) {
5689
+ return this.inTurn(() => this.mergeAll(run));
5690
+ }
5691
+ async mergeAll(run) {
5619
5692
  const partial = await this.settleLanding(run);
5620
5693
  if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
5621
5694
  const repos = await this.reposWithWork(run);
@@ -5749,7 +5822,7 @@ var GitWorktreeIsolation = class {
5749
5822
  if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
5750
5823
  });
5751
5824
  const [entry] = this.waiting.splice(pick, 1);
5752
- entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
5825
+ entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
5753
5826
  }
5754
5827
  } finally {
5755
5828
  this.draining = false;
@@ -5801,6 +5874,10 @@ var GitWorktreeIsolation = class {
5801
5874
  return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
5802
5875
  }
5803
5876
  }
5877
+ for (const repo of changed) {
5878
+ const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
5879
+ if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
5880
+ }
5804
5881
  delete run.landing;
5805
5882
  settleStatus(record, "merged");
5806
5883
  delete record.conflictRepo;
@@ -6075,6 +6152,11 @@ var GitWorktreeIsolation = class {
6075
6152
  async abortMerge(cwd) {
6076
6153
  if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
6077
6154
  }
6155
+ inTurn(fn) {
6156
+ const turn = this.turns.then(fn, fn);
6157
+ this.turns = turn.catch(() => void 0);
6158
+ return turn;
6159
+ }
6078
6160
  admin(repoKey, fn) {
6079
6161
  const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
6080
6162
  const next = prior.then(fn, fn);
@@ -6491,8 +6573,22 @@ function researchPhaseBlock(harnessMode) {
6491
6573
  "- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
6492
6574
  ].join("\n");
6493
6575
  }
6494
- var ISOLATED_PARALLELISM_RULE = "- Each AI task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
6576
+ var ISOLATED_PARALLELISM_RULE = "- Each change task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
6495
6577
  var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
6578
+ function opsTaskSection(conversation) {
6579
+ return [
6580
+ "",
6581
+ "CHANGE AND OPS TASKS:",
6582
+ "- Every AI task is either a change task or an ops task. Decide by one test: is the task's result a change to files in the repository?",
6583
+ '- A change task (the default) edits repository files. Omit "ops".',
6584
+ `- An ops task changes no repository files: it acts on systems outside the repository (a cloud CLI, a deployment, a pipeline, a provisioned resource) or on the git refs and history of the user's branch (push, tag, reword commits). Set "ops": true. Only top-level "ai" tasks can be ops; a subtask runs with its parent.`,
6585
+ `- Ops tasks run in the user's own checkout, after the user has merged the change tasks they depend on. Give an ops task a dependency on every change task whose result it acts on \u2014 "deploy the fix" depends on the fix.`,
6586
+ '- Split a request that mixes the two into a change task and an ops task that depends on it: "bump the version and redeploy" is a change task that bumps and commits the version, then an ops task that pushes and watches the pipeline.',
6587
+ "- Ops tasks run in parallel as their dependencies allow. Two ops tasks whose git operations would collide (a push and a reword of the same branch) must not run in parallel \u2014 make one depend on the other.",
6588
+ "- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
6589
+ ...conversation ? [`- A question is not a task. "What is the frontend's URL?" is answered in this conversation, from your research; never make a task of it.`] : []
6590
+ ];
6591
+ }
6496
6592
  var SHARED_APPEND_FILE_RULE = "- Tasks that run in parallel must not each append to the same shared file (changelogs, registries, index lists) \u2014 give that edit to the final task, or to one task the others depend on.";
6497
6593
  function repoGroupSection(isolated) {
6498
6594
  if (!isolated) return [];
@@ -6572,6 +6668,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
6572
6668
  ' "taskMode": "' + modeExamples + '",',
6573
6669
  ' "autonomy": "AFK|HITL",',
6574
6670
  ' "sliceType": "AFK|HITL",',
6671
+ ' "ops": true,',
6575
6672
  ' "userStoriesCovered": ["user story text"],',
6576
6673
  ' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
6577
6674
  " }",
@@ -6597,6 +6694,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
6597
6694
  SHARED_APPEND_FILE_RULE,
6598
6695
  ...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
6599
6696
  ...repoGroupSection(variant.isolatedExecution),
6697
+ ...opsTaskSection(true),
6600
6698
  "",
6601
6699
  "RULES:",
6602
6700
  "- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
@@ -6672,6 +6770,7 @@ function corePlannerPrompt(isolatedExecution) {
6672
6770
  ' "taskMode": "{{MODE_EXAMPLES}}",',
6673
6771
  ' "autonomy": "AFK|HITL",',
6674
6772
  ' "sliceType": "AFK|HITL",',
6773
+ ' "ops": true,',
6675
6774
  ' "userStoriesCovered": ["user story text"],',
6676
6775
  ' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
6677
6776
  " }",
@@ -6699,6 +6798,7 @@ function corePlannerPrompt(isolatedExecution) {
6699
6798
  SHARED_APPEND_FILE_RULE,
6700
6799
  isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
6701
6800
  ...repoGroupSection(isolatedExecution),
6801
+ ...opsTaskSection(false),
6702
6802
  "",
6703
6803
  "RULES:",
6704
6804
  '- Mark as type "ai" any task the coding assistant can do autonomously.',
@@ -8702,6 +8802,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
8702
8802
  lines.join("\n") + footer + runnerNote
8703
8803
  ].join("\n");
8704
8804
  }
8805
+ function renderPreviousAttempt(output) {
8806
+ const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
8807
+ return [
8808
+ "## Previous attempt",
8809
+ "",
8810
+ "This task ran before and did not finish. What it did outside the repository was not undone: before acting, check what already exists \u2014 resources, deployments, pushed refs \u2014 and do not repeat a step that already took effect.",
8811
+ "",
8812
+ "The end of its output:",
8813
+ tail
8814
+ ].join("\n");
8815
+ }
8705
8816
  function renderCompletionMarker(task) {
8706
8817
  return `
8707
8818
 
@@ -8753,6 +8864,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
8753
8864
  const flat = flattenTasks(allTasks);
8754
8865
  const outputs = collectDirectDependencyOutputs(task, flat);
8755
8866
  if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
8867
+ if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
8756
8868
  if (opts?.tddEnabled) {
8757
8869
  blocks.push(renderTddInstruction());
8758
8870
  }
@@ -8765,9 +8877,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
8765
8877
 
8766
8878
  ${basePrompt}${marker2}`;
8767
8879
  }
8768
- function composeContinuationPrompt(task, message) {
8880
+ function composeContinuationPrompt(task, message, opts = {}) {
8769
8881
  const reminder = [
8770
- "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
8882
+ opts.ops ? "(Ordewell) You are continuing this task in the same session, in the same checkout. What your earlier attempt did outside the repository was not undone: check what already exists before acting again." : "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
8771
8883
  ];
8772
8884
  if (isHitlTask(task)) {
8773
8885
  reminder.push(`If you reach a decision that needs human judgment, print one line holding only the checkpoint marker and wait for ORDEWELL_CONTINUE or ORDEWELL_REJECT. ${CHECKPOINT_MARKER_HOWTO}.`);
@@ -9507,6 +9619,8 @@ var PlanStore = class {
9507
9619
  _planTasks = [];
9508
9620
  _allTasks = [];
9509
9621
  _taskMap = /* @__PURE__ */ new Map();
9622
+ /** Task id → the top-level task it hangs under, itself for a top-level task. */
9623
+ _rootMap = /* @__PURE__ */ new Map();
9510
9624
  _planRunners = ["claude-code"];
9511
9625
  _onMutate = null;
9512
9626
  _executionLog = [];
@@ -9544,6 +9658,14 @@ var PlanStore = class {
9544
9658
  get(taskId) {
9545
9659
  return this._taskMap.get(taskId);
9546
9660
  }
9661
+ /**
9662
+ * Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
9663
+ * can be one; a subtask runs with the top-level task it hangs under.
9664
+ */
9665
+ isOps(taskId) {
9666
+ const root = this._rootMap.get(taskId);
9667
+ return root?.type === "ai" && root.ops === true;
9668
+ }
9547
9669
  /** A copy of the task tree, detached from the store: later status changes do not reach it. */
9548
9670
  snapshot() {
9549
9671
  return cloneTasks(this._planTasks);
@@ -9638,6 +9760,7 @@ ${taskB.description}`,
9638
9760
  assignedRunner: taskA.assignedRunner,
9639
9761
  assignedModel: taskA.assignedModel,
9640
9762
  taskMode: taskA.taskMode,
9763
+ ops: taskA.ops === true && taskB.ops === true,
9641
9764
  order: Math.min(taskA.order, taskB.order)
9642
9765
  });
9643
9766
  const mergedId = merged.id;
@@ -9667,7 +9790,8 @@ ${taskB.description}`,
9667
9790
  taskMode: spec.taskMode ?? original.taskMode,
9668
9791
  autonomy: spec.autonomy ?? original.autonomy,
9669
9792
  sliceType: spec.sliceType ?? original.sliceType,
9670
- userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
9793
+ userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
9794
+ ops: spec.ops ?? original.ops
9671
9795
  }));
9672
9796
  });
9673
9797
  const tailId = finalized[finalized.length - 1].id;
@@ -9721,8 +9845,14 @@ ${taskB.description}`,
9721
9845
  this.setStatus(id, "pending");
9722
9846
  task.verdict = void 0;
9723
9847
  task.outputSummary = void 0;
9848
+ delete task.forcedPastGate;
9724
9849
  }
9725
9850
  }
9851
+ /** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
9852
+ setForcedPastGate(id, dependencies) {
9853
+ const task = this._taskMap.get(id);
9854
+ if (task) task.forcedPastGate = dependencies;
9855
+ }
9726
9856
  /** A reason outlives nothing: any status but `awaiting_user` drops it. */
9727
9857
  setStatus(id, status) {
9728
9858
  const task = this._taskMap.get(id);
@@ -9805,6 +9935,10 @@ ${taskB.description}`,
9805
9935
  for (const task of this._allTasks) {
9806
9936
  this._taskMap.set(task.id, task);
9807
9937
  }
9938
+ this._rootMap.clear();
9939
+ for (const root of this._planTasks) {
9940
+ for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
9941
+ }
9808
9942
  }
9809
9943
  validateAssignedRunners() {
9810
9944
  for (const task of this._allTasks) {
@@ -9858,9 +9992,9 @@ var IsolationRunController = class {
9858
9992
  /** Copied paths already reported for the current run: every task gets the same copies. */
9859
9993
  reportedCopies = /* @__PURE__ */ new Set();
9860
9994
  /**
9861
- * How the open run executes; null while no run is open. A run is one
9862
- * Execute-Plan or one manual task run, from its start until it settles or
9863
- * is stopped.
9995
+ * How the open run executes; null while no run is open, `undecided` until
9996
+ * its first change task asks. A run is one Execute-Plan or one manual task
9997
+ * run, from its start until it settles or is stopped.
9864
9998
  */
9865
9999
  mode = null;
9866
10000
  /** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
@@ -9884,6 +10018,10 @@ var IsolationRunController = class {
9884
10018
  get isOpen() {
9885
10019
  return this.mode !== null;
9886
10020
  }
10021
+ /** The open run has decided whether it isolates. */
10022
+ get decided() {
10023
+ return this.mode === "isolated" || this.mode === "shared";
10024
+ }
9887
10025
  /** The open run executes in worktrees. */
9888
10026
  get isolating() {
9889
10027
  return this.mode === "isolated" && this.run !== null;
@@ -9940,13 +10078,22 @@ var IsolationRunController = class {
9940
10078
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
9941
10079
  }
9942
10080
  }
10081
+ /** Open a run if none is, leaving whether it isolates to its first change task. */
10082
+ open() {
10083
+ if (this.mode) return;
10084
+ this.mode = "undecided";
10085
+ this.listener.opened();
10086
+ }
9943
10087
  /**
9944
- * Open a run if none is: decide once, at its start, whether it executes in
9945
- * worktrees. `resume` is what a dirty tree parks until the user chooses how
9946
- * to go on. Resolves false when the run did not start.
10088
+ * Decide once per run, as its first change task starts, whether it executes
10089
+ * in worktrees (ADR-0020): an ops task needs none, so a run of only ops
10090
+ * tasks never asks. `resume` is what a dirty tree parks until the user
10091
+ * chooses how to go on. Resolves false while the run waits on that choice.
9947
10092
  */
9948
- open(resume) {
9949
- if (this.mode) return Promise.resolve(true);
10093
+ decide(resume) {
10094
+ this.open();
10095
+ if (this.decided) return Promise.resolve(true);
10096
+ if (this.blockedStart) return Promise.resolve(false);
9950
10097
  this.opening ??= this.activate(resume).finally(() => {
9951
10098
  this.opening = null;
9952
10099
  });
@@ -9961,8 +10108,8 @@ var IsolationRunController = class {
9961
10108
  * the safe rule then.
9962
10109
  */
9963
10110
  async plannerLayout() {
9964
- if (this.mode) return this.isolating && this.run ? layoutOf(this.run) : false;
9965
- const decision = await this.decide(this.workspaceRoot());
10111
+ if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
10112
+ const decision = await this.assess(this.workspaceRoot());
9966
10113
  return decision.mode === "isolated" ? decision.layout : false;
9967
10114
  }
9968
10115
  /**
@@ -9997,7 +10144,7 @@ var IsolationRunController = class {
9997
10144
  const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
9998
10145
  return { cwd: cwd2, worktree: true };
9999
10146
  }
10000
- if (!this.isolating || !this.run) {
10147
+ if (opts.ops || !this.isolating || !this.run) {
10001
10148
  await this.release(task.id, { keep: false });
10002
10149
  return { cwd: this.workspaceRoot(), worktree: false };
10003
10150
  }
@@ -10042,18 +10189,71 @@ var IsolationRunController = class {
10042
10189
  }
10043
10190
  this.listener.changed();
10044
10191
  }
10045
- /** Close the open run. An isolated one hands its integration branch over for review. */
10192
+ /**
10193
+ * Close the open run. An isolated one hands its integration branch over for
10194
+ * review — unless the user merged everything it landed at merge gates
10195
+ * already, which leaves nothing to hand over, so it is cleared up as a Merge
10196
+ * all would have.
10197
+ */
10046
10198
  async close() {
10047
10199
  const mode = this.mode;
10048
10200
  this.mode = null;
10049
- if (mode !== "isolated" || !this.run) return;
10201
+ const run = this.run;
10202
+ if (mode !== "isolated" || !run) return;
10203
+ await this.refreshInHead();
10204
+ const records = Object.values(run.tasks);
10205
+ if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
10206
+ this.tell("info", "Everything this run landed is merged into your branch already.");
10207
+ await this.clearMerged(run);
10208
+ return;
10209
+ }
10050
10210
  try {
10051
- this.listener.handoff(await this.isolation.handoff(this.run));
10211
+ this.listener.handoff(await this.isolation.handoff(run));
10052
10212
  } catch (err) {
10053
10213
  this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
10054
10214
  }
10055
10215
  this.listener.changed();
10056
10216
  }
10217
+ /**
10218
+ * Whether a task's landed work still waits to be merged into the user's
10219
+ * branch — what holds an ops or user task at its merge gate (ADR-0020). A
10220
+ * task with no record never landed anything to wait for.
10221
+ */
10222
+ awaitsMerge(taskId) {
10223
+ const record = this.run?.tasks[taskId];
10224
+ return !!record && record.status === "merged" && !record.inHead;
10225
+ }
10226
+ /**
10227
+ * Look again for landed work the user has merged — by Merge all, or by hand
10228
+ * with git. Resolves true when some was found.
10229
+ */
10230
+ async refreshInHead() {
10231
+ const run = this.run;
10232
+ if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
10233
+ const found = await this.isolation.findInHead(run).catch(() => []);
10234
+ if (found.length > 0) this.listener.changed();
10235
+ return found.length > 0;
10236
+ }
10237
+ /**
10238
+ * The tracked state of the workspace as an ops task starts, for
10239
+ * {@link filesChangedSince} (ADR-0020). Null where the check does not run:
10240
+ * a run that shares the workspace root, where every task's edits land in
10241
+ * it, or a workspace git cannot isolate at all.
10242
+ */
10243
+ async snapshotWorkspace() {
10244
+ if (this.mode === "shared") return null;
10245
+ const root = this.workspaceRoot();
10246
+ if (!this.isolating) {
10247
+ const availability = await this.isolation.isActive(root).catch(() => null);
10248
+ if (!availability || !availability.active && availability.reason !== "dirty") return null;
10249
+ }
10250
+ return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
10251
+ }
10252
+ /** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
10253
+ async filesChangedSince(snapshot) {
10254
+ if (!snapshot || this.mode === "shared") return [];
10255
+ return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
10256
+ }
10057
10257
  /**
10058
10258
  * A stop or a plan load: the open run ends without a handoff and a parked
10059
10259
  * start is dropped. `keepOpen` spares a run the scheduler is still driving.
@@ -10084,19 +10284,23 @@ var IsolationRunController = class {
10084
10284
  }
10085
10285
  /**
10086
10286
  * "Merge all": the run's integration branches into whatever the user has
10087
- * checked out, in every repo or none. Once everything merged, the run has
10088
- * nothing left to hand over, so it is cleared up and forgotten; a branch
10089
- * the user's HEAD somehow does not contain stays for the next run's sweep.
10287
+ * checked out, in every repo or none. During a run it merges what has
10288
+ * landed so far and the run goes on (ADR-0020): its branches stay for the
10289
+ * tasks still to land. Once a settled run merged everything, it has nothing
10290
+ * left to hand over, so it is cleared up and forgotten; a branch the user's
10291
+ * HEAD somehow does not contain stays for the next run's sweep.
10090
10292
  */
10091
10293
  async merge() {
10092
10294
  const run = this.requireRun();
10295
+ const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
10093
10296
  const result = await this.isolation.mergeIntoCheckedOut(run);
10094
10297
  const branch = integrationBranchNameOf(run);
10095
10298
  const group = run.repos.some((r) => r.path !== SELF_REPO);
10096
- const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
10097
10299
  const { level, message } = describeMergeResult(result, branch, group, repaired);
10098
10300
  this.tell(level, message);
10099
- if (result.outcome === "merged") await this.clearMerged(run);
10301
+ if (result.outcome !== "merged") return result;
10302
+ await this.refreshInHead();
10303
+ if (!this.isOpen) await this.clearMerged(run);
10100
10304
  return result;
10101
10305
  }
10102
10306
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
@@ -10128,7 +10332,7 @@ var IsolationRunController = class {
10128
10332
  this.resolvers = {};
10129
10333
  this.listener.changed();
10130
10334
  }
10131
- async decide(root) {
10335
+ async assess(root) {
10132
10336
  const availability = await this.isolation.isActive(root);
10133
10337
  const continued = this.continuableRun(root);
10134
10338
  const continuing = continued !== null;
@@ -10143,7 +10347,7 @@ var IsolationRunController = class {
10143
10347
  }
10144
10348
  async activate(resume) {
10145
10349
  const root = this.workspaceRoot();
10146
- const decision = await this.decide(root);
10350
+ const decision = await this.assess(root);
10147
10351
  if (decision.mode === "blocked") {
10148
10352
  this.blockedStart = resume;
10149
10353
  this.blockedRepos = decision.repos;
@@ -10170,8 +10374,8 @@ var IsolationRunController = class {
10170
10374
  return true;
10171
10375
  }
10172
10376
  begin(mode) {
10377
+ this.open();
10173
10378
  this.mode = mode;
10174
- this.listener.opened();
10175
10379
  }
10176
10380
  /** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
10177
10381
  async sweep() {
@@ -10518,8 +10722,9 @@ var MessageQueue = class {
10518
10722
  // src/services/readiness.ts
10519
10723
  function selectReadyTasks(input) {
10520
10724
  const { store, onHold, runs, active, maxParallel } = input;
10521
- if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
10725
+ if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
10522
10726
  const availableSlots = maxParallel - active;
10727
+ const gated = [];
10523
10728
  const candidates = store.allTasks.filter((t) => {
10524
10729
  if (t.status !== "pending" && t.status !== "approved") return false;
10525
10730
  if (t.type === "user") return false;
@@ -10527,13 +10732,21 @@ function selectReadyTasks(input) {
10527
10732
  if (onHold.has(t.id)) return false;
10528
10733
  if (isBlocked(t, store)) return false;
10529
10734
  if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
10735
+ if (store.isOps(t.id)) {
10736
+ if (input.merging) return false;
10737
+ if (mergeGate(t, store, runs).length > 0) {
10738
+ gated.push(t);
10739
+ return false;
10740
+ }
10741
+ }
10530
10742
  return true;
10531
10743
  });
10532
- const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, store, onHold, runs) }));
10744
+ const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
10533
10745
  return {
10534
10746
  ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
10535
10747
  candidateCount: candidates.length,
10536
- excluded
10748
+ excluded,
10749
+ gated
10537
10750
  };
10538
10751
  }
10539
10752
  function isBlocked(task, store) {
@@ -10546,12 +10759,19 @@ function dependencyMet(depId, store, runs) {
10546
10759
  const record = runs.openRecord(depId);
10547
10760
  return !record || record.status === "merged";
10548
10761
  }
10549
- function exclusionReasons(task, store, onHold, runs) {
10762
+ function mergeGate(task, store, runs) {
10763
+ if (task.type !== "user" && !store.isOps(task.id)) return [];
10764
+ return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
10765
+ }
10766
+ function exclusionReasons(task, input) {
10767
+ const { store, onHold, runs } = input;
10550
10768
  const reasons = [];
10551
10769
  if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
10552
10770
  if (onHold.has(task.id)) reasons.push("on-hold");
10553
10771
  if (isBlocked(task, store)) reasons.push("blocked");
10554
10772
  if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
10773
+ if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
10774
+ if (input.merging && store.isOps(task.id)) reasons.push("merging");
10555
10775
  return reasons;
10556
10776
  }
10557
10777
 
@@ -13426,6 +13646,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
13426
13646
  * pending and nothing ran until the user also re-ran the whole plan.
13427
13647
  */
13428
13648
  haltedByFailure = false;
13649
+ /** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
13650
+ merging = false;
13651
+ /** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
13652
+ gateNotice = "";
13429
13653
  /** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
13430
13654
  workspaceEnv;
13431
13655
  /** What the workspace env has already warned about, so a run says it once, not per task. */
@@ -13734,9 +13958,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
13734
13958
  async reviewRunDiff() {
13735
13959
  return this.runs.reviewDiff();
13736
13960
  }
13737
- /** "Merge all"; a run that merged everything is cleared up and forgotten. */
13961
+ /**
13962
+ * "Merge all"; a settled run that merged everything is cleared up and
13963
+ * forgotten. During a run it merges what has landed and the run goes on:
13964
+ * the tasks waiting at a merge gate for that work then start (ADR-0020).
13965
+ * Never while an ops task runs, and no ops task starts until it ends — the
13966
+ * one overlap the plan cannot order, because the merge is the user's.
13967
+ */
13738
13968
  async mergeRun() {
13739
- return this.runs.merge();
13969
+ const ops = [...this.attempts.values()].find((a) => a.ops);
13970
+ if (ops) {
13971
+ const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
13972
+ throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
13973
+ }
13974
+ this.merging = true;
13975
+ let result;
13976
+ try {
13977
+ result = await this.runs.merge();
13978
+ } finally {
13979
+ this.merging = false;
13980
+ }
13981
+ await this.tick();
13982
+ return result;
13983
+ }
13984
+ /**
13985
+ * The run as a surface offers Merge all mid-run (ADR-0020): what has landed
13986
+ * and is not merged, and whether the run is paused at its gates with nothing
13987
+ * else running. Null while no task waits at a gate.
13988
+ */
13989
+ mergeGateView() {
13990
+ const handoff = this.runs.view()?.handoff;
13991
+ if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
13992
+ return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
13993
+ }
13994
+ /**
13995
+ * The dependencies an ops or user task waits on at its merge gate: those
13996
+ * whose landed work is not in the user's branch yet (ADR-0020). Empty once
13997
+ * the task has started, and for every other task.
13998
+ */
13999
+ getMergeGate(taskId) {
14000
+ const task = this.store.get(taskId);
14001
+ if (!task || task.status !== "pending" && task.status !== "approved") return [];
14002
+ return mergeGate(task, this.store, this.runs);
13740
14003
  }
13741
14004
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
13742
14005
  async cleanupRun() {
@@ -13836,7 +14099,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13836
14099
  this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
13837
14100
  return;
13838
14101
  }
13839
- if (!await this.runs.open(() => this.start()) || this.running) return;
14102
+ this.runs.open();
13840
14103
  console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
13841
14104
  this.running = true;
13842
14105
  this.haltedByFailure = false;
@@ -13878,13 +14141,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
13878
14141
  const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
13879
14142
  if (this.attempts.get(taskId) !== attempt) return;
13880
14143
  const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
14144
+ const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
13881
14145
  const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
13882
14146
  if (this.attempts.get(taskId) !== attempt) return;
13883
14147
  this.endAttempt(taskId, "verdict");
13884
14148
  this.store.setTaskVerdict(taskId, verdict);
13885
14149
  console.error(`[TaskOrchestrator] Output summary:
13886
14150
  ${summary || "(empty \u2014 no output captured)"}`);
13887
- if (landing) {
14151
+ if (changedFiles.length > 0) {
14152
+ this.store.markAwaitingUser(taskId, "files-changed");
14153
+ this.tell("warn", `Ops task "${task.title}" finished, but changed tracked files in your checkout: ${capConflictFiles(changedFiles)}. An ops task must not change repository files, so nothing was committed \u2014 look at the changes, then mark it complete or retry it.`);
14154
+ } else if (landing) {
13888
14155
  await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
13889
14156
  } else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
13890
14157
  this.store.markAwaitingUser(taskId);
@@ -13986,21 +14253,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
13986
14253
  });
13987
14254
  }
13988
14255
  getReadyTasks() {
13989
- if (!this.running) return [];
14256
+ return this.readiness().ready;
14257
+ }
14258
+ readiness() {
14259
+ if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
13990
14260
  const maxParallel = this.config.maxParallelSessions;
13991
14261
  const active = this.attempts.size;
13992
- const { ready, candidateCount, excluded } = selectReadyTasks({
14262
+ const readiness = selectReadyTasks({
13993
14263
  store: this.store,
13994
14264
  onHold: this.onHold,
13995
14265
  runs: this.runs,
13996
14266
  active,
13997
- maxParallel
14267
+ maxParallel,
14268
+ merging: this.merging
13998
14269
  });
13999
- for (const { task, reasons } of excluded) {
14270
+ for (const { task, reasons } of readiness.excluded) {
14000
14271
  console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
14001
14272
  }
14002
- console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
14003
- return ready;
14273
+ console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
14274
+ return readiness;
14275
+ }
14276
+ /**
14277
+ * Open the run a task starts in, and for a change task decide whether it
14278
+ * isolates (ADR-0020): an ops task needs no worktree, so it never waits on
14279
+ * that choice. False while a dirty tree waits on the user; `resume` is what
14280
+ * their choice replays.
14281
+ */
14282
+ async openFor(taskId, resume) {
14283
+ this.runs.open();
14284
+ return this.store.isOps(taskId) || this.runs.decide(resume);
14004
14285
  }
14005
14286
  /**
14006
14287
  * Cancel a running (or scheduled) task: kill its session and return it to
@@ -14128,7 +14409,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
14128
14409
  const runner = this.store.resolveTaskRunner(task);
14129
14410
  const route = routeTransport("structured", runner, this.registry);
14130
14411
  if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
14131
- if (!await this.runs.open(() => this.continueTask(taskId, message))) return;
14412
+ if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
14132
14413
  if (this.attempts.has(taskId) || !continuability(task).ok) return;
14133
14414
  this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
14134
14415
  this.endAttempt(taskId, "retry");
@@ -14150,10 +14431,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
14150
14431
  const task = this.store.get(taskId);
14151
14432
  if (!task || task.type !== "ai") return;
14152
14433
  if (this.attempts.has(taskId)) return;
14153
- if (!await this.runs.open(() => this.forceStartTask(taskId))) return;
14434
+ this.refuseOpsDuringMerge(task);
14435
+ if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
14154
14436
  this.onHold.delete(taskId);
14437
+ this.notePastGate(task);
14155
14438
  await this.startTask(task);
14156
14439
  }
14440
+ refuseOpsDuringMerge(task) {
14441
+ if (this.merging && this.store.isOps(task.id)) {
14442
+ throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
14443
+ }
14444
+ }
14445
+ /**
14446
+ * A force start passes a merge gate (ADR-0020), and the surface confirmed it
14447
+ * first; the choice is kept on the task and said, because the work it acts
14448
+ * on is not in the user's branch.
14449
+ */
14450
+ notePastGate(task) {
14451
+ const unmerged = mergeGate(task, this.store, this.runs);
14452
+ if (unmerged.length === 0) return;
14453
+ const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
14454
+ this.store.setForcedPastGate(task.id, titles);
14455
+ this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
14456
+ }
14157
14457
  /**
14158
14458
  * Run exactly one task outside full-plan scheduling. The active/starting
14159
14459
  * session still contributes to {@link hasLiveWork} so every surface exposes
@@ -14164,8 +14464,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
14164
14464
  if (this.hasLiveWork) return;
14165
14465
  const task = this.store.get(taskId);
14166
14466
  if (!task || task.type !== "ai") return;
14167
- if (!await this.runs.open(() => this.runTask(taskId))) return;
14467
+ this.refuseOpsDuringMerge(task);
14468
+ if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
14168
14469
  this.onHold.delete(taskId);
14470
+ this.notePastGate(task);
14169
14471
  await this.startTask(task);
14170
14472
  }
14171
14473
  getCompletedCount() {
@@ -14205,7 +14507,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
14205
14507
  }
14206
14508
  return;
14207
14509
  }
14208
- const ready = this.getReadyTasks();
14510
+ let readiness = this.readiness();
14511
+ const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
14512
+ if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
14513
+ if (!this.running) return;
14514
+ const { ready } = readiness;
14209
14515
  console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
14210
14516
  if (ready.length === 0 && this.attempts.size === 0) {
14211
14517
  const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
@@ -14225,14 +14531,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
14225
14531
  this.emit("onExecutionComplete");
14226
14532
  } else {
14227
14533
  this.notifications.info("Remaining tasks require user action or are on hold.");
14534
+ this.sayGate();
14228
14535
  this.emit("onTaskChanged");
14229
14536
  this.emit("onTick");
14230
14537
  }
14231
14538
  return;
14232
14539
  }
14233
- for (const task of ready) await this.startTask(task);
14540
+ for (const task of ready) {
14541
+ if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
14542
+ await this.startTask(task);
14543
+ }
14234
14544
  this.emit("onTick");
14235
14545
  }
14546
+ /**
14547
+ * A run paused at merge gates says so, as plainly as a task that waits on
14548
+ * the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
14549
+ */
14550
+ sayGate() {
14551
+ const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
14552
+ const notice = waiting.length === 0 ? "" : `Waiting for Merge all: ${waiting.map((t) => `"${t.title}"`).join(", ")} ${waiting.length === 1 ? "needs" : "need"} work that is not merged into your branch yet.`;
14553
+ if (notice === this.gateNotice) return;
14554
+ this.gateNotice = notice;
14555
+ if (notice) this.tell("info", notice);
14556
+ }
14236
14557
  async startTask(task, continuation = null) {
14237
14558
  if (this.attempts.has(task.id)) return;
14238
14559
  const attempt = {
@@ -14246,6 +14567,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
14246
14567
  integration: null,
14247
14568
  repair: this.landing.nextRepair(task.id),
14248
14569
  continuation,
14570
+ ops: this.store.isOps(task.id),
14571
+ snapshot: null,
14249
14572
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
14250
14573
  decided: false
14251
14574
  };
@@ -14271,18 +14594,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
14271
14594
  */
14272
14595
  async spawnAttempt(task, attempt) {
14273
14596
  try {
14274
- const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
14597
+ const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
14275
14598
  attempt.cwd = cwd;
14276
14599
  attempt.worktree = worktree;
14600
+ if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
14277
14601
  if (this.attempts.get(task.id) !== attempt) {
14278
14602
  if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
14279
14603
  this.abandonSpawn(task, attempt);
14280
14604
  return false;
14281
14605
  }
14282
- const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
14606
+ const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message, { ops: attempt.ops }) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
14283
14607
  planMapEnabled: this.config.planMapEnabled,
14284
14608
  // A merge to resolve is not new behaviour to drive test-first.
14285
- tddEnabled: !attempt.repair && this.tddEnabled()
14609
+ tddEnabled: !attempt.repair && this.tddEnabled(),
14610
+ // An ops task's effects outlive a failed attempt and are never rolled
14611
+ // back, so the next one is told what the last one did (ADR-0020).
14612
+ previousAttempt: attempt.ops ? this.output.liveTail(task.id, { maxLines: OPS_RETRY_TAIL_LINES })?.text : void 0
14286
14613
  });
14287
14614
  this.lingering.close(task.id);
14288
14615
  const env = await this.envForTask(cwd);
@@ -14459,6 +14786,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
14459
14786
  return ended;
14460
14787
  }
14461
14788
  };
14789
+ var OPS_RETRY_TAIL_LINES = 60;
14462
14790
  function unresumedMessage(task, why) {
14463
14791
  return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
14464
14792
  }
@@ -16916,7 +17244,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
16916
17244
  return tasks.map((t) => {
16917
17245
  const isMan = t.type === "user";
16918
17246
  const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
16919
- return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
17247
+ return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : t.ops ? "AI ops" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
16920
17248
  });
16921
17249
  }
16922
17250
  };
@@ -17413,6 +17741,7 @@ var SessionEventRelay = class {
17413
17741
  this.statusOwed = this.statusHeld;
17414
17742
  if (this.statusHeld) return;
17415
17743
  plan.tasks = this.store.snapshot();
17744
+ const gate = this.orchestrator.mergeGateView();
17416
17745
  this.broadcast({
17417
17746
  type: "status_update",
17418
17747
  tasks: this.store.allTasks.map((t) => serializeTaskStatus(
@@ -17420,8 +17749,10 @@ var SessionEventRelay = class {
17420
17749
  this.orchestrator.getIdleSince(t.id),
17421
17750
  this.orchestrator.getTaskIsolation(t.id),
17422
17751
  this.orchestrator.getQueuedTaskMessages(t.id),
17423
- this.awaitingApproval(t.id)
17424
- ))
17752
+ this.awaitingApproval(t.id),
17753
+ this.orchestrator.getMergeGate(t.id)
17754
+ )),
17755
+ ...gate ? { gate } : {}
17425
17756
  });
17426
17757
  }
17427
17758
  /**
@@ -18754,6 +19085,14 @@ var Session = class {
18754
19085
  isolationView() {
18755
19086
  return this.orchestrator.isolationView();
18756
19087
  }
19088
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
19089
+ mergeGate(taskId) {
19090
+ return this.orchestrator.getMergeGate(taskId);
19091
+ }
19092
+ /** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
19093
+ mergeGateView() {
19094
+ return this.orchestrator.mergeGateView();
19095
+ }
18757
19096
  /** The run's integration branch against its base ref, as a unified diff. */
18758
19097
  async reviewRunDiff() {
18759
19098
  if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
@@ -18763,10 +19102,12 @@ var Session = class {
18763
19102
  * "Merge all": each repo's integration branch into whatever the user has
18764
19103
  * checked out there — every repo, or none if any cannot take it. The one
18765
19104
  * irreversible step of isolated execution, so this explicit call is the
18766
- * only way it ever happens.
19105
+ * only way it ever happens. During a run it merges what has landed so far,
19106
+ * which is what opens a merge gate (ADR-0020); it waits for no ops task,
19107
+ * and refuses while one runs.
18767
19108
  */
18768
19109
  async mergeRun() {
18769
- this.requireSettledRun();
19110
+ if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
18770
19111
  const result = await this.orchestrator.mergeRun();
18771
19112
  this.broadcast({ type: "isolation_merge", result });
18772
19113
  return result;
@@ -18856,7 +19197,8 @@ var Session = class {
18856
19197
  * falls through to the no-op `store.update` below instead of throwing.
18857
19198
  */
18858
19199
  async updateTask(taskId, changes) {
18859
- if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
19200
+ if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
19201
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
18860
19202
  const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
18861
19203
  if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
18862
19204
  if (check.clear?.length) {