@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.js CHANGED
@@ -400,7 +400,7 @@ function resolveOrderLabel(tasks, label) {
400
400
 
401
401
  // src/models/Task.ts
402
402
  function isAwaitingReason(value) {
403
- return value === "input" || value === "checkpoint" || value === "conflict";
403
+ return value === "input" || value === "checkpoint" || value === "conflict" || value === "files-changed";
404
404
  }
405
405
  function emptyWarnings() {
406
406
  return { deletedCompleted: [], changedCompleted: [], deletedInProgress: [], modifiedInProgress: [], brokenDependencies: [] };
@@ -548,7 +548,8 @@ function createTask(overrides = {}) {
548
548
  completionMarker: overrides.completionMarker ?? (0, import_uuid.v4)(),
549
549
  autonomy: overrides.autonomy,
550
550
  sliceType: overrides.sliceType,
551
- userStoriesCovered: overrides.userStoriesCovered
551
+ userStoriesCovered: overrides.userStoriesCovered,
552
+ ...overrides.ops === true && (overrides.type ?? "ai") === "ai" ? { ops: true } : {}
552
553
  };
553
554
  }
554
555
  function createEmptyPlan() {
@@ -648,6 +649,9 @@ function keepExecutionState(current, rewrite) {
648
649
  verdict: prior?.verdict,
649
650
  outputSummary: prior?.outputSummary,
650
651
  transport: prior?.transport,
652
+ // Where a task that has run ran is fixed, like its transport (ADR-0020).
653
+ ...prior?.status === "failed" ? { ops: prior.ops } : {},
654
+ forcedPastGate: prior?.forcedPastGate,
651
655
  subtasks: overlay(t.subtasks ?? [], prior?.subtasks ?? [])
652
656
  };
653
657
  });
@@ -6168,7 +6172,7 @@ function taskIsolationOf(record, repairLimit) {
6168
6172
  };
6169
6173
  }
6170
6174
  function handoffOf(run) {
6171
- const merged = Object.values(run.tasks).filter((r) => r.status === "merged").sort((a, b) => a.order - b.order);
6175
+ const merged = Object.values(run.tasks).filter((r) => r.status === "merged" && !r.inHead).sort((a, b) => a.order - b.order);
6172
6176
  const entry = (r) => ({
6173
6177
  taskId: r.taskId,
6174
6178
  order: r.order,
@@ -6550,6 +6554,18 @@ function leftoverMarkerFiles(check) {
6550
6554
  const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
6551
6555
  return [...new Set(files)];
6552
6556
  }
6557
+ function rawDiffStates(raw) {
6558
+ const parts = raw.split("\0");
6559
+ const states = {};
6560
+ for (let i = 0; i + 1 < parts.length; i += 2) {
6561
+ const meta = parts[i].trim();
6562
+ const file = parts[i + 1];
6563
+ if (!meta.startsWith(":") || !file) continue;
6564
+ const [, mode, , blob] = meta.slice(1).split(" ");
6565
+ states[file] = `${mode}:${blob}`;
6566
+ }
6567
+ return states;
6568
+ }
6553
6569
  function settleStatus(record, status) {
6554
6570
  record.status = status;
6555
6571
  delete record.repairBase;
@@ -6649,6 +6665,8 @@ var GitWorktreeIsolation = class {
6649
6665
  adminChains = /* @__PURE__ */ new Map();
6650
6666
  waiting = [];
6651
6667
  draining = false;
6668
+ /** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
6669
+ turns = Promise.resolve();
6652
6670
  async isActive(workspaceRoot) {
6653
6671
  if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
6654
6672
  const version = await this.tryGit(void 0, ["--version"]);
@@ -7005,6 +7023,62 @@ var GitWorktreeIsolation = class {
7005
7023
  return !linked.has(rel) && !linked.has(rel.split("/")[0]);
7006
7024
  });
7007
7025
  }
7026
+ async findInHead(run) {
7027
+ const found = [];
7028
+ for (const record of Object.values(run.tasks)) {
7029
+ if (record.status !== "merged" || record.inHead) continue;
7030
+ if (!await this.headContains(run, record)) continue;
7031
+ record.inHead = true;
7032
+ found.push(record.taskId);
7033
+ }
7034
+ return found;
7035
+ }
7036
+ async headContains(run, record) {
7037
+ for (const repo of run.repos) {
7038
+ const entry = record.repos[repo.path];
7039
+ if (!entry?.changed) continue;
7040
+ const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
7041
+ if (!merged.ok) return false;
7042
+ }
7043
+ return true;
7044
+ }
7045
+ async snapshotTree(workspaceRoot, exclude) {
7046
+ const scan = await this.scanGroup(workspaceRoot);
7047
+ if ("refused" in scan) return null;
7048
+ const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
7049
+ if (repos.length === 0) return null;
7050
+ const snapshot = {};
7051
+ for (const repoPath of repos) {
7052
+ const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
7053
+ if (states) snapshot[repoPath] = states;
7054
+ }
7055
+ return snapshot;
7056
+ }
7057
+ async changedSince(workspaceRoot, snapshot) {
7058
+ const changed = [];
7059
+ for (const [repoPath, before] of Object.entries(snapshot)) {
7060
+ const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
7061
+ if (!now) continue;
7062
+ for (const [file, state] of Object.entries(now)) {
7063
+ if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
7064
+ }
7065
+ }
7066
+ return changed.sort();
7067
+ }
7068
+ /**
7069
+ * Each tracked file that differs from HEAD, with what it holds. `git stash
7070
+ * create` records the working tree as a commit without touching the tree,
7071
+ * the index or the stash, so a file changed again is told apart from one
7072
+ * left as it was. Null when git cannot tell — a merge in progress, say.
7073
+ */
7074
+ async trackedStates(root) {
7075
+ const stash = await this.tryGit(root, ["stash", "create"]);
7076
+ if (!stash.ok) return null;
7077
+ const commit = stash.stdout.trim();
7078
+ if (!commit) return {};
7079
+ const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
7080
+ return diff.ok ? rawDiffStates(diff.stdout) : null;
7081
+ }
7008
7082
  /**
7009
7083
  * One section per repo with changes. A repo below the workspace root gets a
7010
7084
  * header and paths prefixed with its own, so the whole reads as one patch
@@ -7022,7 +7096,10 @@ var GitWorktreeIsolation = class {
7022
7096
  }
7023
7097
  return diff;
7024
7098
  }
7025
- async mergeIntoCheckedOut(run) {
7099
+ mergeIntoCheckedOut(run) {
7100
+ return this.inTurn(() => this.mergeAll(run));
7101
+ }
7102
+ async mergeAll(run) {
7026
7103
  const partial = await this.settleLanding(run);
7027
7104
  if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
7028
7105
  const repos = await this.reposWithWork(run);
@@ -7156,7 +7233,7 @@ var GitWorktreeIsolation = class {
7156
7233
  if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
7157
7234
  });
7158
7235
  const [entry] = this.waiting.splice(pick, 1);
7159
- entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
7236
+ entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
7160
7237
  }
7161
7238
  } finally {
7162
7239
  this.draining = false;
@@ -7208,6 +7285,10 @@ var GitWorktreeIsolation = class {
7208
7285
  return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
7209
7286
  }
7210
7287
  }
7288
+ for (const repo of changed) {
7289
+ const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
7290
+ if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
7291
+ }
7211
7292
  delete run.landing;
7212
7293
  settleStatus(record, "merged");
7213
7294
  delete record.conflictRepo;
@@ -7482,6 +7563,11 @@ var GitWorktreeIsolation = class {
7482
7563
  async abortMerge(cwd) {
7483
7564
  if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
7484
7565
  }
7566
+ inTurn(fn) {
7567
+ const turn = this.turns.then(fn, fn);
7568
+ this.turns = turn.catch(() => void 0);
7569
+ return turn;
7570
+ }
7485
7571
  admin(repoKey, fn) {
7486
7572
  const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
7487
7573
  const next = prior.then(fn, fn);
@@ -8155,8 +8241,22 @@ function researchPhaseBlock(harnessMode) {
8155
8241
  "- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
8156
8242
  ].join("\n");
8157
8243
  }
8158
- 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.";
8244
+ 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.";
8159
8245
  var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
8246
+ function opsTaskSection(conversation) {
8247
+ return [
8248
+ "",
8249
+ "CHANGE AND OPS TASKS:",
8250
+ "- 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?",
8251
+ '- A change task (the default) edits repository files. Omit "ops".',
8252
+ `- 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.`,
8253
+ `- 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.`,
8254
+ '- 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.',
8255
+ "- 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.",
8256
+ "- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
8257
+ ...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.`] : []
8258
+ ];
8259
+ }
8160
8260
  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.";
8161
8261
  function repoGroupSection(isolated) {
8162
8262
  if (!isolated) return [];
@@ -8236,6 +8336,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
8236
8336
  ' "taskMode": "' + modeExamples + '",',
8237
8337
  ' "autonomy": "AFK|HITL",',
8238
8338
  ' "sliceType": "AFK|HITL",',
8339
+ ' "ops": true,',
8239
8340
  ' "userStoriesCovered": ["user story text"],',
8240
8341
  ' "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": [] }]',
8241
8342
  " }",
@@ -8261,6 +8362,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
8261
8362
  SHARED_APPEND_FILE_RULE,
8262
8363
  ...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
8263
8364
  ...repoGroupSection(variant.isolatedExecution),
8365
+ ...opsTaskSection(true),
8264
8366
  "",
8265
8367
  "RULES:",
8266
8368
  "- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
@@ -8336,6 +8438,7 @@ function corePlannerPrompt(isolatedExecution) {
8336
8438
  ' "taskMode": "{{MODE_EXAMPLES}}",',
8337
8439
  ' "autonomy": "AFK|HITL",',
8338
8440
  ' "sliceType": "AFK|HITL",',
8441
+ ' "ops": true,',
8339
8442
  ' "userStoriesCovered": ["user story text"],',
8340
8443
  ' "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": [] }]',
8341
8444
  " }",
@@ -8363,6 +8466,7 @@ function corePlannerPrompt(isolatedExecution) {
8363
8466
  SHARED_APPEND_FILE_RULE,
8364
8467
  isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
8365
8468
  ...repoGroupSection(isolatedExecution),
8469
+ ...opsTaskSection(false),
8366
8470
  "",
8367
8471
  "RULES:",
8368
8472
  '- Mark as type "ai" any task the coding assistant can do autonomously.',
@@ -8759,7 +8863,9 @@ function parseTask(raw, runners, runnerModes, autonomousDefault = true, inherite
8759
8863
  ),
8760
8864
  autonomy,
8761
8865
  sliceType,
8762
- userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0
8866
+ userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0,
8867
+ // A subtask runs with its parent, so only a top-level AI task carries it (ADR-0020).
8868
+ ops: !inherited && taskType === "ai" && raw.ops === true
8763
8869
  });
8764
8870
  }
8765
8871
  function validateVerticalSliceShape(tasks, raw, parentTitle) {
@@ -9013,7 +9119,7 @@ function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRu
9013
9119
  }
9014
9120
 
9015
9121
  // src/services/TaskEditValidator.ts
9016
- var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy"];
9122
+ var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy", "ops"];
9017
9123
  function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
9018
9124
  if (!catalog) return { ok: true };
9019
9125
  if (assignedModel) {
@@ -9050,6 +9156,19 @@ function typeCoherenceCheck(target, changes) {
9050
9156
  }
9051
9157
  return { ok: true, clear: target.userSteps && target.userSteps.length > 0 ? ["userSteps"] : [] };
9052
9158
  }
9159
+ var NOT_STARTED = /* @__PURE__ */ new Set(["pending", "approved", "blocked"]);
9160
+ function opsCheck(tasks, target, changes) {
9161
+ if (!("ops" in changes) || !!changes.ops === !!target.ops) return null;
9162
+ if (!NOT_STARTED.has(target.status)) {
9163
+ return { ok: false, error: `"${target.title}" has started, so whether it is an ops task is fixed` };
9164
+ }
9165
+ if (!changes.ops) return null;
9166
+ if ((changes.type ?? target.type) !== "ai") return { ok: false, error: `"${target.title}" is a manual task \u2014 only an AI task can be an ops task` };
9167
+ if (tasks.some((t) => t.subtasks?.some((s) => s.id === target.id))) {
9168
+ return { ok: false, error: `"${target.title}" is a subtask, which runs with its parent \u2014 only a top-level task can be an ops task` };
9169
+ }
9170
+ return null;
9171
+ }
9053
9172
  function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
9054
9173
  const target = tasks.find((t) => t.id === taskId);
9055
9174
  if (!target) return { ok: false, error: "Task not found" };
@@ -9063,6 +9182,8 @@ function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
9063
9182
  }
9064
9183
  const coherence = typeCoherenceCheck(target, changes);
9065
9184
  if (coherence && !coherence.ok) return coherence;
9185
+ const ops = opsCheck(tasks, target, changes);
9186
+ if (ops) return ops;
9066
9187
  const runner = changes.assignedRunner ?? target.assignedRunner;
9067
9188
  const validity = checkModelAndModeValidity(runner, changes.assignedModel, changes.taskMode, catalog);
9068
9189
  if (!validity.ok) return validity;
@@ -9082,7 +9203,8 @@ var UPDATABLE_FIELDS = [
9082
9203
  "type",
9083
9204
  "userSteps",
9084
9205
  "autonomy",
9085
- "sliceType"
9206
+ "sliceType",
9207
+ "ops"
9086
9208
  ];
9087
9209
  function taskOpsProtocol(executionNote = "") {
9088
9210
  const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
@@ -9090,7 +9212,7 @@ function taskOpsProtocol(executionNote = "") {
9090
9212
  "- To modify specific tasks, reply with ONLY this JSON object:",
9091
9213
  ' {"taskOps": [',
9092
9214
  ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
9093
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
9215
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?,"ops"?},"handle"?:"<name>"},',
9094
9216
  ' {"op":"remove","taskId":"<id or #order>"},',
9095
9217
  ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
9096
9218
  ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
@@ -9193,6 +9315,14 @@ function refCollidesWithExisting(name, originalFlatAll) {
9193
9315
  if (/^\d+$/.test(orderStr) && originalFlatAll.some((t) => t.order === Number(orderStr))) return true;
9194
9316
  return originalFlatAll.some((t) => t.title === name);
9195
9317
  }
9318
+ function updatableChanges(raw) {
9319
+ const changes = {};
9320
+ for (const key of UPDATABLE_FIELDS) {
9321
+ if (raw && key in raw) changes[key] = raw[key];
9322
+ }
9323
+ if ("ops" in changes) changes.ops = changes.ops === true ? true : void 0;
9324
+ return changes;
9325
+ }
9196
9326
  function findInTasks(tasks, id) {
9197
9327
  return flattenTasks(tasks).find((t) => t.id === id);
9198
9328
  }
@@ -9374,10 +9504,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9374
9504
  errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
9375
9505
  break;
9376
9506
  }
9377
- const changes = {};
9378
- for (const key of UPDATABLE_FIELDS) {
9379
- if (op.changes && key in op.changes) changes[key] = op.changes[key];
9380
- }
9507
+ const changes = updatableChanges(op.changes);
9381
9508
  if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
9382
9509
  errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
9383
9510
  break;
@@ -9529,6 +9656,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9529
9656
  taskMode: spec.taskMode ?? toMerge[0].taskMode,
9530
9657
  autonomy: spec.autonomy ?? toMerge.find((t) => t.autonomy)?.autonomy,
9531
9658
  sliceType: spec.sliceType ?? toMerge.find((t) => t.sliceType)?.sliceType,
9659
+ ops: spec.ops ?? toMerge.every((t) => t.ops),
9532
9660
  userStoriesCovered: spec.userStoriesCovered ?? (toMerge.flatMap((t) => t.userStoriesCovered ?? []).length ? [...new Set(toMerge.flatMap((t) => t.userStoriesCovered ?? []))] : void 0)
9533
9661
  });
9534
9662
  const mergedId = mergedTask.id;
@@ -9609,6 +9737,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9609
9737
  taskMode: spec.taskMode ?? target.taskMode,
9610
9738
  autonomy: spec.autonomy ?? target.autonomy,
9611
9739
  sliceType: spec.sliceType ?? target.sliceType,
9740
+ ops: spec.ops ?? target.ops,
9612
9741
  userStoriesCovered: spec.userStoriesCovered ?? target.userStoriesCovered
9613
9742
  });
9614
9743
  newTasks.push(nt);
@@ -9644,10 +9773,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9644
9773
  errors.push(`${label}: "${target.title}" is running and cannot be re-armed`);
9645
9774
  break;
9646
9775
  }
9647
- const changes = {};
9648
- for (const key of UPDATABLE_FIELDS) {
9649
- if (op.changes && key in op.changes) changes[key] = op.changes[key];
9650
- }
9776
+ const changes = updatableChanges(op.changes);
9651
9777
  if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
9652
9778
  errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
9653
9779
  break;
@@ -11477,6 +11603,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
11477
11603
  lines.join("\n") + footer + runnerNote
11478
11604
  ].join("\n");
11479
11605
  }
11606
+ function renderPreviousAttempt(output) {
11607
+ const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
11608
+ return [
11609
+ "## Previous attempt",
11610
+ "",
11611
+ "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.",
11612
+ "",
11613
+ "The end of its output:",
11614
+ tail
11615
+ ].join("\n");
11616
+ }
11480
11617
  function renderCompletionMarker(task) {
11481
11618
  return `
11482
11619
 
@@ -11528,6 +11665,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
11528
11665
  const flat = flattenTasks(allTasks);
11529
11666
  const outputs = collectDirectDependencyOutputs(task, flat);
11530
11667
  if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
11668
+ if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
11531
11669
  if (opts?.tddEnabled) {
11532
11670
  blocks.push(renderTddInstruction());
11533
11671
  }
@@ -11540,9 +11678,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
11540
11678
 
11541
11679
  ${basePrompt}${marker2}`;
11542
11680
  }
11543
- function composeContinuationPrompt(task, message) {
11681
+ function composeContinuationPrompt(task, message, opts = {}) {
11544
11682
  const reminder = [
11545
- "(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."
11683
+ 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."
11546
11684
  ];
11547
11685
  if (isHitlTask(task)) {
11548
11686
  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}.`);
@@ -12282,6 +12420,8 @@ var PlanStore = class {
12282
12420
  _planTasks = [];
12283
12421
  _allTasks = [];
12284
12422
  _taskMap = /* @__PURE__ */ new Map();
12423
+ /** Task id → the top-level task it hangs under, itself for a top-level task. */
12424
+ _rootMap = /* @__PURE__ */ new Map();
12285
12425
  _planRunners = ["claude-code"];
12286
12426
  _onMutate = null;
12287
12427
  _executionLog = [];
@@ -12319,6 +12459,14 @@ var PlanStore = class {
12319
12459
  get(taskId) {
12320
12460
  return this._taskMap.get(taskId);
12321
12461
  }
12462
+ /**
12463
+ * Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
12464
+ * can be one; a subtask runs with the top-level task it hangs under.
12465
+ */
12466
+ isOps(taskId) {
12467
+ const root = this._rootMap.get(taskId);
12468
+ return root?.type === "ai" && root.ops === true;
12469
+ }
12322
12470
  /** A copy of the task tree, detached from the store: later status changes do not reach it. */
12323
12471
  snapshot() {
12324
12472
  return cloneTasks(this._planTasks);
@@ -12413,6 +12561,7 @@ ${taskB.description}`,
12413
12561
  assignedRunner: taskA.assignedRunner,
12414
12562
  assignedModel: taskA.assignedModel,
12415
12563
  taskMode: taskA.taskMode,
12564
+ ops: taskA.ops === true && taskB.ops === true,
12416
12565
  order: Math.min(taskA.order, taskB.order)
12417
12566
  });
12418
12567
  const mergedId = merged.id;
@@ -12442,7 +12591,8 @@ ${taskB.description}`,
12442
12591
  taskMode: spec.taskMode ?? original.taskMode,
12443
12592
  autonomy: spec.autonomy ?? original.autonomy,
12444
12593
  sliceType: spec.sliceType ?? original.sliceType,
12445
- userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
12594
+ userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
12595
+ ops: spec.ops ?? original.ops
12446
12596
  }));
12447
12597
  });
12448
12598
  const tailId = finalized[finalized.length - 1].id;
@@ -12496,8 +12646,14 @@ ${taskB.description}`,
12496
12646
  this.setStatus(id, "pending");
12497
12647
  task.verdict = void 0;
12498
12648
  task.outputSummary = void 0;
12649
+ delete task.forcedPastGate;
12499
12650
  }
12500
12651
  }
12652
+ /** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
12653
+ setForcedPastGate(id, dependencies) {
12654
+ const task = this._taskMap.get(id);
12655
+ if (task) task.forcedPastGate = dependencies;
12656
+ }
12501
12657
  /** A reason outlives nothing: any status but `awaiting_user` drops it. */
12502
12658
  setStatus(id, status) {
12503
12659
  const task = this._taskMap.get(id);
@@ -12580,6 +12736,10 @@ ${taskB.description}`,
12580
12736
  for (const task of this._allTasks) {
12581
12737
  this._taskMap.set(task.id, task);
12582
12738
  }
12739
+ this._rootMap.clear();
12740
+ for (const root of this._planTasks) {
12741
+ for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
12742
+ }
12583
12743
  }
12584
12744
  validateAssignedRunners() {
12585
12745
  for (const task of this._allTasks) {
@@ -12633,9 +12793,9 @@ var IsolationRunController = class {
12633
12793
  /** Copied paths already reported for the current run: every task gets the same copies. */
12634
12794
  reportedCopies = /* @__PURE__ */ new Set();
12635
12795
  /**
12636
- * How the open run executes; null while no run is open. A run is one
12637
- * Execute-Plan or one manual task run, from its start until it settles or
12638
- * is stopped.
12796
+ * How the open run executes; null while no run is open, `undecided` until
12797
+ * its first change task asks. A run is one Execute-Plan or one manual task
12798
+ * run, from its start until it settles or is stopped.
12639
12799
  */
12640
12800
  mode = null;
12641
12801
  /** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
@@ -12659,6 +12819,10 @@ var IsolationRunController = class {
12659
12819
  get isOpen() {
12660
12820
  return this.mode !== null;
12661
12821
  }
12822
+ /** The open run has decided whether it isolates. */
12823
+ get decided() {
12824
+ return this.mode === "isolated" || this.mode === "shared";
12825
+ }
12662
12826
  /** The open run executes in worktrees. */
12663
12827
  get isolating() {
12664
12828
  return this.mode === "isolated" && this.run !== null;
@@ -12715,13 +12879,22 @@ var IsolationRunController = class {
12715
12879
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
12716
12880
  }
12717
12881
  }
12882
+ /** Open a run if none is, leaving whether it isolates to its first change task. */
12883
+ open() {
12884
+ if (this.mode) return;
12885
+ this.mode = "undecided";
12886
+ this.listener.opened();
12887
+ }
12718
12888
  /**
12719
- * Open a run if none is: decide once, at its start, whether it executes in
12720
- * worktrees. `resume` is what a dirty tree parks until the user chooses how
12721
- * to go on. Resolves false when the run did not start.
12889
+ * Decide once per run, as its first change task starts, whether it executes
12890
+ * in worktrees (ADR-0020): an ops task needs none, so a run of only ops
12891
+ * tasks never asks. `resume` is what a dirty tree parks until the user
12892
+ * chooses how to go on. Resolves false while the run waits on that choice.
12722
12893
  */
12723
- open(resume) {
12724
- if (this.mode) return Promise.resolve(true);
12894
+ decide(resume) {
12895
+ this.open();
12896
+ if (this.decided) return Promise.resolve(true);
12897
+ if (this.blockedStart) return Promise.resolve(false);
12725
12898
  this.opening ??= this.activate(resume).finally(() => {
12726
12899
  this.opening = null;
12727
12900
  });
@@ -12736,8 +12909,8 @@ var IsolationRunController = class {
12736
12909
  * the safe rule then.
12737
12910
  */
12738
12911
  async plannerLayout() {
12739
- if (this.mode) return this.isolating && this.run ? layoutOf(this.run) : false;
12740
- const decision = await this.decide(this.workspaceRoot());
12912
+ if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
12913
+ const decision = await this.assess(this.workspaceRoot());
12741
12914
  return decision.mode === "isolated" ? decision.layout : false;
12742
12915
  }
12743
12916
  /**
@@ -12772,7 +12945,7 @@ var IsolationRunController = class {
12772
12945
  const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
12773
12946
  return { cwd: cwd2, worktree: true };
12774
12947
  }
12775
- if (!this.isolating || !this.run) {
12948
+ if (opts.ops || !this.isolating || !this.run) {
12776
12949
  await this.release(task.id, { keep: false });
12777
12950
  return { cwd: this.workspaceRoot(), worktree: false };
12778
12951
  }
@@ -12817,18 +12990,71 @@ var IsolationRunController = class {
12817
12990
  }
12818
12991
  this.listener.changed();
12819
12992
  }
12820
- /** Close the open run. An isolated one hands its integration branch over for review. */
12993
+ /**
12994
+ * Close the open run. An isolated one hands its integration branch over for
12995
+ * review — unless the user merged everything it landed at merge gates
12996
+ * already, which leaves nothing to hand over, so it is cleared up as a Merge
12997
+ * all would have.
12998
+ */
12821
12999
  async close() {
12822
13000
  const mode = this.mode;
12823
13001
  this.mode = null;
12824
- if (mode !== "isolated" || !this.run) return;
13002
+ const run = this.run;
13003
+ if (mode !== "isolated" || !run) return;
13004
+ await this.refreshInHead();
13005
+ const records = Object.values(run.tasks);
13006
+ if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
13007
+ this.tell("info", "Everything this run landed is merged into your branch already.");
13008
+ await this.clearMerged(run);
13009
+ return;
13010
+ }
12825
13011
  try {
12826
- this.listener.handoff(await this.isolation.handoff(this.run));
13012
+ this.listener.handoff(await this.isolation.handoff(run));
12827
13013
  } catch (err) {
12828
13014
  this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
12829
13015
  }
12830
13016
  this.listener.changed();
12831
13017
  }
13018
+ /**
13019
+ * Whether a task's landed work still waits to be merged into the user's
13020
+ * branch — what holds an ops or user task at its merge gate (ADR-0020). A
13021
+ * task with no record never landed anything to wait for.
13022
+ */
13023
+ awaitsMerge(taskId) {
13024
+ const record = this.run?.tasks[taskId];
13025
+ return !!record && record.status === "merged" && !record.inHead;
13026
+ }
13027
+ /**
13028
+ * Look again for landed work the user has merged — by Merge all, or by hand
13029
+ * with git. Resolves true when some was found.
13030
+ */
13031
+ async refreshInHead() {
13032
+ const run = this.run;
13033
+ if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
13034
+ const found = await this.isolation.findInHead(run).catch(() => []);
13035
+ if (found.length > 0) this.listener.changed();
13036
+ return found.length > 0;
13037
+ }
13038
+ /**
13039
+ * The tracked state of the workspace as an ops task starts, for
13040
+ * {@link filesChangedSince} (ADR-0020). Null where the check does not run:
13041
+ * a run that shares the workspace root, where every task's edits land in
13042
+ * it, or a workspace git cannot isolate at all.
13043
+ */
13044
+ async snapshotWorkspace() {
13045
+ if (this.mode === "shared") return null;
13046
+ const root = this.workspaceRoot();
13047
+ if (!this.isolating) {
13048
+ const availability = await this.isolation.isActive(root).catch(() => null);
13049
+ if (!availability || !availability.active && availability.reason !== "dirty") return null;
13050
+ }
13051
+ return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
13052
+ }
13053
+ /** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
13054
+ async filesChangedSince(snapshot) {
13055
+ if (!snapshot || this.mode === "shared") return [];
13056
+ return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
13057
+ }
12832
13058
  /**
12833
13059
  * A stop or a plan load: the open run ends without a handoff and a parked
12834
13060
  * start is dropped. `keepOpen` spares a run the scheduler is still driving.
@@ -12859,19 +13085,23 @@ var IsolationRunController = class {
12859
13085
  }
12860
13086
  /**
12861
13087
  * "Merge all": the run's integration branches into whatever the user has
12862
- * checked out, in every repo or none. Once everything merged, the run has
12863
- * nothing left to hand over, so it is cleared up and forgotten; a branch
12864
- * the user's HEAD somehow does not contain stays for the next run's sweep.
13088
+ * checked out, in every repo or none. During a run it merges what has
13089
+ * landed so far and the run goes on (ADR-0020): its branches stay for the
13090
+ * tasks still to land. Once a settled run merged everything, it has nothing
13091
+ * left to hand over, so it is cleared up and forgotten; a branch the user's
13092
+ * HEAD somehow does not contain stays for the next run's sweep.
12865
13093
  */
12866
13094
  async merge() {
12867
13095
  const run = this.requireRun();
13096
+ const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
12868
13097
  const result = await this.isolation.mergeIntoCheckedOut(run);
12869
13098
  const branch = integrationBranchNameOf(run);
12870
13099
  const group = run.repos.some((r) => r.path !== SELF_REPO);
12871
- const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
12872
13100
  const { level, message } = describeMergeResult(result, branch, group, repaired);
12873
13101
  this.tell(level, message);
12874
- if (result.outcome === "merged") await this.clearMerged(run);
13102
+ if (result.outcome !== "merged") return result;
13103
+ await this.refreshInHead();
13104
+ if (!this.isOpen) await this.clearMerged(run);
12875
13105
  return result;
12876
13106
  }
12877
13107
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
@@ -12903,7 +13133,7 @@ var IsolationRunController = class {
12903
13133
  this.resolvers = {};
12904
13134
  this.listener.changed();
12905
13135
  }
12906
- async decide(root) {
13136
+ async assess(root) {
12907
13137
  const availability = await this.isolation.isActive(root);
12908
13138
  const continued = this.continuableRun(root);
12909
13139
  const continuing = continued !== null;
@@ -12918,7 +13148,7 @@ var IsolationRunController = class {
12918
13148
  }
12919
13149
  async activate(resume) {
12920
13150
  const root = this.workspaceRoot();
12921
- const decision = await this.decide(root);
13151
+ const decision = await this.assess(root);
12922
13152
  if (decision.mode === "blocked") {
12923
13153
  this.blockedStart = resume;
12924
13154
  this.blockedRepos = decision.repos;
@@ -12945,8 +13175,8 @@ var IsolationRunController = class {
12945
13175
  return true;
12946
13176
  }
12947
13177
  begin(mode) {
13178
+ this.open();
12948
13179
  this.mode = mode;
12949
- this.listener.opened();
12950
13180
  }
12951
13181
  /** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
12952
13182
  async sweep() {
@@ -13293,8 +13523,9 @@ var MessageQueue = class {
13293
13523
  // src/services/readiness.ts
13294
13524
  function selectReadyTasks(input) {
13295
13525
  const { store, onHold, runs, active, maxParallel } = input;
13296
- if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
13526
+ if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
13297
13527
  const availableSlots = maxParallel - active;
13528
+ const gated = [];
13298
13529
  const candidates = store.allTasks.filter((t) => {
13299
13530
  if (t.status !== "pending" && t.status !== "approved") return false;
13300
13531
  if (t.type === "user") return false;
@@ -13302,13 +13533,21 @@ function selectReadyTasks(input) {
13302
13533
  if (onHold.has(t.id)) return false;
13303
13534
  if (isBlocked(t, store)) return false;
13304
13535
  if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
13536
+ if (store.isOps(t.id)) {
13537
+ if (input.merging) return false;
13538
+ if (mergeGate(t, store, runs).length > 0) {
13539
+ gated.push(t);
13540
+ return false;
13541
+ }
13542
+ }
13305
13543
  return true;
13306
13544
  });
13307
- const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, store, onHold, runs) }));
13545
+ const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
13308
13546
  return {
13309
13547
  ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
13310
13548
  candidateCount: candidates.length,
13311
- excluded
13549
+ excluded,
13550
+ gated
13312
13551
  };
13313
13552
  }
13314
13553
  function isBlocked(task, store) {
@@ -13321,12 +13560,19 @@ function dependencyMet(depId, store, runs) {
13321
13560
  const record = runs.openRecord(depId);
13322
13561
  return !record || record.status === "merged";
13323
13562
  }
13324
- function exclusionReasons(task, store, onHold, runs) {
13563
+ function mergeGate(task, store, runs) {
13564
+ if (task.type !== "user" && !store.isOps(task.id)) return [];
13565
+ return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
13566
+ }
13567
+ function exclusionReasons(task, input) {
13568
+ const { store, onHold, runs } = input;
13325
13569
  const reasons = [];
13326
13570
  if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
13327
13571
  if (onHold.has(task.id)) reasons.push("on-hold");
13328
13572
  if (isBlocked(task, store)) reasons.push("blocked");
13329
13573
  if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
13574
+ if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
13575
+ if (input.merging && store.isOps(task.id)) reasons.push("merging");
13330
13576
  return reasons;
13331
13577
  }
13332
13578
 
@@ -16217,6 +16463,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
16217
16463
  * pending and nothing ran until the user also re-ran the whole plan.
16218
16464
  */
16219
16465
  haltedByFailure = false;
16466
+ /** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
16467
+ merging = false;
16468
+ /** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
16469
+ gateNotice = "";
16220
16470
  /** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
16221
16471
  workspaceEnv;
16222
16472
  /** What the workspace env has already warned about, so a run says it once, not per task. */
@@ -16525,9 +16775,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
16525
16775
  async reviewRunDiff() {
16526
16776
  return this.runs.reviewDiff();
16527
16777
  }
16528
- /** "Merge all"; a run that merged everything is cleared up and forgotten. */
16778
+ /**
16779
+ * "Merge all"; a settled run that merged everything is cleared up and
16780
+ * forgotten. During a run it merges what has landed and the run goes on:
16781
+ * the tasks waiting at a merge gate for that work then start (ADR-0020).
16782
+ * Never while an ops task runs, and no ops task starts until it ends — the
16783
+ * one overlap the plan cannot order, because the merge is the user's.
16784
+ */
16529
16785
  async mergeRun() {
16530
- return this.runs.merge();
16786
+ const ops = [...this.attempts.values()].find((a) => a.ops);
16787
+ if (ops) {
16788
+ const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
16789
+ throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
16790
+ }
16791
+ this.merging = true;
16792
+ let result;
16793
+ try {
16794
+ result = await this.runs.merge();
16795
+ } finally {
16796
+ this.merging = false;
16797
+ }
16798
+ await this.tick();
16799
+ return result;
16800
+ }
16801
+ /**
16802
+ * The run as a surface offers Merge all mid-run (ADR-0020): what has landed
16803
+ * and is not merged, and whether the run is paused at its gates with nothing
16804
+ * else running. Null while no task waits at a gate.
16805
+ */
16806
+ mergeGateView() {
16807
+ const handoff = this.runs.view()?.handoff;
16808
+ if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
16809
+ return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
16810
+ }
16811
+ /**
16812
+ * The dependencies an ops or user task waits on at its merge gate: those
16813
+ * whose landed work is not in the user's branch yet (ADR-0020). Empty once
16814
+ * the task has started, and for every other task.
16815
+ */
16816
+ getMergeGate(taskId) {
16817
+ const task = this.store.get(taskId);
16818
+ if (!task || task.status !== "pending" && task.status !== "approved") return [];
16819
+ return mergeGate(task, this.store, this.runs);
16531
16820
  }
16532
16821
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
16533
16822
  async cleanupRun() {
@@ -16627,7 +16916,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16627
16916
  this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
16628
16917
  return;
16629
16918
  }
16630
- if (!await this.runs.open(() => this.start()) || this.running) return;
16919
+ this.runs.open();
16631
16920
  console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
16632
16921
  this.running = true;
16633
16922
  this.haltedByFailure = false;
@@ -16669,13 +16958,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
16669
16958
  const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
16670
16959
  if (this.attempts.get(taskId) !== attempt) return;
16671
16960
  const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
16961
+ const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
16672
16962
  const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
16673
16963
  if (this.attempts.get(taskId) !== attempt) return;
16674
16964
  this.endAttempt(taskId, "verdict");
16675
16965
  this.store.setTaskVerdict(taskId, verdict);
16676
16966
  console.error(`[TaskOrchestrator] Output summary:
16677
16967
  ${summary || "(empty \u2014 no output captured)"}`);
16678
- if (landing) {
16968
+ if (changedFiles.length > 0) {
16969
+ this.store.markAwaitingUser(taskId, "files-changed");
16970
+ 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.`);
16971
+ } else if (landing) {
16679
16972
  await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
16680
16973
  } else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
16681
16974
  this.store.markAwaitingUser(taskId);
@@ -16777,21 +17070,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
16777
17070
  });
16778
17071
  }
16779
17072
  getReadyTasks() {
16780
- if (!this.running) return [];
17073
+ return this.readiness().ready;
17074
+ }
17075
+ readiness() {
17076
+ if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
16781
17077
  const maxParallel = this.config.maxParallelSessions;
16782
17078
  const active = this.attempts.size;
16783
- const { ready, candidateCount, excluded } = selectReadyTasks({
17079
+ const readiness = selectReadyTasks({
16784
17080
  store: this.store,
16785
17081
  onHold: this.onHold,
16786
17082
  runs: this.runs,
16787
17083
  active,
16788
- maxParallel
17084
+ maxParallel,
17085
+ merging: this.merging
16789
17086
  });
16790
- for (const { task, reasons } of excluded) {
17087
+ for (const { task, reasons } of readiness.excluded) {
16791
17088
  console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
16792
17089
  }
16793
- console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
16794
- return ready;
17090
+ console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
17091
+ return readiness;
17092
+ }
17093
+ /**
17094
+ * Open the run a task starts in, and for a change task decide whether it
17095
+ * isolates (ADR-0020): an ops task needs no worktree, so it never waits on
17096
+ * that choice. False while a dirty tree waits on the user; `resume` is what
17097
+ * their choice replays.
17098
+ */
17099
+ async openFor(taskId, resume) {
17100
+ this.runs.open();
17101
+ return this.store.isOps(taskId) || this.runs.decide(resume);
16795
17102
  }
16796
17103
  /**
16797
17104
  * Cancel a running (or scheduled) task: kill its session and return it to
@@ -16919,7 +17226,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
16919
17226
  const runner = this.store.resolveTaskRunner(task);
16920
17227
  const route = routeTransport("structured", runner, this.registry);
16921
17228
  if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
16922
- if (!await this.runs.open(() => this.continueTask(taskId, message))) return;
17229
+ if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
16923
17230
  if (this.attempts.has(taskId) || !continuability(task).ok) return;
16924
17231
  this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
16925
17232
  this.endAttempt(taskId, "retry");
@@ -16941,10 +17248,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
16941
17248
  const task = this.store.get(taskId);
16942
17249
  if (!task || task.type !== "ai") return;
16943
17250
  if (this.attempts.has(taskId)) return;
16944
- if (!await this.runs.open(() => this.forceStartTask(taskId))) return;
17251
+ this.refuseOpsDuringMerge(task);
17252
+ if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
16945
17253
  this.onHold.delete(taskId);
17254
+ this.notePastGate(task);
16946
17255
  await this.startTask(task);
16947
17256
  }
17257
+ refuseOpsDuringMerge(task) {
17258
+ if (this.merging && this.store.isOps(task.id)) {
17259
+ throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
17260
+ }
17261
+ }
17262
+ /**
17263
+ * A force start passes a merge gate (ADR-0020), and the surface confirmed it
17264
+ * first; the choice is kept on the task and said, because the work it acts
17265
+ * on is not in the user's branch.
17266
+ */
17267
+ notePastGate(task) {
17268
+ const unmerged = mergeGate(task, this.store, this.runs);
17269
+ if (unmerged.length === 0) return;
17270
+ const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
17271
+ this.store.setForcedPastGate(task.id, titles);
17272
+ this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
17273
+ }
16948
17274
  /**
16949
17275
  * Run exactly one task outside full-plan scheduling. The active/starting
16950
17276
  * session still contributes to {@link hasLiveWork} so every surface exposes
@@ -16955,8 +17281,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
16955
17281
  if (this.hasLiveWork) return;
16956
17282
  const task = this.store.get(taskId);
16957
17283
  if (!task || task.type !== "ai") return;
16958
- if (!await this.runs.open(() => this.runTask(taskId))) return;
17284
+ this.refuseOpsDuringMerge(task);
17285
+ if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
16959
17286
  this.onHold.delete(taskId);
17287
+ this.notePastGate(task);
16960
17288
  await this.startTask(task);
16961
17289
  }
16962
17290
  getCompletedCount() {
@@ -16996,7 +17324,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
16996
17324
  }
16997
17325
  return;
16998
17326
  }
16999
- const ready = this.getReadyTasks();
17327
+ let readiness = this.readiness();
17328
+ const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
17329
+ if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
17330
+ if (!this.running) return;
17331
+ const { ready } = readiness;
17000
17332
  console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
17001
17333
  if (ready.length === 0 && this.attempts.size === 0) {
17002
17334
  const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
@@ -17016,14 +17348,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
17016
17348
  this.emit("onExecutionComplete");
17017
17349
  } else {
17018
17350
  this.notifications.info("Remaining tasks require user action or are on hold.");
17351
+ this.sayGate();
17019
17352
  this.emit("onTaskChanged");
17020
17353
  this.emit("onTick");
17021
17354
  }
17022
17355
  return;
17023
17356
  }
17024
- for (const task of ready) await this.startTask(task);
17357
+ for (const task of ready) {
17358
+ if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
17359
+ await this.startTask(task);
17360
+ }
17025
17361
  this.emit("onTick");
17026
17362
  }
17363
+ /**
17364
+ * A run paused at merge gates says so, as plainly as a task that waits on
17365
+ * the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
17366
+ */
17367
+ sayGate() {
17368
+ const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
17369
+ 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.`;
17370
+ if (notice === this.gateNotice) return;
17371
+ this.gateNotice = notice;
17372
+ if (notice) this.tell("info", notice);
17373
+ }
17027
17374
  async startTask(task, continuation = null) {
17028
17375
  if (this.attempts.has(task.id)) return;
17029
17376
  const attempt = {
@@ -17037,6 +17384,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
17037
17384
  integration: null,
17038
17385
  repair: this.landing.nextRepair(task.id),
17039
17386
  continuation,
17387
+ ops: this.store.isOps(task.id),
17388
+ snapshot: null,
17040
17389
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
17041
17390
  decided: false
17042
17391
  };
@@ -17062,18 +17411,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
17062
17411
  */
17063
17412
  async spawnAttempt(task, attempt) {
17064
17413
  try {
17065
- const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
17414
+ const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
17066
17415
  attempt.cwd = cwd;
17067
17416
  attempt.worktree = worktree;
17417
+ if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
17068
17418
  if (this.attempts.get(task.id) !== attempt) {
17069
17419
  if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
17070
17420
  this.abandonSpawn(task, attempt);
17071
17421
  return false;
17072
17422
  }
17073
- const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
17423
+ 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, {
17074
17424
  planMapEnabled: this.config.planMapEnabled,
17075
17425
  // A merge to resolve is not new behaviour to drive test-first.
17076
- tddEnabled: !attempt.repair && this.tddEnabled()
17426
+ tddEnabled: !attempt.repair && this.tddEnabled(),
17427
+ // An ops task's effects outlive a failed attempt and are never rolled
17428
+ // back, so the next one is told what the last one did (ADR-0020).
17429
+ previousAttempt: attempt.ops ? this.output.liveTail(task.id, { maxLines: OPS_RETRY_TAIL_LINES })?.text : void 0
17077
17430
  });
17078
17431
  this.lingering.close(task.id);
17079
17432
  const env = await this.envForTask(cwd);
@@ -17250,6 +17603,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
17250
17603
  return ended;
17251
17604
  }
17252
17605
  };
17606
+ var OPS_RETRY_TAIL_LINES = 60;
17253
17607
  function unresumedMessage(task, why) {
17254
17608
  return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
17255
17609
  }
@@ -19707,7 +20061,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
19707
20061
  return tasks.map((t) => {
19708
20062
  const isMan = t.type === "user";
19709
20063
  const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
19710
- 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(", ")}]`;
20064
+ 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(", ")}]`;
19711
20065
  });
19712
20066
  }
19713
20067
  };
@@ -20163,10 +20517,11 @@ function serializeTask(t) {
20163
20517
  thinkingEffort: t.thinkingEffort || void 0,
20164
20518
  autonomy: t.autonomy || void 0,
20165
20519
  sliceType: t.sliceType || void 0,
20166
- userStoriesCovered: t.userStoriesCovered || void 0
20520
+ userStoriesCovered: t.userStoriesCovered || void 0,
20521
+ ...t.ops ? { ops: true } : {}
20167
20522
  };
20168
20523
  }
20169
- function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0) {
20524
+ function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0, mergeGate2 = []) {
20170
20525
  return {
20171
20526
  id: t.id,
20172
20527
  status: t.status,
@@ -20177,7 +20532,9 @@ function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [],
20177
20532
  ...t.status === "awaiting_user" && t.awaitingReason ? { awaitingReason: t.awaitingReason } : {},
20178
20533
  ...queued.length > 0 ? { queued: queued.map((m) => ({ ...m })) } : {},
20179
20534
  ...canContinue(t) ? { continuable: true } : {},
20180
- ...awaitingApproval > 0 ? { awaitingApproval } : {}
20535
+ ...awaitingApproval > 0 ? { awaitingApproval } : {},
20536
+ ...mergeGate2.length > 0 ? { mergeGate: [...mergeGate2] } : {},
20537
+ ...t.forcedPastGate?.length ? { forcedPastGate: [...t.forcedPastGate] } : {}
20181
20538
  };
20182
20539
  }
20183
20540
  function serializePlan(plan) {
@@ -20263,6 +20620,7 @@ var SessionEventRelay = class {
20263
20620
  this.statusOwed = this.statusHeld;
20264
20621
  if (this.statusHeld) return;
20265
20622
  plan.tasks = this.store.snapshot();
20623
+ const gate = this.orchestrator.mergeGateView();
20266
20624
  this.broadcast({
20267
20625
  type: "status_update",
20268
20626
  tasks: this.store.allTasks.map((t) => serializeTaskStatus(
@@ -20270,8 +20628,10 @@ var SessionEventRelay = class {
20270
20628
  this.orchestrator.getIdleSince(t.id),
20271
20629
  this.orchestrator.getTaskIsolation(t.id),
20272
20630
  this.orchestrator.getQueuedTaskMessages(t.id),
20273
- this.awaitingApproval(t.id)
20274
- ))
20631
+ this.awaitingApproval(t.id),
20632
+ this.orchestrator.getMergeGate(t.id)
20633
+ )),
20634
+ ...gate ? { gate } : {}
20275
20635
  });
20276
20636
  }
20277
20637
  /**
@@ -21604,6 +21964,14 @@ var Session = class {
21604
21964
  isolationView() {
21605
21965
  return this.orchestrator.isolationView();
21606
21966
  }
21967
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
21968
+ mergeGate(taskId) {
21969
+ return this.orchestrator.getMergeGate(taskId);
21970
+ }
21971
+ /** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
21972
+ mergeGateView() {
21973
+ return this.orchestrator.mergeGateView();
21974
+ }
21607
21975
  /** The run's integration branch against its base ref, as a unified diff. */
21608
21976
  async reviewRunDiff() {
21609
21977
  if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
@@ -21613,10 +21981,12 @@ var Session = class {
21613
21981
  * "Merge all": each repo's integration branch into whatever the user has
21614
21982
  * checked out there — every repo, or none if any cannot take it. The one
21615
21983
  * irreversible step of isolated execution, so this explicit call is the
21616
- * only way it ever happens.
21984
+ * only way it ever happens. During a run it merges what has landed so far,
21985
+ * which is what opens a merge gate (ADR-0020); it waits for no ops task,
21986
+ * and refuses while one runs.
21617
21987
  */
21618
21988
  async mergeRun() {
21619
- this.requireSettledRun();
21989
+ if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
21620
21990
  const result = await this.orchestrator.mergeRun();
21621
21991
  this.broadcast({ type: "isolation_merge", result });
21622
21992
  return result;
@@ -21706,7 +22076,8 @@ var Session = class {
21706
22076
  * falls through to the no-op `store.update` below instead of throwing.
21707
22077
  */
21708
22078
  async updateTask(taskId, changes) {
21709
- if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
22079
+ if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
22080
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
21710
22081
  const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
21711
22082
  if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
21712
22083
  if (check.clear?.length) {