@ordewell/core 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +181 -48
  16. package/dist/index.d.ts +181 -48
  17. package/dist/index.js +1207 -127
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +1153 -105
  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-_T5S7Mnk.d.ts → plan-utils-2K_N5ZJe.d.ts} +30 -4
  31. package/dist/{plan-utils-Ba-l8EZS.d.mts → plan-utils-D94CJ7CA.d.mts} +30 -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,12 +121,14 @@ 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;
128
128
  var TAIL_LINES = 40;
129
129
  var MAX_LINE_CHARS = 2e3;
130
+ var DIGEST_ARGS_CHARS = 120;
131
+ var DIGEST_MESSAGE_CHARS = 1e3;
130
132
  function capLine(line) {
131
133
  return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
132
134
  }
@@ -209,6 +211,33 @@ function coalesceTaskLog(events) {
209
211
  }
210
212
  return out;
211
213
  }
214
+ function digestTaskLog(events, maxCalls) {
215
+ const outcomes = /* @__PURE__ */ new Map();
216
+ let lastText;
217
+ let lastError;
218
+ for (const event of events) {
219
+ if (event.type === "tool_result") outcomes.set(event.id, event.success);
220
+ else if (event.type === "text") lastText = event.text;
221
+ else if (event.type === "error") lastError = event.message;
222
+ }
223
+ const lines = [];
224
+ for (const event of events) {
225
+ if (event.type !== "tool_call") continue;
226
+ const outcome = outcomes.get(event.id);
227
+ const args = event.args.length > DIGEST_ARGS_CHARS ? `${event.args.slice(0, DIGEST_ARGS_CHARS)}\u2026` : event.args;
228
+ lines.push(`- ${event.name} ${args} \u2192 ${outcome === void 0 ? "no result" : outcome ? "ok" : "failed"}`);
229
+ }
230
+ const omitted = lines.length - maxCalls;
231
+ const out = omitted > 0 ? [`\u2026 ${omitted} earlier call${omitted === 1 ? "" : "s"} omitted \u2026`, ...lines.slice(omitted)] : lines;
232
+ const gap = () => out.length > 0 ? [""] : [];
233
+ const said2 = lastText?.trim();
234
+ if (said2) {
235
+ const end = said2.length > DIGEST_MESSAGE_CHARS ? `\u2026${said2.slice(-DIGEST_MESSAGE_CHARS)}` : said2;
236
+ out.push(...gap(), "Its last message:", ...end.split("\n").map((l) => ` ${l}`));
237
+ }
238
+ if (lastError) out.push(...gap(), `It stopped with an error: ${lastError}`);
239
+ return out.join("\n");
240
+ }
212
241
 
213
242
  // src/interfaces/IFileSystem.ts
214
243
  var GREP_DEFAULT_HEAD_LIMIT = 100;
@@ -2446,6 +2475,8 @@ function lex(command, nested, dialect) {
2446
2475
  let unsafeRedirect;
2447
2476
  let processSubstitution = false;
2448
2477
  let unbalanced = false;
2478
+ let grouped = false;
2479
+ let joinedBy;
2449
2480
  let tokens = [];
2450
2481
  let expandable = false;
2451
2482
  let current = "";
@@ -2475,13 +2506,14 @@ function lex(command, nested, dialect) {
2475
2506
  current = "";
2476
2507
  started = false;
2477
2508
  };
2478
- const endSegment = (nextPiped) => {
2509
+ const endSegment = (nextPiped, next = "other") => {
2479
2510
  endToken();
2480
- if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
2511
+ if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs, joinedBy });
2481
2512
  tokens = [];
2482
2513
  inputs = [];
2483
2514
  expandable = false;
2484
2515
  piped = nextPiped;
2516
+ joinedBy = next;
2485
2517
  };
2486
2518
  let i = 0;
2487
2519
  while (i < command.length) {
@@ -2603,17 +2635,18 @@ function lex(command, nested, dialect) {
2603
2635
  }
2604
2636
  if (c === "(" || c === ")") {
2605
2637
  endToken();
2638
+ grouped = true;
2606
2639
  i++;
2607
2640
  continue;
2608
2641
  }
2609
2642
  if (c === "|") {
2610
2643
  const double = command[i + 1] === "|";
2611
- endSegment(!double);
2644
+ endSegment(!double, double ? "other" : "pipe");
2612
2645
  i += double || command[i + 1] === "&" ? 2 : 1;
2613
2646
  continue;
2614
2647
  }
2615
2648
  if (c === "&" || c === ";" || c === "\n") {
2616
- endSegment(false);
2649
+ endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
2617
2650
  i += command[i + 1] === c ? 2 : 1;
2618
2651
  continue;
2619
2652
  }
@@ -2631,7 +2664,7 @@ function lex(command, nested, dialect) {
2631
2664
  }
2632
2665
  if (quote !== "") unbalanced = true;
2633
2666
  endSegment(false);
2634
- return { segments, unsafeRedirect, processSubstitution, unbalanced };
2667
+ return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped };
2635
2668
  }
2636
2669
  function binaryName(token, dialect) {
2637
2670
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -2771,10 +2804,11 @@ function unwrap(seg, dialect) {
2771
2804
  }
2772
2805
  return { seg: { ...current, assignments }, wrappers, runner };
2773
2806
  }
2774
- function lexAll(command, dialect) {
2807
+ function lexAll(command, dialect, cdpathSet = false) {
2775
2808
  const queue = [];
2776
2809
  const top = lex(command, queue, dialect);
2777
2810
  const all = { ...top, segments: top.segments.filter((s) => s.binary) };
2811
+ if (!top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
2778
2812
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
2779
2813
  const inner = lex(queue.shift(), queue, dialect);
2780
2814
  all.segments.push(...inner.segments.filter((s) => s.binary));
@@ -2784,15 +2818,32 @@ function lexAll(command, dialect) {
2784
2818
  }
2785
2819
  return all;
2786
2820
  }
2821
+ function literalCdTarget(seg) {
2822
+ if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
2823
+ if (seg.args.length !== 1) return void 0;
2824
+ const [target] = seg.args;
2825
+ return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
2826
+ }
2827
+ function followCd(segments) {
2828
+ if (segments.some((s) => s.joinedBy === "other")) return;
2829
+ let chain = [];
2830
+ segments.forEach((seg, i) => {
2831
+ seg.cwd = chain;
2832
+ const inPipeline = seg.joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
2833
+ const target = inPipeline ? void 0 : literalCdTarget(seg);
2834
+ if (target !== void 0) chain = [...chain, target];
2835
+ });
2836
+ }
2787
2837
  function looksLikePath(arg) {
2788
2838
  if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
2789
2839
  return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
2790
2840
  }
2791
- function pathLikeArgs(command, opts = {}) {
2841
+ function pathRefs(command, opts = {}) {
2792
2842
  const dialect = dialectFor(opts.dialect);
2793
- return lexAll(command, dialect).segments.flatMap((seg) => {
2843
+ return lexAll(command, dialect, opts.cdpathSet).segments.flatMap((seg) => {
2794
2844
  const patterns = patternArgs(seg);
2795
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn);
2845
+ const cwd = seg.cwd ?? [];
2846
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path26) => ({ path: path26, cwd }));
2796
2847
  });
2797
2848
  }
2798
2849
  function pathIn(a) {
@@ -2956,7 +3007,8 @@ function scopeFor(seg) {
2956
3007
  }
2957
3008
  return [seg.binary, ...lead].join(" ");
2958
3009
  }
2959
- function isAuto(seg) {
3010
+ function isAuto(seg, opts) {
3011
+ if (seg.binary === "cd") return !opts.cdpathSet && literalCdTarget(seg) !== void 0;
2960
3012
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
2961
3013
  if (seg.expandable) return false;
2962
3014
  if (seg.binary !== "git") return true;
@@ -3051,7 +3103,7 @@ function classifyCommand(command, opts = {}) {
3051
3103
  const trimmed = command.trim();
3052
3104
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
3053
3105
  const dialect = dialectFor(opts.dialect);
3054
- const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
3106
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
3055
3107
  if (unbalanced) {
3056
3108
  return {
3057
3109
  tier: "refuse",
@@ -3088,7 +3140,7 @@ function classifyCommand(command, opts = {}) {
3088
3140
  };
3089
3141
  }
3090
3142
  }
3091
- const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
3143
+ const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
3092
3144
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
3093
3145
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3094
3146
  return { tier: "ask", scope };
@@ -3168,11 +3220,15 @@ function expandHome(target) {
3168
3220
  if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
3169
3221
  return path2.join(path2.dirname(home), target.slice(1));
3170
3222
  }
3171
- function resolveWithin(root, target) {
3172
- const abs = path2.resolve(root, expandHome(target));
3223
+ function resolveWithin(root, target, from = root) {
3224
+ const abs = path2.resolve(from, expandHome(target));
3173
3225
  const rel = path2.relative(root, abs);
3174
3226
  return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
3175
3227
  }
3228
+ function resolveRef(root, ref) {
3229
+ const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
3230
+ return resolveWithin(root, ref.path, from);
3231
+ }
3176
3232
  function grantScopeFor(abs, kind) {
3177
3233
  if (kind === "directory") return path2.join(abs, "*");
3178
3234
  const parent = path2.dirname(abs);
@@ -3378,14 +3434,17 @@ ${refs.output.trim()}`);
3378
3434
  outsidePaths(command) {
3379
3435
  const root = this.getWorkspaceRoot();
3380
3436
  const found = /* @__PURE__ */ new Map();
3381
- for (const p of pathLikeArgs(command, { dialect: this.researchShell.dialect })) {
3382
- const { abs, inside } = resolveWithin(root, p);
3437
+ for (const ref of pathRefs(command, this.policyOptions())) {
3438
+ const { abs, inside } = resolveRef(root, ref);
3383
3439
  if (inside || isInertDevice(abs)) continue;
3384
3440
  const scope = grantScopeFor(abs, "file");
3385
3441
  if (!found.has(scope)) found.set(scope, abs);
3386
3442
  }
3387
3443
  return [...found].map(([scope, abs]) => ({ abs, scope }));
3388
3444
  }
3445
+ policyOptions() {
3446
+ return { dialect: this.researchShell.dialect, cdpathSet: !!process.env.CDPATH };
3447
+ }
3389
3448
  /**
3390
3449
  * Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
3391
3450
  * anything else asks once and is remembered, and writes are refused outright
@@ -3397,7 +3456,7 @@ ${refs.output.trim()}`);
3397
3456
  * directory the next command names.
3398
3457
  */
3399
3458
  async bash(command, signal) {
3400
- const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
3459
+ const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
3401
3460
  if (tier === "refuse") {
3402
3461
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
3403
3462
  }
@@ -3896,10 +3955,78 @@ function buildGrepArgs(pattern, opts, root) {
3896
3955
  function buildGlobArgs(pattern, root) {
3897
3956
  return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
3898
3957
  }
3899
- function buildFallbackGrepArgs(pattern, opts, root) {
3958
+ var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
3959
+ function grepRegexFromProbe(exitCode) {
3960
+ return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
3961
+ }
3962
+ function toPosixClasses(pattern) {
3963
+ const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
3964
+ let out = "";
3965
+ let i = 0;
3966
+ while (i < pattern.length) {
3967
+ const c = pattern[i];
3968
+ if (c === "\\") {
3969
+ out += pattern.slice(i, i + 2);
3970
+ i += 2;
3971
+ continue;
3972
+ }
3973
+ if (c !== "[") {
3974
+ out += c;
3975
+ i++;
3976
+ continue;
3977
+ }
3978
+ const start = i;
3979
+ i++;
3980
+ let negate = false;
3981
+ if (pattern[i] === "^") {
3982
+ negate = true;
3983
+ i++;
3984
+ }
3985
+ let body = "";
3986
+ let closeBracket = false;
3987
+ let openBracket = false;
3988
+ let dash = false;
3989
+ let closed = false;
3990
+ while (i < pattern.length) {
3991
+ const ch = pattern[i];
3992
+ if (ch === "]") {
3993
+ closed = true;
3994
+ i++;
3995
+ break;
3996
+ }
3997
+ if (ch === "[" && pattern[i + 1] === ":") {
3998
+ const end = pattern.indexOf(":]", i + 2);
3999
+ if (end !== -1) {
4000
+ body += pattern.slice(i, end + 2);
4001
+ i = end + 2;
4002
+ continue;
4003
+ }
4004
+ }
4005
+ if (ch === "\\" && i + 1 < pattern.length) {
4006
+ const e = pattern[i + 1];
4007
+ i += 2;
4008
+ if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
4009
+ else if (e === "]") closeBracket = true;
4010
+ else if (e === "[") openBracket = true;
4011
+ else if (e === "-") dash = true;
4012
+ else body += e;
4013
+ continue;
4014
+ }
4015
+ body += ch;
4016
+ i++;
4017
+ }
4018
+ if (!closed) {
4019
+ out += pattern.slice(start);
4020
+ break;
4021
+ }
4022
+ out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
4023
+ }
4024
+ return out;
4025
+ }
4026
+ function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
3900
4027
  const args = ["-r", "-n"];
3901
4028
  if (opts.literal) args.push("-F");
3902
- else args.push("-P");
4029
+ else args.push(regex === "pcre" ? "-P" : "-E");
3903
4030
  if (opts.caseInsensitive) args.push("-i");
3904
4031
  if (opts.outputMode === "files") args.push("-l");
3905
4032
  if (opts.outputMode === "count") args.push("-c");
@@ -3909,7 +4036,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
3909
4036
  }
3910
4037
  if (opts.include) args.push(`--include=${opts.include}`);
3911
4038
  for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
3912
- args.push("-e", pattern, root);
4039
+ args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
3913
4040
  return args;
3914
4041
  }
3915
4042
  function globSegmentToRegex(segment) {
@@ -5143,6 +5270,18 @@ function leftoverMarkerFiles(check) {
5143
5270
  const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
5144
5271
  return [...new Set(files)];
5145
5272
  }
5273
+ function rawDiffStates(raw) {
5274
+ const parts = raw.split("\0");
5275
+ const states = {};
5276
+ for (let i = 0; i + 1 < parts.length; i += 2) {
5277
+ const meta = parts[i].trim();
5278
+ const file = parts[i + 1];
5279
+ if (!meta.startsWith(":") || !file) continue;
5280
+ const [, mode, , blob] = meta.slice(1).split(" ");
5281
+ states[file] = `${mode}:${blob}`;
5282
+ }
5283
+ return states;
5284
+ }
5146
5285
  function settleStatus(record, status) {
5147
5286
  record.status = status;
5148
5287
  delete record.repairBase;
@@ -5242,6 +5381,8 @@ var GitWorktreeIsolation = class {
5242
5381
  adminChains = /* @__PURE__ */ new Map();
5243
5382
  waiting = [];
5244
5383
  draining = false;
5384
+ /** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
5385
+ turns = Promise.resolve();
5245
5386
  async isActive(workspaceRoot) {
5246
5387
  if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
5247
5388
  const version = await this.tryGit(void 0, ["--version"]);
@@ -5598,6 +5739,62 @@ var GitWorktreeIsolation = class {
5598
5739
  return !linked.has(rel) && !linked.has(rel.split("/")[0]);
5599
5740
  });
5600
5741
  }
5742
+ async findInHead(run) {
5743
+ const found = [];
5744
+ for (const record of Object.values(run.tasks)) {
5745
+ if (record.status !== "merged" || record.inHead) continue;
5746
+ if (!await this.headContains(run, record)) continue;
5747
+ record.inHead = true;
5748
+ found.push(record.taskId);
5749
+ }
5750
+ return found;
5751
+ }
5752
+ async headContains(run, record) {
5753
+ for (const repo of run.repos) {
5754
+ const entry = record.repos[repo.path];
5755
+ if (!entry?.changed) continue;
5756
+ const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
5757
+ if (!merged.ok) return false;
5758
+ }
5759
+ return true;
5760
+ }
5761
+ async snapshotTree(workspaceRoot, exclude) {
5762
+ const scan = await this.scanGroup(workspaceRoot);
5763
+ if ("refused" in scan) return null;
5764
+ const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
5765
+ if (repos.length === 0) return null;
5766
+ const snapshot = {};
5767
+ for (const repoPath of repos) {
5768
+ const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
5769
+ if (states) snapshot[repoPath] = states;
5770
+ }
5771
+ return snapshot;
5772
+ }
5773
+ async changedSince(workspaceRoot, snapshot) {
5774
+ const changed = [];
5775
+ for (const [repoPath, before] of Object.entries(snapshot)) {
5776
+ const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
5777
+ if (!now) continue;
5778
+ for (const [file, state] of Object.entries(now)) {
5779
+ if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
5780
+ }
5781
+ }
5782
+ return changed.sort();
5783
+ }
5784
+ /**
5785
+ * Each tracked file that differs from HEAD, with what it holds. `git stash
5786
+ * create` records the working tree as a commit without touching the tree,
5787
+ * the index or the stash, so a file changed again is told apart from one
5788
+ * left as it was. Null when git cannot tell — a merge in progress, say.
5789
+ */
5790
+ async trackedStates(root) {
5791
+ const stash = await this.tryGit(root, ["stash", "create"]);
5792
+ if (!stash.ok) return null;
5793
+ const commit = stash.stdout.trim();
5794
+ if (!commit) return {};
5795
+ const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
5796
+ return diff.ok ? rawDiffStates(diff.stdout) : null;
5797
+ }
5601
5798
  /**
5602
5799
  * One section per repo with changes. A repo below the workspace root gets a
5603
5800
  * header and paths prefixed with its own, so the whole reads as one patch
@@ -5615,7 +5812,10 @@ var GitWorktreeIsolation = class {
5615
5812
  }
5616
5813
  return diff;
5617
5814
  }
5618
- async mergeIntoCheckedOut(run) {
5815
+ mergeIntoCheckedOut(run) {
5816
+ return this.inTurn(() => this.mergeAll(run));
5817
+ }
5818
+ async mergeAll(run) {
5619
5819
  const partial = await this.settleLanding(run);
5620
5820
  if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
5621
5821
  const repos = await this.reposWithWork(run);
@@ -5749,7 +5949,7 @@ var GitWorktreeIsolation = class {
5749
5949
  if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
5750
5950
  });
5751
5951
  const [entry] = this.waiting.splice(pick, 1);
5752
- entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
5952
+ entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
5753
5953
  }
5754
5954
  } finally {
5755
5955
  this.draining = false;
@@ -5801,6 +6001,10 @@ var GitWorktreeIsolation = class {
5801
6001
  return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
5802
6002
  }
5803
6003
  }
6004
+ for (const repo of changed) {
6005
+ const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
6006
+ if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
6007
+ }
5804
6008
  delete run.landing;
5805
6009
  settleStatus(record, "merged");
5806
6010
  delete record.conflictRepo;
@@ -6075,6 +6279,11 @@ var GitWorktreeIsolation = class {
6075
6279
  async abortMerge(cwd) {
6076
6280
  if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
6077
6281
  }
6282
+ inTurn(fn) {
6283
+ const turn = this.turns.then(fn, fn);
6284
+ this.turns = turn.catch(() => void 0);
6285
+ return turn;
6286
+ }
6078
6287
  admin(repoKey, fn) {
6079
6288
  const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
6080
6289
  const next = prior.then(fn, fn);
@@ -6491,8 +6700,22 @@ function researchPhaseBlock(harnessMode) {
6491
6700
  "- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
6492
6701
  ].join("\n");
6493
6702
  }
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.";
6703
+ 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
6704
  var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
6705
+ function opsTaskSection(conversation) {
6706
+ return [
6707
+ "",
6708
+ "CHANGE AND OPS TASKS:",
6709
+ "- 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?",
6710
+ '- A change task (the default) edits repository files. Omit "ops".',
6711
+ `- 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.`,
6712
+ `- 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.`,
6713
+ '- 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.',
6714
+ "- 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.",
6715
+ "- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
6716
+ ...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.`] : []
6717
+ ];
6718
+ }
6496
6719
  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
6720
  function repoGroupSection(isolated) {
6498
6721
  if (!isolated) return [];
@@ -6572,6 +6795,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
6572
6795
  ' "taskMode": "' + modeExamples + '",',
6573
6796
  ' "autonomy": "AFK|HITL",',
6574
6797
  ' "sliceType": "AFK|HITL",',
6798
+ ' "ops": true,',
6575
6799
  ' "userStoriesCovered": ["user story text"],',
6576
6800
  ' "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
6801
  " }",
@@ -6597,6 +6821,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
6597
6821
  SHARED_APPEND_FILE_RULE,
6598
6822
  ...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
6599
6823
  ...repoGroupSection(variant.isolatedExecution),
6824
+ ...opsTaskSection(true),
6600
6825
  "",
6601
6826
  "RULES:",
6602
6827
  "- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
@@ -6672,6 +6897,7 @@ function corePlannerPrompt(isolatedExecution) {
6672
6897
  ' "taskMode": "{{MODE_EXAMPLES}}",',
6673
6898
  ' "autonomy": "AFK|HITL",',
6674
6899
  ' "sliceType": "AFK|HITL",',
6900
+ ' "ops": true,',
6675
6901
  ' "userStoriesCovered": ["user story text"],',
6676
6902
  ' "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
6903
  " }",
@@ -6699,6 +6925,7 @@ function corePlannerPrompt(isolatedExecution) {
6699
6925
  SHARED_APPEND_FILE_RULE,
6700
6926
  isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
6701
6927
  ...repoGroupSection(isolatedExecution),
6928
+ ...opsTaskSection(false),
6702
6929
  "",
6703
6930
  "RULES:",
6704
6931
  '- Mark as type "ai" any task the coding assistant can do autonomously.',
@@ -7500,14 +7727,15 @@ function nonPromptingFs(fs16) {
7500
7727
  listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
7501
7728
  bash: async (command, signal) => {
7502
7729
  const dialect = resolveResearchShell().dialect;
7503
- const { tier, reason } = classifyCommand(command, { dialect });
7730
+ const policy = { dialect, cdpathSet: !!process.env.CDPATH };
7731
+ const { tier, reason } = classifyCommand(command, policy);
7504
7732
  if (tier !== "auto") {
7505
7733
  return refused2(
7506
7734
  tier === "refuse" ? `Command refused: ${reason}` : `"${command}" needs user approval, and research agents run in the background where nothing can be approved. Use the read-only research tools, and note in your digest that this command would answer the question.`
7507
7735
  );
7508
7736
  }
7509
- const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
7510
- if (escaping) return outsideRefusal(escaping);
7737
+ const escaping = pathRefs(command, policy).find((ref) => !resolveRef(fs16.getWorkspaceRoot(), ref).inside);
7738
+ if (escaping) return outsideRefusal(escaping.path);
7511
7739
  return fs16.bash(command, signal);
7512
7740
  }
7513
7741
  };
@@ -8702,6 +8930,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
8702
8930
  lines.join("\n") + footer + runnerNote
8703
8931
  ].join("\n");
8704
8932
  }
8933
+ function renderPreviousAttempt(output) {
8934
+ const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
8935
+ return [
8936
+ "## Previous attempt",
8937
+ "",
8938
+ "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.",
8939
+ "",
8940
+ "The end of its output:",
8941
+ tail
8942
+ ].join("\n");
8943
+ }
8705
8944
  function renderCompletionMarker(task) {
8706
8945
  return `
8707
8946
 
@@ -8753,6 +8992,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
8753
8992
  const flat = flattenTasks(allTasks);
8754
8993
  const outputs = collectDirectDependencyOutputs(task, flat);
8755
8994
  if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
8995
+ if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
8756
8996
  if (opts?.tddEnabled) {
8757
8997
  blocks.push(renderTddInstruction());
8758
8998
  }
@@ -8765,9 +9005,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
8765
9005
 
8766
9006
  ${basePrompt}${marker2}`;
8767
9007
  }
8768
- function composeContinuationPrompt(task, message) {
9008
+ function composeContinuationPrompt(task, message, opts = {}) {
8769
9009
  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."
9010
+ 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
9011
  ];
8772
9012
  if (isHitlTask(task)) {
8773
9013
  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}.`);
@@ -8811,6 +9051,12 @@ var VerdictEngine = class {
8811
9051
  idleSince = /* @__PURE__ */ new Map();
8812
9052
  /** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
8813
9053
  idlePaused = /* @__PURE__ */ new Set();
9054
+ /**
9055
+ * Open tool approvals per structured task. They arrive mid-turn and leave the
9056
+ * status alone (ADR-0018, W1), but a task waiting on one is waiting on the
9057
+ * user, so it is not idle either.
9058
+ */
9059
+ openApprovals = /* @__PURE__ */ new Map();
8814
9060
  onVerdict(listener) {
8815
9061
  this.listeners.push(listener);
8816
9062
  }
@@ -8839,7 +9085,7 @@ var VerdictEngine = class {
8839
9085
  }
8840
9086
  /** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
8841
9087
  touchIdle(taskId, gen) {
8842
- if (this.idlePaused.has(taskId)) return;
9088
+ if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
8843
9089
  const existing = this.idleTimers.get(taskId);
8844
9090
  if (existing) clearTimeout(existing);
8845
9091
  if (this.idleSince.get(taskId)) {
@@ -8921,7 +9167,10 @@ ${line}
8921
9167
  });
8922
9168
  if (isStructuredSession(session)) {
8923
9169
  session.onEvent((event) => {
8924
- if (event.type === "turn_start" && this.generations.get(task.id) === gen) this.resumeIdle(task.id);
9170
+ if (this.generations.get(task.id) !== gen) return;
9171
+ if (event.type === "turn_start") this.resumeIdle(task.id);
9172
+ else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
9173
+ else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
8925
9174
  });
8926
9175
  }
8927
9176
  session.onExit((exitCode) => {
@@ -8932,6 +9181,18 @@ ${line}
8932
9181
  for (const l of this.listeners) l(task.id, verdict);
8933
9182
  });
8934
9183
  }
9184
+ approvalOpened(taskId, approvalId) {
9185
+ const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
9186
+ open.add(approvalId);
9187
+ this.openApprovals.set(taskId, open);
9188
+ this.clearIdle(taskId);
9189
+ }
9190
+ approvalClosed(taskId, approvalId, gen) {
9191
+ const open = this.openApprovals.get(taskId);
9192
+ if (!open?.delete(approvalId) || open.size) return;
9193
+ this.openApprovals.delete(taskId);
9194
+ this.touchIdle(taskId, gen);
9195
+ }
8935
9196
  /** Scan only the new text plus the unmatched carry, so a long run stays linear. */
8936
9197
  scanCheckpoints(taskId, session, text) {
8937
9198
  const scan = (this.checkpointCarry.get(taskId) ?? "") + text;
@@ -8953,6 +9214,7 @@ ${line}
8953
9214
  this.checkpointCarry.delete(taskId);
8954
9215
  this.pausedSessions.delete(taskId);
8955
9216
  this.idlePaused.delete(taskId);
9217
+ this.openApprovals.delete(taskId);
8956
9218
  this.clearIdle(taskId);
8957
9219
  }
8958
9220
  /** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
@@ -8990,6 +9252,7 @@ ${line}
8990
9252
  this.idleTimers.clear();
8991
9253
  this.idleSince.clear();
8992
9254
  this.idlePaused.clear();
9255
+ this.openApprovals.clear();
8993
9256
  this.generations.clear();
8994
9257
  }
8995
9258
  decide(task, exitCode) {
@@ -9507,6 +9770,8 @@ var PlanStore = class {
9507
9770
  _planTasks = [];
9508
9771
  _allTasks = [];
9509
9772
  _taskMap = /* @__PURE__ */ new Map();
9773
+ /** Task id → the top-level task it hangs under, itself for a top-level task. */
9774
+ _rootMap = /* @__PURE__ */ new Map();
9510
9775
  _planRunners = ["claude-code"];
9511
9776
  _onMutate = null;
9512
9777
  _executionLog = [];
@@ -9544,6 +9809,14 @@ var PlanStore = class {
9544
9809
  get(taskId) {
9545
9810
  return this._taskMap.get(taskId);
9546
9811
  }
9812
+ /**
9813
+ * Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
9814
+ * can be one; a subtask runs with the top-level task it hangs under.
9815
+ */
9816
+ isOps(taskId) {
9817
+ const root = this._rootMap.get(taskId);
9818
+ return root?.type === "ai" && root.ops === true;
9819
+ }
9547
9820
  /** A copy of the task tree, detached from the store: later status changes do not reach it. */
9548
9821
  snapshot() {
9549
9822
  return cloneTasks(this._planTasks);
@@ -9638,6 +9911,7 @@ ${taskB.description}`,
9638
9911
  assignedRunner: taskA.assignedRunner,
9639
9912
  assignedModel: taskA.assignedModel,
9640
9913
  taskMode: taskA.taskMode,
9914
+ ops: taskA.ops === true && taskB.ops === true,
9641
9915
  order: Math.min(taskA.order, taskB.order)
9642
9916
  });
9643
9917
  const mergedId = merged.id;
@@ -9667,7 +9941,8 @@ ${taskB.description}`,
9667
9941
  taskMode: spec.taskMode ?? original.taskMode,
9668
9942
  autonomy: spec.autonomy ?? original.autonomy,
9669
9943
  sliceType: spec.sliceType ?? original.sliceType,
9670
- userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
9944
+ userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
9945
+ ops: spec.ops ?? original.ops
9671
9946
  }));
9672
9947
  });
9673
9948
  const tailId = finalized[finalized.length - 1].id;
@@ -9721,8 +9996,14 @@ ${taskB.description}`,
9721
9996
  this.setStatus(id, "pending");
9722
9997
  task.verdict = void 0;
9723
9998
  task.outputSummary = void 0;
9999
+ delete task.forcedPastGate;
9724
10000
  }
9725
10001
  }
10002
+ /** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
10003
+ setForcedPastGate(id, dependencies) {
10004
+ const task = this._taskMap.get(id);
10005
+ if (task) task.forcedPastGate = dependencies;
10006
+ }
9726
10007
  /** A reason outlives nothing: any status but `awaiting_user` drops it. */
9727
10008
  setStatus(id, status) {
9728
10009
  const task = this._taskMap.get(id);
@@ -9805,6 +10086,10 @@ ${taskB.description}`,
9805
10086
  for (const task of this._allTasks) {
9806
10087
  this._taskMap.set(task.id, task);
9807
10088
  }
10089
+ this._rootMap.clear();
10090
+ for (const root of this._planTasks) {
10091
+ for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
10092
+ }
9808
10093
  }
9809
10094
  validateAssignedRunners() {
9810
10095
  for (const task of this._allTasks) {
@@ -9858,9 +10143,9 @@ var IsolationRunController = class {
9858
10143
  /** Copied paths already reported for the current run: every task gets the same copies. */
9859
10144
  reportedCopies = /* @__PURE__ */ new Set();
9860
10145
  /**
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.
10146
+ * How the open run executes; null while no run is open, `undecided` until
10147
+ * its first change task asks. A run is one Execute-Plan or one manual task
10148
+ * run, from its start until it settles or is stopped.
9864
10149
  */
9865
10150
  mode = null;
9866
10151
  /** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
@@ -9884,6 +10169,10 @@ var IsolationRunController = class {
9884
10169
  get isOpen() {
9885
10170
  return this.mode !== null;
9886
10171
  }
10172
+ /** The open run has decided whether it isolates. */
10173
+ get decided() {
10174
+ return this.mode === "isolated" || this.mode === "shared";
10175
+ }
9887
10176
  /** The open run executes in worktrees. */
9888
10177
  get isolating() {
9889
10178
  return this.mode === "isolated" && this.run !== null;
@@ -9940,13 +10229,22 @@ var IsolationRunController = class {
9940
10229
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
9941
10230
  }
9942
10231
  }
10232
+ /** Open a run if none is, leaving whether it isolates to its first change task. */
10233
+ open() {
10234
+ if (this.mode) return;
10235
+ this.mode = "undecided";
10236
+ this.listener.opened();
10237
+ }
9943
10238
  /**
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.
10239
+ * Decide once per run, as its first change task starts, whether it executes
10240
+ * in worktrees (ADR-0020): an ops task needs none, so a run of only ops
10241
+ * tasks never asks. `resume` is what a dirty tree parks until the user
10242
+ * chooses how to go on. Resolves false while the run waits on that choice.
9947
10243
  */
9948
- open(resume) {
9949
- if (this.mode) return Promise.resolve(true);
10244
+ decide(resume) {
10245
+ this.open();
10246
+ if (this.decided) return Promise.resolve(true);
10247
+ if (this.blockedStart) return Promise.resolve(false);
9950
10248
  this.opening ??= this.activate(resume).finally(() => {
9951
10249
  this.opening = null;
9952
10250
  });
@@ -9961,8 +10259,8 @@ var IsolationRunController = class {
9961
10259
  * the safe rule then.
9962
10260
  */
9963
10261
  async plannerLayout() {
9964
- if (this.mode) return this.isolating && this.run ? layoutOf(this.run) : false;
9965
- const decision = await this.decide(this.workspaceRoot());
10262
+ if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
10263
+ const decision = await this.assess(this.workspaceRoot());
9966
10264
  return decision.mode === "isolated" ? decision.layout : false;
9967
10265
  }
9968
10266
  /**
@@ -9997,7 +10295,7 @@ var IsolationRunController = class {
9997
10295
  const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
9998
10296
  return { cwd: cwd2, worktree: true };
9999
10297
  }
10000
- if (!this.isolating || !this.run) {
10298
+ if (opts.ops || !this.isolating || !this.run) {
10001
10299
  await this.release(task.id, { keep: false });
10002
10300
  return { cwd: this.workspaceRoot(), worktree: false };
10003
10301
  }
@@ -10042,18 +10340,71 @@ var IsolationRunController = class {
10042
10340
  }
10043
10341
  this.listener.changed();
10044
10342
  }
10045
- /** Close the open run. An isolated one hands its integration branch over for review. */
10343
+ /**
10344
+ * Close the open run. An isolated one hands its integration branch over for
10345
+ * review — unless the user merged everything it landed at merge gates
10346
+ * already, which leaves nothing to hand over, so it is cleared up as a Merge
10347
+ * all would have.
10348
+ */
10046
10349
  async close() {
10047
10350
  const mode = this.mode;
10048
10351
  this.mode = null;
10049
- if (mode !== "isolated" || !this.run) return;
10352
+ const run = this.run;
10353
+ if (mode !== "isolated" || !run) return;
10354
+ await this.refreshInHead();
10355
+ const records = Object.values(run.tasks);
10356
+ if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
10357
+ this.tell("info", "Everything this run landed is merged into your branch already.");
10358
+ await this.clearMerged(run);
10359
+ return;
10360
+ }
10050
10361
  try {
10051
- this.listener.handoff(await this.isolation.handoff(this.run));
10362
+ this.listener.handoff(await this.isolation.handoff(run));
10052
10363
  } catch (err) {
10053
10364
  this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
10054
10365
  }
10055
10366
  this.listener.changed();
10056
10367
  }
10368
+ /**
10369
+ * Whether a task's landed work still waits to be merged into the user's
10370
+ * branch — what holds an ops or user task at its merge gate (ADR-0020). A
10371
+ * task with no record never landed anything to wait for.
10372
+ */
10373
+ awaitsMerge(taskId) {
10374
+ const record = this.run?.tasks[taskId];
10375
+ return !!record && record.status === "merged" && !record.inHead;
10376
+ }
10377
+ /**
10378
+ * Look again for landed work the user has merged — by Merge all, or by hand
10379
+ * with git. Resolves true when some was found.
10380
+ */
10381
+ async refreshInHead() {
10382
+ const run = this.run;
10383
+ if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
10384
+ const found = await this.isolation.findInHead(run).catch(() => []);
10385
+ if (found.length > 0) this.listener.changed();
10386
+ return found.length > 0;
10387
+ }
10388
+ /**
10389
+ * The tracked state of the workspace as an ops task starts, for
10390
+ * {@link filesChangedSince} (ADR-0020). Null where the check does not run:
10391
+ * a run that shares the workspace root, where every task's edits land in
10392
+ * it, or a workspace git cannot isolate at all.
10393
+ */
10394
+ async snapshotWorkspace() {
10395
+ if (this.mode === "shared") return null;
10396
+ const root = this.workspaceRoot();
10397
+ if (!this.isolating) {
10398
+ const availability = await this.isolation.isActive(root).catch(() => null);
10399
+ if (!availability || !availability.active && availability.reason !== "dirty") return null;
10400
+ }
10401
+ return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
10402
+ }
10403
+ /** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
10404
+ async filesChangedSince(snapshot) {
10405
+ if (!snapshot || this.mode === "shared") return [];
10406
+ return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
10407
+ }
10057
10408
  /**
10058
10409
  * A stop or a plan load: the open run ends without a handoff and a parked
10059
10410
  * start is dropped. `keepOpen` spares a run the scheduler is still driving.
@@ -10084,19 +10435,23 @@ var IsolationRunController = class {
10084
10435
  }
10085
10436
  /**
10086
10437
  * "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.
10438
+ * checked out, in every repo or none. During a run it merges what has
10439
+ * landed so far and the run goes on (ADR-0020): its branches stay for the
10440
+ * tasks still to land. Once a settled run merged everything, it has nothing
10441
+ * left to hand over, so it is cleared up and forgotten; a branch the user's
10442
+ * HEAD somehow does not contain stays for the next run's sweep.
10090
10443
  */
10091
10444
  async merge() {
10092
10445
  const run = this.requireRun();
10446
+ const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
10093
10447
  const result = await this.isolation.mergeIntoCheckedOut(run);
10094
10448
  const branch = integrationBranchNameOf(run);
10095
10449
  const group = run.repos.some((r) => r.path !== SELF_REPO);
10096
- const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
10097
10450
  const { level, message } = describeMergeResult(result, branch, group, repaired);
10098
10451
  this.tell(level, message);
10099
- if (result.outcome === "merged") await this.clearMerged(run);
10452
+ if (result.outcome !== "merged") return result;
10453
+ await this.refreshInHead();
10454
+ if (!this.isOpen) await this.clearMerged(run);
10100
10455
  return result;
10101
10456
  }
10102
10457
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
@@ -10128,7 +10483,7 @@ var IsolationRunController = class {
10128
10483
  this.resolvers = {};
10129
10484
  this.listener.changed();
10130
10485
  }
10131
- async decide(root) {
10486
+ async assess(root) {
10132
10487
  const availability = await this.isolation.isActive(root);
10133
10488
  const continued = this.continuableRun(root);
10134
10489
  const continuing = continued !== null;
@@ -10143,7 +10498,7 @@ var IsolationRunController = class {
10143
10498
  }
10144
10499
  async activate(resume) {
10145
10500
  const root = this.workspaceRoot();
10146
- const decision = await this.decide(root);
10501
+ const decision = await this.assess(root);
10147
10502
  if (decision.mode === "blocked") {
10148
10503
  this.blockedStart = resume;
10149
10504
  this.blockedRepos = decision.repos;
@@ -10170,8 +10525,8 @@ var IsolationRunController = class {
10170
10525
  return true;
10171
10526
  }
10172
10527
  begin(mode) {
10528
+ this.open();
10173
10529
  this.mode = mode;
10174
- this.listener.opened();
10175
10530
  }
10176
10531
  /** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
10177
10532
  async sweep() {
@@ -10518,8 +10873,9 @@ var MessageQueue = class {
10518
10873
  // src/services/readiness.ts
10519
10874
  function selectReadyTasks(input) {
10520
10875
  const { store, onHold, runs, active, maxParallel } = input;
10521
- if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
10876
+ if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
10522
10877
  const availableSlots = maxParallel - active;
10878
+ const gated = [];
10523
10879
  const candidates = store.allTasks.filter((t) => {
10524
10880
  if (t.status !== "pending" && t.status !== "approved") return false;
10525
10881
  if (t.type === "user") return false;
@@ -10527,13 +10883,21 @@ function selectReadyTasks(input) {
10527
10883
  if (onHold.has(t.id)) return false;
10528
10884
  if (isBlocked(t, store)) return false;
10529
10885
  if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
10886
+ if (store.isOps(t.id)) {
10887
+ if (input.merging) return false;
10888
+ if (mergeGate(t, store, runs).length > 0) {
10889
+ gated.push(t);
10890
+ return false;
10891
+ }
10892
+ }
10530
10893
  return true;
10531
10894
  });
10532
- const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, store, onHold, runs) }));
10895
+ const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
10533
10896
  return {
10534
10897
  ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
10535
10898
  candidateCount: candidates.length,
10536
- excluded
10899
+ excluded,
10900
+ gated
10537
10901
  };
10538
10902
  }
10539
10903
  function isBlocked(task, store) {
@@ -10546,12 +10910,19 @@ function dependencyMet(depId, store, runs) {
10546
10910
  const record = runs.openRecord(depId);
10547
10911
  return !record || record.status === "merged";
10548
10912
  }
10549
- function exclusionReasons(task, store, onHold, runs) {
10913
+ function mergeGate(task, store, runs) {
10914
+ if (task.type !== "user" && !store.isOps(task.id)) return [];
10915
+ return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
10916
+ }
10917
+ function exclusionReasons(task, input) {
10918
+ const { store, onHold, runs } = input;
10550
10919
  const reasons = [];
10551
10920
  if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
10552
10921
  if (onHold.has(task.id)) reasons.push("on-hold");
10553
10922
  if (isBlocked(task, store)) reasons.push("blocked");
10554
10923
  if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
10924
+ if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
10925
+ if (input.merging && store.isOps(task.id)) reasons.push("merging");
10555
10926
  return reasons;
10556
10927
  }
10557
10928
 
@@ -12545,9 +12916,502 @@ ${item.text}` : item.text });
12545
12916
 
12546
12917
  // src/services/harness/OpenCodeAdapter.ts
12547
12918
  import { randomBytes as randomBytes2 } from "crypto";
12919
+
12920
+ // src/services/harness/OpenCodeV2.ts
12921
+ var STREAM_CONNECT_TIMEOUT_MS = 5e3;
12922
+ var ACTIVE_POLL_INTERVAL_MS = 1e3;
12923
+ var IDLE_POLLS_BEFORE_START = 3;
12924
+ var PLANNER_RULES = [
12925
+ { action: "question", resource: "*", effect: "deny" },
12926
+ { action: "edit", resource: "*", effect: "deny" }
12927
+ ];
12928
+ var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
12929
+ var AUTO_APPROVALS = "auto";
12930
+ function newTurnState() {
12931
+ return { seen: /* @__PURE__ */ new Set(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
12932
+ }
12933
+ function splitModelId(id) {
12934
+ const slash = id.indexOf("/");
12935
+ if (slash <= 0 || slash === id.length - 1) return null;
12936
+ return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
12937
+ }
12938
+ function delay(ms, signal) {
12939
+ return new Promise((resolve7) => {
12940
+ const timer = setTimeout(resolve7, ms);
12941
+ timer.unref?.();
12942
+ signal?.addEventListener("abort", () => {
12943
+ clearTimeout(timer);
12944
+ resolve7();
12945
+ }, { once: true });
12946
+ });
12947
+ }
12948
+ function contentText(content) {
12949
+ return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
12950
+ }
12951
+ function subagentDigest(output) {
12952
+ const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
12953
+ return inner ? inner[1] : output;
12954
+ }
12955
+ function permissionReply(decision) {
12956
+ if (decision.decision === "allow") return { decision: "once" };
12957
+ if (decision.decision === "allowForTask") return { decision: "always" };
12958
+ const note = decision.note?.trim();
12959
+ return note ? { decision: "reject", message: note } : { decision: "reject" };
12960
+ }
12961
+ var OpenCodeV2 = class {
12962
+ constructor(host, opts, role) {
12963
+ this.host = host;
12964
+ this.opts = opts;
12965
+ this.role = role;
12966
+ this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
12967
+ const split = opts.model ? splitModelId(opts.model) : null;
12968
+ const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
12969
+ if (split) this.model = { ...split, ...variant ? { variant } : {} };
12970
+ }
12971
+ host;
12972
+ opts;
12973
+ role;
12974
+ sessionId = null;
12975
+ model = null;
12976
+ agent;
12977
+ turn = null;
12978
+ turnHasText = false;
12979
+ interruptRequested = false;
12980
+ /** A task's requests waiting for an answer, by request id, with the session that asked. */
12981
+ openPermissions = /* @__PURE__ */ new Map();
12982
+ nativeSessionId() {
12983
+ return this.sessionId;
12984
+ }
12985
+ async start() {
12986
+ const resume = this.opts.resumeSessionId;
12987
+ if (resume) {
12988
+ const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
12989
+ if (existing?.data?.id) {
12990
+ this.sessionId = existing.data.id;
12991
+ if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
12992
+ await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
12993
+ await this.applyInstructions();
12994
+ return;
12995
+ }
12996
+ if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
12997
+ }
12998
+ const created = await this.host.json("POST", "/api/session", {
12999
+ agent: this.agent,
13000
+ ...this.model ? { model: this.model } : {},
13001
+ permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
13002
+ });
13003
+ if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
13004
+ this.sessionId = created.data.id;
13005
+ await this.applyInstructions();
13006
+ }
13007
+ /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
13008
+ async applyInstructions() {
13009
+ if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
13010
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
13011
+ if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
13012
+ }
13013
+ async send(message, onEvent, signal, onActivity) {
13014
+ if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
13015
+ if (this.host.isExited()) {
13016
+ onEvent({ type: "error", message: this.host.exitMessage() });
13017
+ return;
13018
+ }
13019
+ const state = newTurnState();
13020
+ this.turnHasText = false;
13021
+ this.interruptRequested = false;
13022
+ let markDone = () => {
13023
+ };
13024
+ const ended = new Promise((resolve7) => {
13025
+ markDone = resolve7;
13026
+ });
13027
+ const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
13028
+ turn.done = true;
13029
+ markDone();
13030
+ } };
13031
+ this.turn = turn;
13032
+ const closeStream = await this.openStream(state, turn, onEvent, onActivity);
13033
+ const poll = new AbortController();
13034
+ try {
13035
+ try {
13036
+ await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
13037
+ } catch (err) {
13038
+ if (signal?.aborted) return;
13039
+ onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
13040
+ return;
13041
+ }
13042
+ void this.pollActive(turn, poll.signal);
13043
+ const aborted = new Promise((resolve7) => {
13044
+ if (signal?.aborted) resolve7();
13045
+ signal?.addEventListener("abort", () => resolve7(), { once: true });
13046
+ });
13047
+ await Promise.race([turn.ended, aborted, this.host.processEnded]);
13048
+ if (signal?.aborted) return;
13049
+ if (!turn.done) {
13050
+ onEvent({ type: "error", message: this.host.exitMessage() });
13051
+ return;
13052
+ }
13053
+ await this.readBack(state, onEvent);
13054
+ for (const id of [...this.openPermissions.keys()]) {
13055
+ this.openPermissions.delete(id);
13056
+ onEvent({ type: "permission_cancelled", id });
13057
+ }
13058
+ if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
13059
+ else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
13060
+ else onEvent({ type: "turn_end" });
13061
+ } finally {
13062
+ this.turn = null;
13063
+ poll.abort();
13064
+ await closeStream();
13065
+ }
13066
+ }
13067
+ /**
13068
+ * The stream can drop the end frame while still delivering the rest, so the
13069
+ * turn also asks which sessions are running. A session that is not, after
13070
+ * this turn's work was seen, is over; one never seen working after a few
13071
+ * polls had already finished before the stream connected.
13072
+ */
13073
+ async pollActive(turn, signal) {
13074
+ let idlePolls = 0;
13075
+ while (!turn.done && !signal.aborted) {
13076
+ await delay(ACTIVE_POLL_INTERVAL_MS, signal);
13077
+ if (turn.done || signal.aborted) return;
13078
+ const active = await this.host.json("GET", "/api/session/active").catch(() => null);
13079
+ if (!active?.data) continue;
13080
+ if (this.sessionId && this.sessionId in active.data) {
13081
+ turn.live = true;
13082
+ idlePolls = 0;
13083
+ continue;
13084
+ }
13085
+ idlePolls++;
13086
+ if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
13087
+ const messages = await this.messages();
13088
+ const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
13089
+ if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
13090
+ turn.outcome ??= "succeeded";
13091
+ if (!turn.done) turn.finish();
13092
+ }
13093
+ }
13094
+ }
13095
+ async messages() {
13096
+ const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
13097
+ return Array.isArray(response?.data) ? response.data : null;
13098
+ }
13099
+ /**
13100
+ * Whatever of the turn's own replies the stream missed. A message whose text
13101
+ * the stream delivered adds nothing here; one that lost its text frames still
13102
+ * reaches the plain-text channel, where the done marker is looked for.
13103
+ */
13104
+ async readBack(state, onEvent) {
13105
+ const messages = await this.messages();
13106
+ if (!messages) return;
13107
+ const latestUser = messages.findIndex((m) => m.type === "user");
13108
+ const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
13109
+ for (const message of [...own].reverse()) {
13110
+ if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
13111
+ (message.content ?? []).forEach((part, index) => {
13112
+ if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
13113
+ });
13114
+ }
13115
+ }
13116
+ /**
13117
+ * Abort the running turn, keeping the server and its session. OpenCode
13118
+ * acknowledges with `session.execution.interrupted`, which also ends the turn.
13119
+ */
13120
+ async interrupt(timeoutMs) {
13121
+ if (!this.sessionId || this.host.isExited()) return false;
13122
+ const turn = this.turn;
13123
+ this.interruptRequested = true;
13124
+ const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
13125
+ if (!response) return false;
13126
+ if (!turn || turn.done) return true;
13127
+ let timer;
13128
+ const timedOut = new Promise((resolve7) => {
13129
+ timer = setTimeout(() => resolve7(false), timeoutMs);
13130
+ timer.unref?.();
13131
+ });
13132
+ const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
13133
+ clearTimeout(timer);
13134
+ return acknowledged;
13135
+ }
13136
+ answerPermission(id, decision) {
13137
+ const sessionId = this.openPermissions.get(id);
13138
+ if (sessionId === void 0) return false;
13139
+ this.openPermissions.delete(id);
13140
+ void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
13141
+ });
13142
+ return true;
13143
+ }
13144
+ async replyPermission(id, sessionId, reply) {
13145
+ await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
13146
+ }
13147
+ async openStream(state, turn, onEvent, onActivity) {
13148
+ const streamAbort = new AbortController();
13149
+ let connected = () => {
13150
+ };
13151
+ const ready = new Promise((resolve7) => {
13152
+ connected = resolve7;
13153
+ });
13154
+ const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
13155
+ await Promise.race([ready, new Promise((r) => {
13156
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
13157
+ t.unref?.();
13158
+ })]);
13159
+ return async () => {
13160
+ streamAbort.abort();
13161
+ await live.catch(() => {
13162
+ });
13163
+ };
13164
+ }
13165
+ async streamEvents(signal, onFrame, onConnected, onActivity) {
13166
+ const response = await this.host.openEvents(signal).catch(() => null);
13167
+ const body = response?.body;
13168
+ if (!body) {
13169
+ onConnected();
13170
+ return;
13171
+ }
13172
+ onConnected();
13173
+ const reader = body.getReader();
13174
+ const decoder = new TextDecoder();
13175
+ let buffer = "";
13176
+ for (; ; ) {
13177
+ const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
13178
+ if (done) return;
13179
+ onActivity?.();
13180
+ buffer += decoder.decode(value, { stream: true });
13181
+ let newline;
13182
+ while ((newline = buffer.indexOf("\n")) >= 0) {
13183
+ const line = buffer.slice(0, newline).trim();
13184
+ buffer = buffer.slice(newline + 1);
13185
+ if (!line.startsWith("data:")) continue;
13186
+ try {
13187
+ onFrame(JSON.parse(line.slice(5).trim()));
13188
+ } catch {
13189
+ }
13190
+ }
13191
+ }
13192
+ }
13193
+ /**
13194
+ * One frame. Only this session and its children are followed: the stream is
13195
+ * the whole server's, and another client's session is none of this turn's business.
13196
+ */
13197
+ onFrame(frame, state, turn, onEvent) {
13198
+ const data = frame.data;
13199
+ if (!data || !frame.type) return;
13200
+ if (frame.type === "session.created") {
13201
+ if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
13202
+ return;
13203
+ }
13204
+ const session = data.sessionID;
13205
+ if (!session) return;
13206
+ const isChild = session !== this.sessionId;
13207
+ if (isChild && !state.children.has(session)) return;
13208
+ if (!isChild) {
13209
+ if (frame.type === "session.execution.started") {
13210
+ turn.live = true;
13211
+ return;
13212
+ }
13213
+ if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
13214
+ if (!turn.live || turn.done) return;
13215
+ turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
13216
+ if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
13217
+ turn.finish();
13218
+ return;
13219
+ }
13220
+ }
13221
+ if (frame.type === "permission.asked") {
13222
+ if (this.role === "task") this.askPermission(data, session, state, onEvent);
13223
+ else this.denyPermission(data, session, state, onEvent);
13224
+ return;
13225
+ }
13226
+ if (frame.type === "permission.replied") {
13227
+ const id = data.requestID;
13228
+ if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
13229
+ return;
13230
+ }
13231
+ let subagentId;
13232
+ if (isChild) {
13233
+ const owner = state.children.get(session);
13234
+ if (!owner) {
13235
+ state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
13236
+ return;
13237
+ }
13238
+ subagentId = owner;
13239
+ }
13240
+ this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
13241
+ }
13242
+ onContentFrame(frame, data, state, turn, onEvent, subagentId) {
13243
+ const message = data.assistantMessageID ?? "";
13244
+ switch (frame.type) {
13245
+ case "session.step.started":
13246
+ if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
13247
+ return;
13248
+ case "session.reasoning.delta":
13249
+ if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
13250
+ return;
13251
+ case "session.reasoning.ended": {
13252
+ const key = `reasoning:${message}:${data.ordinal ?? 0}`;
13253
+ if (!data.text || state.seen.has(key)) return;
13254
+ state.seen.add(key);
13255
+ onEvent({ type: "thinking", text: data.text, subagentId });
13256
+ return;
13257
+ }
13258
+ case "session.text.delta":
13259
+ if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
13260
+ return;
13261
+ case "session.text.ended":
13262
+ if (subagentId || !data.text) return;
13263
+ if (message) state.streamedMessages.add(message);
13264
+ this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
13265
+ return;
13266
+ case "session.tool.input.started":
13267
+ if (data.id && data.name) state.toolNames.set(data.id, data.name);
13268
+ return;
13269
+ case "session.tool.called": {
13270
+ const id = data.id;
13271
+ if (!id || state.seen.has(`call:${id}`)) return;
13272
+ state.seen.add(`call:${id}`);
13273
+ const input = data.input ?? {};
13274
+ const name = state.toolNames.get(id) ?? "tool";
13275
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
13276
+ state.briefs.set(id, brief);
13277
+ onEvent({ type: "tool_call", id, name, args: input, subagentId });
13278
+ return;
13279
+ }
13280
+ case "session.tool.progress":
13281
+ if (!subagentId) this.adoptChild(data, state, onEvent, turn);
13282
+ return;
13283
+ case "session.tool.success":
13284
+ case "session.tool.failed": {
13285
+ const id = data.id;
13286
+ if (!id || state.seen.has(`result:${id}`)) return;
13287
+ state.seen.add(`result:${id}`);
13288
+ const success = frame.type === "session.tool.success";
13289
+ const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
13290
+ onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
13291
+ if (!subagentId) this.finishChild(id, success, output, state, onEvent);
13292
+ return;
13293
+ }
13294
+ case "session.step.ended":
13295
+ case "session.step.failed":
13296
+ this.countUsage(data, state, onEvent, subagentId);
13297
+ return;
13298
+ default:
13299
+ }
13300
+ }
13301
+ adoptChild(data, state, onEvent, turn) {
13302
+ const child = data.metadata?.sessionID;
13303
+ const callId = data.id;
13304
+ if (!child || !callId || state.children.get(child)) return;
13305
+ const model = state.stepModels.get(data.assistantMessageID ?? "");
13306
+ onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
13307
+ state.children.set(child, callId);
13308
+ const held = state.heldFrames.get(child) ?? [];
13309
+ state.heldFrames.delete(child);
13310
+ for (const frame of held) this.onFrame(frame, state, turn, onEvent);
13311
+ }
13312
+ finishChild(callId, success, output, state, onEvent) {
13313
+ if (![...state.children.values()].includes(callId)) return;
13314
+ onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
13315
+ }
13316
+ /** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
13317
+ countUsage(data, state, onEvent, subagentId) {
13318
+ const message = data.assistantMessageID;
13319
+ const tokens = data.tokens;
13320
+ if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
13321
+ state.seen.add(`usage:${message}`);
13322
+ const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
13323
+ const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
13324
+ if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
13325
+ const record = { source: "opencode", ...prompt, outputTokens };
13326
+ const model = state.stepModels.get(message);
13327
+ if (model) record.model = model;
13328
+ if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
13329
+ if (subagentId) record.subagentId = subagentId;
13330
+ onEvent({ type: "usage", record });
13331
+ }
13332
+ /** One complete reply text, once. A message can carry text on both sides of a tool call, so each run after the first opens a paragraph. */
13333
+ emitText(id, text, state, onEvent) {
13334
+ if (state.seen.has(`text:${id}`)) return;
13335
+ state.seen.add(`text:${id}`);
13336
+ if (!text.trim()) return;
13337
+ const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
13338
+ onEvent({ type: "assistant_text", text: `${lead}${text}` });
13339
+ this.turnHasText = true;
13340
+ }
13341
+ /**
13342
+ * Stream one piece of a reply text. The paragraph break goes out with the
13343
+ * first visible delta, so the deltas add up to exactly the text the completed
13344
+ * run then re-sends; a run that is only whitespace so far is held back.
13345
+ */
13346
+ onTextDelta(id, delta, state, onEvent) {
13347
+ if (state.seen.has(`text:${id}`)) return;
13348
+ const run = state.textRuns.get(id) ?? { held: "", lead: null };
13349
+ state.textRuns.set(id, run);
13350
+ if (run.lead !== null) {
13351
+ onEvent({ type: "assistant_text_delta", text: delta });
13352
+ return;
13353
+ }
13354
+ run.held += delta;
13355
+ if (!run.held.trim()) return;
13356
+ run.lead = this.turnHasText ? "\n\n" : "";
13357
+ this.turnHasText = true;
13358
+ onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
13359
+ }
13360
+ permissionRequest(ask) {
13361
+ const id = ask.id;
13362
+ if (!id) return null;
13363
+ const scope = (ask.resources ?? []).join(", ");
13364
+ return {
13365
+ type: "permission_request",
13366
+ id,
13367
+ name: ask.action ?? "permission",
13368
+ detail: JSON.stringify(scope ? { scope } : {}),
13369
+ input: ask.resources ? { resources: ask.resources } : {},
13370
+ // The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
13371
+ ...ask.save?.length ? { suggestions: ask.save } : {},
13372
+ ...ask.source?.id ? { toolUseId: ask.source.id } : {}
13373
+ };
13374
+ }
13375
+ /**
13376
+ * Deny one planner request. OpenCode blocks the turn until it is answered, so
13377
+ * this must answer: `reject` rather than a silent drop is the "absent answer
13378
+ * is a denial" invariant ADR-0008 states. The refusal is announced so the
13379
+ * timeline shows the planner reaching for something it may not have.
13380
+ */
13381
+ denyPermission(ask, session, state, onEvent) {
13382
+ const request = this.permissionRequest(ask);
13383
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
13384
+ state.seen.add(`perm:${request.id}`);
13385
+ onEvent(request);
13386
+ void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
13387
+ });
13388
+ }
13389
+ /**
13390
+ * A task's request. Under a mode whose manifest sets `approvals: auto` it is
13391
+ * answered at once with what `opencode run --auto` answers, so the same plan
13392
+ * behaves the same on both transports (ADR-0001), and announced already
13393
+ * decided so the log still shows it. Any other mode leaves it open for an
13394
+ * approval card.
13395
+ */
13396
+ askPermission(ask, session, state, onEvent) {
13397
+ const request = this.permissionRequest(ask);
13398
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
13399
+ state.seen.add(`perm:${request.id}`);
13400
+ if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
13401
+ onEvent({ ...request, decided: { decision: "allow" } });
13402
+ void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
13403
+ });
13404
+ return;
13405
+ }
13406
+ this.openPermissions.set(request.id, session);
13407
+ onEvent(request);
13408
+ }
13409
+ };
13410
+
13411
+ // src/services/harness/OpenCodeAdapter.ts
12548
13412
  var SERVER_READY_TIMEOUT_MS = 3e4;
12549
13413
  var STDERR_TAIL_CHARS2 = 4e3;
12550
- var STREAM_CONNECT_TIMEOUT_MS = 5e3;
13414
+ var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
12551
13415
  var RECOVERY_POLL_INTERVAL_MS = 2e3;
12552
13416
  var RECOVERY_TIMEOUT_MS = 9e5;
12553
13417
  var STATUS_POLL_INTERVAL_MS = 1e3;
@@ -12560,8 +13424,8 @@ var DISABLED_TOOLS = {
12560
13424
  todowrite: false
12561
13425
  };
12562
13426
  var TASK_DISABLED_TOOLS = { question: false };
12563
- var AUTO_APPROVALS = "auto";
12564
- function splitModelId(id) {
13427
+ var AUTO_APPROVALS2 = "auto";
13428
+ function splitModelId2(id) {
12565
13429
  const slash = id.indexOf("/");
12566
13430
  if (slash <= 0 || slash === id.length - 1) return null;
12567
13431
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
@@ -12617,7 +13481,7 @@ function unwrapFileToolOutput(output) {
12617
13481
  const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
12618
13482
  return inner ? inner[1] : output;
12619
13483
  }
12620
- function delay(ms, signal) {
13484
+ function delay2(ms, signal) {
12621
13485
  return new Promise((resolve7) => {
12622
13486
  const timer = setTimeout(resolve7, ms);
12623
13487
  timer.unref?.();
@@ -12627,10 +13491,10 @@ function delay(ms, signal) {
12627
13491
  }, { once: true });
12628
13492
  });
12629
13493
  }
12630
- function newTurnState() {
13494
+ function newTurnState2() {
12631
13495
  return { seen: /* @__PURE__ */ new Set(), assistantMessages: /* @__PURE__ */ new Set(), partTypes: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map() };
12632
13496
  }
12633
- function permissionReply(decision) {
13497
+ function permissionReply2(decision) {
12634
13498
  if (decision.decision === "allow") return { reply: "once" };
12635
13499
  if (decision.decision === "allowForTask") return { reply: "always" };
12636
13500
  const note = decision.note?.trim();
@@ -12667,6 +13531,8 @@ var OpenCodeAdapter = class {
12667
13531
  interruptRequested = false;
12668
13532
  /** A task's requests waiting for an answer, by request id, with the session that asked. */
12669
13533
  openPermissions = /* @__PURE__ */ new Map();
13534
+ /** Set once the server turns out to speak the 2.x API, which then owns the session. */
13535
+ v2 = null;
12670
13536
  async start(opts) {
12671
13537
  if (opts.kind === "task") this.task = opts;
12672
13538
  else this.planner = opts;
@@ -12681,14 +13547,14 @@ var OpenCodeAdapter = class {
12681
13547
  throw new ExecutableNotFoundError("opencode", PATH);
12682
13548
  }
12683
13549
  const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
12684
- const password = opts.kind === "task" ? randomBytes2(24).toString("base64url") : null;
13550
+ const password = randomBytes2(24).toString("base64url");
12685
13551
  this.process = this.deps.spawn(launch.file, launch.args, {
12686
- env: runnerEnv(PATH, password ? { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password } : workspaceEnv),
13552
+ env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
12687
13553
  stdio: ["pipe", "pipe", "pipe"],
12688
13554
  cwd: opts.cwd,
12689
13555
  windowsVerbatimArguments: launch.verbatim
12690
13556
  });
12691
- if (password) this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
13557
+ this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
12692
13558
  this.process.stdin?.on("error", () => {
12693
13559
  });
12694
13560
  const banner = new Promise((resolve7) => {
@@ -12725,6 +13591,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
12725
13591
 
12726
13592
  ${this.stderrTail.trim()}` : ""}`);
12727
13593
  }
13594
+ if (await this.speaksV2()) {
13595
+ this.v2 = new OpenCodeV2({
13596
+ request: (method, path26, body, signal) => this.request(method, path26, body, signal),
13597
+ json: (method, path26, body, signal) => this.json(method, path26, body, signal),
13598
+ openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
13599
+ processEnded: this.processEnded,
13600
+ isExited: () => this.exited,
13601
+ exitMessage: () => this.exitMessage()
13602
+ }, opts, this.role());
13603
+ try {
13604
+ await this.v2.start();
13605
+ } catch (err) {
13606
+ this.dispose();
13607
+ throw err;
13608
+ }
13609
+ this.sessionId = this.v2.nativeSessionId();
13610
+ return;
13611
+ }
12728
13612
  if (opts.resumeSessionId) {
12729
13613
  const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
12730
13614
  if (existing?.id) {
@@ -12748,12 +13632,17 @@ ${this.stderrTail.trim()}` : ""}`);
12748
13632
  onEvent({ type: "error", message: this.exitMessage() });
12749
13633
  return;
12750
13634
  }
13635
+ if (this.v2) {
13636
+ await this.v2.send(message, onEvent, signal, onActivity);
13637
+ if (signal?.aborted) this.dispose();
13638
+ return;
13639
+ }
12751
13640
  if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
12752
- const turn = newTurnState();
13641
+ const turn = newTurnState2();
12753
13642
  this.turnHasText = false;
12754
13643
  const closeStream = await this.openStream(turn, onEvent, onActivity);
12755
13644
  try {
12756
- const model = this.planner?.model ? splitModelId(this.planner.model) : null;
13645
+ const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
12757
13646
  const body = {
12758
13647
  parts: [{ type: "text", text: message }],
12759
13648
  // The read-only guarantee: OpenCode's own plan agent has no write tools.
@@ -12794,7 +13683,7 @@ ${this.stderrTail.trim()}` : ""}`);
12794
13683
  * {@link recoverReply}, never applies.
12795
13684
  */
12796
13685
  async sendTask(task, message, onEvent, signal, onActivity) {
12797
- const state = newTurnState();
13686
+ const state = newTurnState2();
12798
13687
  this.turnHasText = false;
12799
13688
  this.interruptRequested = false;
12800
13689
  let markDone = () => {
@@ -12810,7 +13699,7 @@ ${this.stderrTail.trim()}` : ""}`);
12810
13699
  const closeStream = await this.openStream(state, onEvent, onActivity);
12811
13700
  const poll = new AbortController();
12812
13701
  try {
12813
- const model = task.model ? splitModelId(task.model) : null;
13702
+ const model = task.model ? splitModelId2(task.model) : null;
12814
13703
  const body = {
12815
13704
  parts: [{ type: "text", text: message }],
12816
13705
  // The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
@@ -12890,7 +13779,7 @@ ${this.stderrTail.trim()}` : ""}`);
12890
13779
  */
12891
13780
  async pollStatus(turn, signal) {
12892
13781
  while (!turn.done && !signal.aborted) {
12893
- await delay(STATUS_POLL_INTERVAL_MS, signal);
13782
+ await delay2(STATUS_POLL_INTERVAL_MS, signal);
12894
13783
  if (turn.done || signal.aborted) return;
12895
13784
  const busy = await this.sessionBusy();
12896
13785
  if (busy === true) turn.live = true;
@@ -12937,6 +13826,7 @@ ${this.stderrTail.trim()}` : ""}`);
12937
13826
  */
12938
13827
  async interrupt(timeoutMs) {
12939
13828
  if (!this.process || !this.sessionId || this.exited) return false;
13829
+ if (this.v2) return this.v2.interrupt(timeoutMs);
12940
13830
  const turn = this.taskTurn;
12941
13831
  this.interruptRequested = true;
12942
13832
  const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
@@ -12957,13 +13847,23 @@ ${this.stderrTail.trim()}` : ""}`);
12957
13847
  void this.processEnded.then(() => listener(this.exitCode));
12958
13848
  }
12959
13849
  answerPermission(id, decision) {
13850
+ if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
12960
13851
  const sessionId = this.openPermissions.get(id);
12961
13852
  if (sessionId === void 0 || !this.process) return false;
12962
13853
  this.openPermissions.delete(id);
12963
- void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
13854
+ void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
12964
13855
  });
12965
13856
  return true;
12966
13857
  }
13858
+ /**
13859
+ * OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
13860
+ * only 2.x answers `/api/info` with its version. A 1.x server — or one that
13861
+ * answers with anything else — keeps the 1.x protocol this class speaks.
13862
+ */
13863
+ async speaksV2() {
13864
+ const info = await this.json("GET", "/api/info").catch(() => null);
13865
+ return typeof info === "object" && info !== null && typeof info.version === "string";
13866
+ }
12967
13867
  role() {
12968
13868
  return this.task ? "task" : "planner";
12969
13869
  }
@@ -13014,7 +13914,7 @@ ${this.stderrTail.trim()}` : ""}`);
13014
13914
  if (pending?.info?.time?.completed || pending?.info?.error) return pending;
13015
13915
  if (pending) onActivity?.();
13016
13916
  if (Date.now() >= deadline) return null;
13017
- await delay(RECOVERY_POLL_INTERVAL_MS, signal);
13917
+ await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
13018
13918
  }
13019
13919
  }
13020
13920
  newestAssistantId(messages) {
@@ -13227,7 +14127,7 @@ ${this.stderrTail.trim()}` : ""}`);
13227
14127
  ...ask.always?.length ? { suggestions: ask.always } : {},
13228
14128
  ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
13229
14129
  };
13230
- if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS) {
14130
+ if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
13231
14131
  onEvent({ ...request, decided: { decision: "allow" } });
13232
14132
  void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
13233
14133
  });
@@ -13261,7 +14161,7 @@ ${this.stderrTail.trim()}` : ""}`);
13261
14161
  });
13262
14162
  const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
13263
14163
  await Promise.race([streamReady, new Promise((r) => {
13264
- const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
14164
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
13265
14165
  t.unref?.();
13266
14166
  })]);
13267
14167
  return async () => {
@@ -13426,6 +14326,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
13426
14326
  * pending and nothing ran until the user also re-ran the whole plan.
13427
14327
  */
13428
14328
  haltedByFailure = false;
14329
+ /** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
14330
+ merging = false;
14331
+ /** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
14332
+ gateNotice = "";
13429
14333
  /** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
13430
14334
  workspaceEnv;
13431
14335
  /** What the workspace env has already warned about, so a run says it once, not per task. */
@@ -13446,6 +14350,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13446
14350
  observers = [];
13447
14351
  tddEnabled;
13448
14352
  readRunnerTransport;
14353
+ previousAttemptFromLog;
13449
14354
  /**
13450
14355
  * The setting as the latest run copied it when it opened. Every spawn of
13451
14356
  * that run uses this, so flipping the setting mid-run changes the next run
@@ -13465,6 +14370,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13465
14370
  this.workspaceEnv = deps.workspaceEnv;
13466
14371
  this.tddEnabled = deps.tddEnabled;
13467
14372
  this.readRunnerTransport = deps.runnerTransport;
14373
+ this.previousAttemptFromLog = deps.previousAttemptFromLog;
13468
14374
  this.store.onMutate = () => this.emit("onTaskChanged");
13469
14375
  this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
13470
14376
  this.verifier.onCheckpoint((taskId, summary) => {
@@ -13520,7 +14426,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
13520
14426
  workspaceRoot,
13521
14427
  workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
13522
14428
  tddEnabled: options.tddEnabled ?? (() => false),
13523
- runnerTransport: options.runnerTransport ?? (() => "terminal")
14429
+ runnerTransport: options.runnerTransport ?? (() => "terminal"),
14430
+ previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
13524
14431
  });
13525
14432
  return orchestrator;
13526
14433
  }
@@ -13528,6 +14435,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
13528
14435
  getIdleSince(taskId) {
13529
14436
  return this.verifier.getIdleSince(taskId);
13530
14437
  }
14438
+ /**
14439
+ * What an ops retry is told about the attempt before. The saved log is read
14440
+ * whether or not the session was reloaded, so both behave alike; the terminal
14441
+ * buffer is the fallback for a runner that has no log. A reload clears the
14442
+ * attempt count along with the buffer, so a retry made since is what says
14443
+ * there was an attempt to report on.
14444
+ */
14445
+ opsPreviousAttempt(taskId) {
14446
+ const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
14447
+ if (saved !== void 0) return saved;
14448
+ return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
14449
+ }
13531
14450
  /** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
13532
14451
  getLiveOutput(taskId, opts) {
13533
14452
  return this.output.liveTail(taskId, opts);
@@ -13734,9 +14653,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
13734
14653
  async reviewRunDiff() {
13735
14654
  return this.runs.reviewDiff();
13736
14655
  }
13737
- /** "Merge all"; a run that merged everything is cleared up and forgotten. */
14656
+ /**
14657
+ * "Merge all"; a settled run that merged everything is cleared up and
14658
+ * forgotten. During a run it merges what has landed and the run goes on:
14659
+ * the tasks waiting at a merge gate for that work then start (ADR-0020).
14660
+ * Never while an ops task runs, and no ops task starts until it ends — the
14661
+ * one overlap the plan cannot order, because the merge is the user's.
14662
+ */
13738
14663
  async mergeRun() {
13739
- return this.runs.merge();
14664
+ const ops = [...this.attempts.values()].find((a) => a.ops);
14665
+ if (ops) {
14666
+ const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
14667
+ throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
14668
+ }
14669
+ this.merging = true;
14670
+ let result;
14671
+ try {
14672
+ result = await this.runs.merge();
14673
+ } finally {
14674
+ this.merging = false;
14675
+ }
14676
+ await this.tick();
14677
+ return result;
14678
+ }
14679
+ /**
14680
+ * The run as a surface offers Merge all mid-run (ADR-0020): what has landed
14681
+ * and is not merged, and whether the run is paused at its gates with nothing
14682
+ * else running. Null while no task waits at a gate.
14683
+ */
14684
+ mergeGateView() {
14685
+ const handoff = this.runs.view()?.handoff;
14686
+ if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
14687
+ return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
14688
+ }
14689
+ /**
14690
+ * The dependencies an ops or user task waits on at its merge gate: those
14691
+ * whose landed work is not in the user's branch yet (ADR-0020). Empty once
14692
+ * the task has started, and for every other task.
14693
+ */
14694
+ getMergeGate(taskId) {
14695
+ const task = this.store.get(taskId);
14696
+ if (!task || task.status !== "pending" && task.status !== "approved") return [];
14697
+ return mergeGate(task, this.store, this.runs);
13740
14698
  }
13741
14699
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
13742
14700
  async cleanupRun() {
@@ -13836,7 +14794,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13836
14794
  this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
13837
14795
  return;
13838
14796
  }
13839
- if (!await this.runs.open(() => this.start()) || this.running) return;
14797
+ this.runs.open();
13840
14798
  console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
13841
14799
  this.running = true;
13842
14800
  this.haltedByFailure = false;
@@ -13878,13 +14836,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
13878
14836
  const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
13879
14837
  if (this.attempts.get(taskId) !== attempt) return;
13880
14838
  const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
14839
+ const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
13881
14840
  const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
13882
14841
  if (this.attempts.get(taskId) !== attempt) return;
13883
14842
  this.endAttempt(taskId, "verdict");
13884
14843
  this.store.setTaskVerdict(taskId, verdict);
13885
14844
  console.error(`[TaskOrchestrator] Output summary:
13886
14845
  ${summary || "(empty \u2014 no output captured)"}`);
13887
- if (landing) {
14846
+ if (changedFiles.length > 0) {
14847
+ this.store.markAwaitingUser(taskId, "files-changed");
14848
+ 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.`);
14849
+ } else if (landing) {
13888
14850
  await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
13889
14851
  } else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
13890
14852
  this.store.markAwaitingUser(taskId);
@@ -13986,21 +14948,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
13986
14948
  });
13987
14949
  }
13988
14950
  getReadyTasks() {
13989
- if (!this.running) return [];
14951
+ return this.readiness().ready;
14952
+ }
14953
+ readiness() {
14954
+ if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
13990
14955
  const maxParallel = this.config.maxParallelSessions;
13991
14956
  const active = this.attempts.size;
13992
- const { ready, candidateCount, excluded } = selectReadyTasks({
14957
+ const readiness = selectReadyTasks({
13993
14958
  store: this.store,
13994
14959
  onHold: this.onHold,
13995
14960
  runs: this.runs,
13996
14961
  active,
13997
- maxParallel
14962
+ maxParallel,
14963
+ merging: this.merging
13998
14964
  });
13999
- for (const { task, reasons } of excluded) {
14965
+ for (const { task, reasons } of readiness.excluded) {
14000
14966
  console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
14001
14967
  }
14002
- console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
14003
- return ready;
14968
+ console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
14969
+ return readiness;
14970
+ }
14971
+ /**
14972
+ * Open the run a task starts in, and for a change task decide whether it
14973
+ * isolates (ADR-0020): an ops task needs no worktree, so it never waits on
14974
+ * that choice. False while a dirty tree waits on the user; `resume` is what
14975
+ * their choice replays.
14976
+ */
14977
+ async openFor(taskId, resume) {
14978
+ this.runs.open();
14979
+ return this.store.isOps(taskId) || this.runs.decide(resume);
14004
14980
  }
14005
14981
  /**
14006
14982
  * Cancel a running (or scheduled) task: kill its session and return it to
@@ -14128,7 +15104,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
14128
15104
  const runner = this.store.resolveTaskRunner(task);
14129
15105
  const route = routeTransport("structured", runner, this.registry);
14130
15106
  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;
15107
+ if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
14132
15108
  if (this.attempts.has(taskId) || !continuability(task).ok) return;
14133
15109
  this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
14134
15110
  this.endAttempt(taskId, "retry");
@@ -14150,10 +15126,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
14150
15126
  const task = this.store.get(taskId);
14151
15127
  if (!task || task.type !== "ai") return;
14152
15128
  if (this.attempts.has(taskId)) return;
14153
- if (!await this.runs.open(() => this.forceStartTask(taskId))) return;
15129
+ this.refuseOpsDuringMerge(task);
15130
+ if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
14154
15131
  this.onHold.delete(taskId);
15132
+ this.notePastGate(task);
14155
15133
  await this.startTask(task);
14156
15134
  }
15135
+ refuseOpsDuringMerge(task) {
15136
+ if (this.merging && this.store.isOps(task.id)) {
15137
+ throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
15138
+ }
15139
+ }
15140
+ /**
15141
+ * A force start passes a merge gate (ADR-0020), and the surface confirmed it
15142
+ * first; the choice is kept on the task and said, because the work it acts
15143
+ * on is not in the user's branch.
15144
+ */
15145
+ notePastGate(task) {
15146
+ const unmerged = mergeGate(task, this.store, this.runs);
15147
+ if (unmerged.length === 0) return;
15148
+ const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
15149
+ this.store.setForcedPastGate(task.id, titles);
15150
+ this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
15151
+ }
14157
15152
  /**
14158
15153
  * Run exactly one task outside full-plan scheduling. The active/starting
14159
15154
  * session still contributes to {@link hasLiveWork} so every surface exposes
@@ -14164,8 +15159,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
14164
15159
  if (this.hasLiveWork) return;
14165
15160
  const task = this.store.get(taskId);
14166
15161
  if (!task || task.type !== "ai") return;
14167
- if (!await this.runs.open(() => this.runTask(taskId))) return;
15162
+ this.refuseOpsDuringMerge(task);
15163
+ if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
14168
15164
  this.onHold.delete(taskId);
15165
+ this.notePastGate(task);
14169
15166
  await this.startTask(task);
14170
15167
  }
14171
15168
  getCompletedCount() {
@@ -14205,7 +15202,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
14205
15202
  }
14206
15203
  return;
14207
15204
  }
14208
- const ready = this.getReadyTasks();
15205
+ let readiness = this.readiness();
15206
+ const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
15207
+ if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
15208
+ if (!this.running) return;
15209
+ const { ready } = readiness;
14209
15210
  console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
14210
15211
  if (ready.length === 0 && this.attempts.size === 0) {
14211
15212
  const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
@@ -14225,14 +15226,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
14225
15226
  this.emit("onExecutionComplete");
14226
15227
  } else {
14227
15228
  this.notifications.info("Remaining tasks require user action or are on hold.");
15229
+ this.sayGate();
14228
15230
  this.emit("onTaskChanged");
14229
15231
  this.emit("onTick");
14230
15232
  }
14231
15233
  return;
14232
15234
  }
14233
- for (const task of ready) await this.startTask(task);
15235
+ for (const task of ready) {
15236
+ if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
15237
+ await this.startTask(task);
15238
+ }
14234
15239
  this.emit("onTick");
14235
15240
  }
15241
+ /**
15242
+ * A run paused at merge gates says so, as plainly as a task that waits on
15243
+ * the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
15244
+ */
15245
+ sayGate() {
15246
+ const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
15247
+ 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.`;
15248
+ if (notice === this.gateNotice) return;
15249
+ this.gateNotice = notice;
15250
+ if (notice) this.tell("info", notice);
15251
+ }
14236
15252
  async startTask(task, continuation = null) {
14237
15253
  if (this.attempts.has(task.id)) return;
14238
15254
  const attempt = {
@@ -14246,6 +15262,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
14246
15262
  integration: null,
14247
15263
  repair: this.landing.nextRepair(task.id),
14248
15264
  continuation,
15265
+ ops: this.store.isOps(task.id),
15266
+ snapshot: null,
14249
15267
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
14250
15268
  decided: false
14251
15269
  };
@@ -14271,18 +15289,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
14271
15289
  */
14272
15290
  async spawnAttempt(task, attempt) {
14273
15291
  try {
14274
- const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
15292
+ const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
14275
15293
  attempt.cwd = cwd;
14276
15294
  attempt.worktree = worktree;
15295
+ if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
14277
15296
  if (this.attempts.get(task.id) !== attempt) {
14278
15297
  if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
14279
15298
  this.abandonSpawn(task, attempt);
14280
15299
  return false;
14281
15300
  }
14282
- const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
15301
+ 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
15302
  planMapEnabled: this.config.planMapEnabled,
14284
15303
  // A merge to resolve is not new behaviour to drive test-first.
14285
- tddEnabled: !attempt.repair && this.tddEnabled()
15304
+ tddEnabled: !attempt.repair && this.tddEnabled(),
15305
+ // An ops task's effects outlive a failed attempt and are never rolled
15306
+ // back, so the next one is told what the last one did (ADR-0020).
15307
+ previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
14286
15308
  });
14287
15309
  this.lingering.close(task.id);
14288
15310
  const env = await this.envForTask(cwd);
@@ -14459,6 +15481,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
14459
15481
  return ended;
14460
15482
  }
14461
15483
  };
15484
+ var OPS_RETRY_TAIL_LINES = 60;
14462
15485
  function unresumedMessage(task, why) {
14463
15486
  return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
14464
15487
  }
@@ -16916,7 +17939,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
16916
17939
  return tasks.map((t) => {
16917
17940
  const isMan = t.type === "user";
16918
17941
  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(", ")}]`;
17942
+ 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
17943
  });
16921
17944
  }
16922
17945
  };
@@ -17413,6 +18436,7 @@ var SessionEventRelay = class {
17413
18436
  this.statusOwed = this.statusHeld;
17414
18437
  if (this.statusHeld) return;
17415
18438
  plan.tasks = this.store.snapshot();
18439
+ const gate = this.orchestrator.mergeGateView();
17416
18440
  this.broadcast({
17417
18441
  type: "status_update",
17418
18442
  tasks: this.store.allTasks.map((t) => serializeTaskStatus(
@@ -17420,8 +18444,10 @@ var SessionEventRelay = class {
17420
18444
  this.orchestrator.getIdleSince(t.id),
17421
18445
  this.orchestrator.getTaskIsolation(t.id),
17422
18446
  this.orchestrator.getQueuedTaskMessages(t.id),
17423
- this.awaitingApproval(t.id)
17424
- ))
18447
+ this.awaitingApproval(t.id),
18448
+ this.orchestrator.getMergeGate(t.id)
18449
+ )),
18450
+ ...gate ? { gate } : {}
17425
18451
  });
17426
18452
  }
17427
18453
  /**
@@ -17897,6 +18923,13 @@ var PlanEditError = class extends Error {
17897
18923
  this.name = "PlanEditError";
17898
18924
  }
17899
18925
  };
18926
+ var OPS_RETRY_DIGEST_CALLS = 20;
18927
+ function lastAttemptDigest(location, taskId) {
18928
+ const attempt = listTaskLogAttempts(location, taskId).pop();
18929
+ if (attempt === void 0) return null;
18930
+ const events = readTaskLog(location, taskId, attempt);
18931
+ return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
18932
+ }
17900
18933
  function sessionRuntimeSettings(settings) {
17901
18934
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
17902
18935
  }
@@ -17981,7 +19014,8 @@ function createSession(deps) {
17981
19014
  registry: deps.registry,
17982
19015
  workspaceRoot: deps.workspaceRoot,
17983
19016
  tddEnabled: () => deps.settings().tddEnabled,
17984
- runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
19017
+ runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
19018
+ previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
17985
19019
  });
17986
19020
  const usage = new PlannerUsageLedger();
17987
19021
  const events = new SessionEventRelay({
@@ -18754,6 +19788,14 @@ var Session = class {
18754
19788
  isolationView() {
18755
19789
  return this.orchestrator.isolationView();
18756
19790
  }
19791
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
19792
+ mergeGate(taskId) {
19793
+ return this.orchestrator.getMergeGate(taskId);
19794
+ }
19795
+ /** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
19796
+ mergeGateView() {
19797
+ return this.orchestrator.mergeGateView();
19798
+ }
18757
19799
  /** The run's integration branch against its base ref, as a unified diff. */
18758
19800
  async reviewRunDiff() {
18759
19801
  if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
@@ -18763,10 +19805,12 @@ var Session = class {
18763
19805
  * "Merge all": each repo's integration branch into whatever the user has
18764
19806
  * checked out there — every repo, or none if any cannot take it. The one
18765
19807
  * irreversible step of isolated execution, so this explicit call is the
18766
- * only way it ever happens.
19808
+ * only way it ever happens. During a run it merges what has landed so far,
19809
+ * which is what opens a merge gate (ADR-0020); it waits for no ops task,
19810
+ * and refuses while one runs.
18767
19811
  */
18768
19812
  async mergeRun() {
18769
- this.requireSettledRun();
19813
+ if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
18770
19814
  const result = await this.orchestrator.mergeRun();
18771
19815
  this.broadcast({ type: "isolation_merge", result });
18772
19816
  return result;
@@ -18856,7 +19900,8 @@ var Session = class {
18856
19900
  * falls through to the no-op `store.update` below instead of throwing.
18857
19901
  */
18858
19902
  async updateTask(taskId, changes) {
18859
- if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
19903
+ if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
19904
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
18860
19905
  const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
18861
19906
  if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
18862
19907
  if (check.clear?.length) {
@@ -21133,6 +22178,7 @@ export {
21133
22178
  FsPluginStore,
21134
22179
  GIT_READONLY_SUBCOMMANDS,
21135
22180
  GREP_DEFAULT_HEAD_LIMIT,
22181
+ GREP_PCRE_PROBE_ARGS,
21136
22182
  GeminiService,
21137
22183
  HeadlessRunner,
21138
22184
  HeadlessSession,
@@ -21272,6 +22318,7 @@ export {
21272
22318
  dependencyCandidates,
21273
22319
  dependentsOf,
21274
22320
  describeMergeResult,
22321
+ digestTaskLog,
21275
22322
  discoverGeminiModels,
21276
22323
  drainNext,
21277
22324
  effectiveAllowlist,
@@ -21304,6 +22351,7 @@ export {
21304
22351
  getStateDir,
21305
22352
  globalDataDir,
21306
22353
  grantScopeFor,
22354
+ grepRegexFromProbe,
21307
22355
  hasHiddenDetail,
21308
22356
  hasTmux,
21309
22357
  holdPrompt,