@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.js CHANGED
@@ -67,6 +67,7 @@ __export(index_exports, {
67
67
  FsPluginStore: () => FsPluginStore,
68
68
  GIT_READONLY_SUBCOMMANDS: () => GIT_READONLY_SUBCOMMANDS,
69
69
  GREP_DEFAULT_HEAD_LIMIT: () => GREP_DEFAULT_HEAD_LIMIT,
70
+ GREP_PCRE_PROBE_ARGS: () => GREP_PCRE_PROBE_ARGS,
70
71
  GeminiService: () => GeminiService,
71
72
  HeadlessRunner: () => HeadlessRunner,
72
73
  HeadlessSession: () => HeadlessSession,
@@ -206,6 +207,7 @@ __export(index_exports, {
206
207
  dependencyCandidates: () => dependencyCandidates,
207
208
  dependentsOf: () => dependentsOf,
208
209
  describeMergeResult: () => describeMergeResult,
210
+ digestTaskLog: () => digestTaskLog,
209
211
  discoverGeminiModels: () => discoverGeminiModels,
210
212
  drainNext: () => drainNext,
211
213
  effectiveAllowlist: () => effectiveAllowlist,
@@ -238,6 +240,7 @@ __export(index_exports, {
238
240
  getStateDir: () => getStateDir,
239
241
  globalDataDir: () => globalDataDir,
240
242
  grantScopeFor: () => grantScopeFor,
243
+ grepRegexFromProbe: () => grepRegexFromProbe,
241
244
  hasHiddenDetail: () => hasHiddenDetail,
242
245
  hasTmux: () => hasTmux,
243
246
  holdPrompt: () => holdPrompt,
@@ -400,7 +403,7 @@ function resolveOrderLabel(tasks, label) {
400
403
 
401
404
  // src/models/Task.ts
402
405
  function isAwaitingReason(value) {
403
- return value === "input" || value === "checkpoint" || value === "conflict";
406
+ return value === "input" || value === "checkpoint" || value === "conflict" || value === "files-changed";
404
407
  }
405
408
  function emptyWarnings() {
406
409
  return { deletedCompleted: [], changedCompleted: [], deletedInProgress: [], modifiedInProgress: [], brokenDependencies: [] };
@@ -548,7 +551,8 @@ function createTask(overrides = {}) {
548
551
  completionMarker: overrides.completionMarker ?? (0, import_uuid.v4)(),
549
552
  autonomy: overrides.autonomy,
550
553
  sliceType: overrides.sliceType,
551
- userStoriesCovered: overrides.userStoriesCovered
554
+ userStoriesCovered: overrides.userStoriesCovered,
555
+ ...overrides.ops === true && (overrides.type ?? "ai") === "ai" ? { ops: true } : {}
552
556
  };
553
557
  }
554
558
  function createEmptyPlan() {
@@ -648,6 +652,9 @@ function keepExecutionState(current, rewrite) {
648
652
  verdict: prior?.verdict,
649
653
  outputSummary: prior?.outputSummary,
650
654
  transport: prior?.transport,
655
+ // Where a task that has run ran is fixed, like its transport (ADR-0020).
656
+ ...prior?.status === "failed" ? { ops: prior.ops } : {},
657
+ forcedPastGate: prior?.forcedPastGate,
651
658
  subtasks: overlay(t.subtasks ?? [], prior?.subtasks ?? [])
652
659
  };
653
660
  });
@@ -762,6 +769,8 @@ function plannerContextFill(usage) {
762
769
  var HEAD_LINES = 60;
763
770
  var TAIL_LINES = 40;
764
771
  var MAX_LINE_CHARS = 2e3;
772
+ var DIGEST_ARGS_CHARS = 120;
773
+ var DIGEST_MESSAGE_CHARS = 1e3;
765
774
  function capLine(line) {
766
775
  return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
767
776
  }
@@ -844,6 +853,33 @@ function coalesceTaskLog(events) {
844
853
  }
845
854
  return out;
846
855
  }
856
+ function digestTaskLog(events, maxCalls) {
857
+ const outcomes = /* @__PURE__ */ new Map();
858
+ let lastText;
859
+ let lastError;
860
+ for (const event of events) {
861
+ if (event.type === "tool_result") outcomes.set(event.id, event.success);
862
+ else if (event.type === "text") lastText = event.text;
863
+ else if (event.type === "error") lastError = event.message;
864
+ }
865
+ const lines = [];
866
+ for (const event of events) {
867
+ if (event.type !== "tool_call") continue;
868
+ const outcome = outcomes.get(event.id);
869
+ const args = event.args.length > DIGEST_ARGS_CHARS ? `${event.args.slice(0, DIGEST_ARGS_CHARS)}\u2026` : event.args;
870
+ lines.push(`- ${event.name} ${args} \u2192 ${outcome === void 0 ? "no result" : outcome ? "ok" : "failed"}`);
871
+ }
872
+ const omitted = lines.length - maxCalls;
873
+ const out = omitted > 0 ? [`\u2026 ${omitted} earlier call${omitted === 1 ? "" : "s"} omitted \u2026`, ...lines.slice(omitted)] : lines;
874
+ const gap = () => out.length > 0 ? [""] : [];
875
+ const said2 = lastText?.trim();
876
+ if (said2) {
877
+ const end = said2.length > DIGEST_MESSAGE_CHARS ? `\u2026${said2.slice(-DIGEST_MESSAGE_CHARS)}` : said2;
878
+ out.push(...gap(), "Its last message:", ...end.split("\n").map((l) => ` ${l}`));
879
+ }
880
+ if (lastError) out.push(...gap(), `It stopped with an error: ${lastError}`);
881
+ return out.join("\n");
882
+ }
847
883
 
848
884
  // src/interfaces/IFileSystem.ts
849
885
  var GREP_DEFAULT_HEAD_LIMIT = 100;
@@ -3081,6 +3117,8 @@ function lex(command, nested, dialect) {
3081
3117
  let unsafeRedirect;
3082
3118
  let processSubstitution = false;
3083
3119
  let unbalanced = false;
3120
+ let grouped = false;
3121
+ let joinedBy;
3084
3122
  let tokens = [];
3085
3123
  let expandable = false;
3086
3124
  let current = "";
@@ -3110,13 +3148,14 @@ function lex(command, nested, dialect) {
3110
3148
  current = "";
3111
3149
  started = false;
3112
3150
  };
3113
- const endSegment = (nextPiped) => {
3151
+ const endSegment = (nextPiped, next = "other") => {
3114
3152
  endToken();
3115
- if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
3153
+ if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs, joinedBy });
3116
3154
  tokens = [];
3117
3155
  inputs = [];
3118
3156
  expandable = false;
3119
3157
  piped = nextPiped;
3158
+ joinedBy = next;
3120
3159
  };
3121
3160
  let i = 0;
3122
3161
  while (i < command.length) {
@@ -3238,17 +3277,18 @@ function lex(command, nested, dialect) {
3238
3277
  }
3239
3278
  if (c === "(" || c === ")") {
3240
3279
  endToken();
3280
+ grouped = true;
3241
3281
  i++;
3242
3282
  continue;
3243
3283
  }
3244
3284
  if (c === "|") {
3245
3285
  const double = command[i + 1] === "|";
3246
- endSegment(!double);
3286
+ endSegment(!double, double ? "other" : "pipe");
3247
3287
  i += double || command[i + 1] === "&" ? 2 : 1;
3248
3288
  continue;
3249
3289
  }
3250
3290
  if (c === "&" || c === ";" || c === "\n") {
3251
- endSegment(false);
3291
+ endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
3252
3292
  i += command[i + 1] === c ? 2 : 1;
3253
3293
  continue;
3254
3294
  }
@@ -3266,7 +3306,7 @@ function lex(command, nested, dialect) {
3266
3306
  }
3267
3307
  if (quote !== "") unbalanced = true;
3268
3308
  endSegment(false);
3269
- return { segments, unsafeRedirect, processSubstitution, unbalanced };
3309
+ return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped };
3270
3310
  }
3271
3311
  function binaryName(token, dialect) {
3272
3312
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -3406,10 +3446,11 @@ function unwrap(seg, dialect) {
3406
3446
  }
3407
3447
  return { seg: { ...current, assignments }, wrappers, runner };
3408
3448
  }
3409
- function lexAll(command, dialect) {
3449
+ function lexAll(command, dialect, cdpathSet = false) {
3410
3450
  const queue = [];
3411
3451
  const top = lex(command, queue, dialect);
3412
3452
  const all = { ...top, segments: top.segments.filter((s) => s.binary) };
3453
+ if (!top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
3413
3454
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
3414
3455
  const inner = lex(queue.shift(), queue, dialect);
3415
3456
  all.segments.push(...inner.segments.filter((s) => s.binary));
@@ -3419,15 +3460,32 @@ function lexAll(command, dialect) {
3419
3460
  }
3420
3461
  return all;
3421
3462
  }
3463
+ function literalCdTarget(seg) {
3464
+ if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
3465
+ if (seg.args.length !== 1) return void 0;
3466
+ const [target] = seg.args;
3467
+ return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
3468
+ }
3469
+ function followCd(segments) {
3470
+ if (segments.some((s) => s.joinedBy === "other")) return;
3471
+ let chain = [];
3472
+ segments.forEach((seg, i) => {
3473
+ seg.cwd = chain;
3474
+ const inPipeline = seg.joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
3475
+ const target = inPipeline ? void 0 : literalCdTarget(seg);
3476
+ if (target !== void 0) chain = [...chain, target];
3477
+ });
3478
+ }
3422
3479
  function looksLikePath(arg) {
3423
3480
  if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
3424
3481
  return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
3425
3482
  }
3426
- function pathLikeArgs(command, opts = {}) {
3483
+ function pathRefs(command, opts = {}) {
3427
3484
  const dialect = dialectFor(opts.dialect);
3428
- return lexAll(command, dialect).segments.flatMap((seg) => {
3485
+ return lexAll(command, dialect, opts.cdpathSet).segments.flatMap((seg) => {
3429
3486
  const patterns = patternArgs(seg);
3430
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn);
3487
+ const cwd = seg.cwd ?? [];
3488
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path27) => ({ path: path27, cwd }));
3431
3489
  });
3432
3490
  }
3433
3491
  function pathIn(a) {
@@ -3591,7 +3649,8 @@ function scopeFor(seg) {
3591
3649
  }
3592
3650
  return [seg.binary, ...lead].join(" ");
3593
3651
  }
3594
- function isAuto(seg) {
3652
+ function isAuto(seg, opts) {
3653
+ if (seg.binary === "cd") return !opts.cdpathSet && literalCdTarget(seg) !== void 0;
3595
3654
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
3596
3655
  if (seg.expandable) return false;
3597
3656
  if (seg.binary !== "git") return true;
@@ -3686,7 +3745,7 @@ function classifyCommand(command, opts = {}) {
3686
3745
  const trimmed = command.trim();
3687
3746
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
3688
3747
  const dialect = dialectFor(opts.dialect);
3689
- const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
3748
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
3690
3749
  if (unbalanced) {
3691
3750
  return {
3692
3751
  tier: "refuse",
@@ -3723,7 +3782,7 @@ function classifyCommand(command, opts = {}) {
3723
3782
  };
3724
3783
  }
3725
3784
  }
3726
- const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
3785
+ const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
3727
3786
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
3728
3787
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3729
3788
  return { tier: "ask", scope };
@@ -3803,11 +3862,15 @@ function expandHome(target) {
3803
3862
  if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
3804
3863
  return path2.join(path2.dirname(home), target.slice(1));
3805
3864
  }
3806
- function resolveWithin(root, target) {
3807
- const abs = path2.resolve(root, expandHome(target));
3865
+ function resolveWithin(root, target, from = root) {
3866
+ const abs = path2.resolve(from, expandHome(target));
3808
3867
  const rel = path2.relative(root, abs);
3809
3868
  return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
3810
3869
  }
3870
+ function resolveRef(root, ref) {
3871
+ const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
3872
+ return resolveWithin(root, ref.path, from);
3873
+ }
3811
3874
  function grantScopeFor(abs, kind) {
3812
3875
  if (kind === "directory") return path2.join(abs, "*");
3813
3876
  const parent = path2.dirname(abs);
@@ -4013,14 +4076,17 @@ ${refs.output.trim()}`);
4013
4076
  outsidePaths(command) {
4014
4077
  const root = this.getWorkspaceRoot();
4015
4078
  const found = /* @__PURE__ */ new Map();
4016
- for (const p of pathLikeArgs(command, { dialect: this.researchShell.dialect })) {
4017
- const { abs, inside } = resolveWithin(root, p);
4079
+ for (const ref of pathRefs(command, this.policyOptions())) {
4080
+ const { abs, inside } = resolveRef(root, ref);
4018
4081
  if (inside || isInertDevice(abs)) continue;
4019
4082
  const scope = grantScopeFor(abs, "file");
4020
4083
  if (!found.has(scope)) found.set(scope, abs);
4021
4084
  }
4022
4085
  return [...found].map(([scope, abs]) => ({ abs, scope }));
4023
4086
  }
4087
+ policyOptions() {
4088
+ return { dialect: this.researchShell.dialect, cdpathSet: !!process.env.CDPATH };
4089
+ }
4024
4090
  /**
4025
4091
  * Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
4026
4092
  * anything else asks once and is remembered, and writes are refused outright
@@ -4032,7 +4098,7 @@ ${refs.output.trim()}`);
4032
4098
  * directory the next command names.
4033
4099
  */
4034
4100
  async bash(command, signal) {
4035
- const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
4101
+ const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
4036
4102
  if (tier === "refuse") {
4037
4103
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
4038
4104
  }
@@ -5208,10 +5274,78 @@ function buildGrepArgs(pattern, opts, root) {
5208
5274
  function buildGlobArgs(pattern, root) {
5209
5275
  return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
5210
5276
  }
5211
- function buildFallbackGrepArgs(pattern, opts, root) {
5277
+ var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
5278
+ function grepRegexFromProbe(exitCode) {
5279
+ return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
5280
+ }
5281
+ function toPosixClasses(pattern) {
5282
+ const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
5283
+ let out = "";
5284
+ let i = 0;
5285
+ while (i < pattern.length) {
5286
+ const c = pattern[i];
5287
+ if (c === "\\") {
5288
+ out += pattern.slice(i, i + 2);
5289
+ i += 2;
5290
+ continue;
5291
+ }
5292
+ if (c !== "[") {
5293
+ out += c;
5294
+ i++;
5295
+ continue;
5296
+ }
5297
+ const start = i;
5298
+ i++;
5299
+ let negate = false;
5300
+ if (pattern[i] === "^") {
5301
+ negate = true;
5302
+ i++;
5303
+ }
5304
+ let body = "";
5305
+ let closeBracket = false;
5306
+ let openBracket = false;
5307
+ let dash = false;
5308
+ let closed = false;
5309
+ while (i < pattern.length) {
5310
+ const ch = pattern[i];
5311
+ if (ch === "]") {
5312
+ closed = true;
5313
+ i++;
5314
+ break;
5315
+ }
5316
+ if (ch === "[" && pattern[i + 1] === ":") {
5317
+ const end = pattern.indexOf(":]", i + 2);
5318
+ if (end !== -1) {
5319
+ body += pattern.slice(i, end + 2);
5320
+ i = end + 2;
5321
+ continue;
5322
+ }
5323
+ }
5324
+ if (ch === "\\" && i + 1 < pattern.length) {
5325
+ const e = pattern[i + 1];
5326
+ i += 2;
5327
+ if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
5328
+ else if (e === "]") closeBracket = true;
5329
+ else if (e === "[") openBracket = true;
5330
+ else if (e === "-") dash = true;
5331
+ else body += e;
5332
+ continue;
5333
+ }
5334
+ body += ch;
5335
+ i++;
5336
+ }
5337
+ if (!closed) {
5338
+ out += pattern.slice(start);
5339
+ break;
5340
+ }
5341
+ out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
5342
+ }
5343
+ return out;
5344
+ }
5345
+ function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
5212
5346
  const args = ["-r", "-n"];
5213
5347
  if (opts.literal) args.push("-F");
5214
- else args.push("-P");
5348
+ else args.push(regex === "pcre" ? "-P" : "-E");
5215
5349
  if (opts.caseInsensitive) args.push("-i");
5216
5350
  if (opts.outputMode === "files") args.push("-l");
5217
5351
  if (opts.outputMode === "count") args.push("-c");
@@ -5221,7 +5355,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
5221
5355
  }
5222
5356
  if (opts.include) args.push(`--include=${opts.include}`);
5223
5357
  for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
5224
- args.push("-e", pattern, root);
5358
+ args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
5225
5359
  return args;
5226
5360
  }
5227
5361
  function globSegmentToRegex(segment) {
@@ -6168,7 +6302,7 @@ function taskIsolationOf(record, repairLimit) {
6168
6302
  };
6169
6303
  }
6170
6304
  function handoffOf(run) {
6171
- const merged = Object.values(run.tasks).filter((r) => r.status === "merged").sort((a, b) => a.order - b.order);
6305
+ const merged = Object.values(run.tasks).filter((r) => r.status === "merged" && !r.inHead).sort((a, b) => a.order - b.order);
6172
6306
  const entry = (r) => ({
6173
6307
  taskId: r.taskId,
6174
6308
  order: r.order,
@@ -6550,6 +6684,18 @@ function leftoverMarkerFiles(check) {
6550
6684
  const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
6551
6685
  return [...new Set(files)];
6552
6686
  }
6687
+ function rawDiffStates(raw) {
6688
+ const parts = raw.split("\0");
6689
+ const states = {};
6690
+ for (let i = 0; i + 1 < parts.length; i += 2) {
6691
+ const meta = parts[i].trim();
6692
+ const file = parts[i + 1];
6693
+ if (!meta.startsWith(":") || !file) continue;
6694
+ const [, mode, , blob] = meta.slice(1).split(" ");
6695
+ states[file] = `${mode}:${blob}`;
6696
+ }
6697
+ return states;
6698
+ }
6553
6699
  function settleStatus(record, status) {
6554
6700
  record.status = status;
6555
6701
  delete record.repairBase;
@@ -6649,6 +6795,8 @@ var GitWorktreeIsolation = class {
6649
6795
  adminChains = /* @__PURE__ */ new Map();
6650
6796
  waiting = [];
6651
6797
  draining = false;
6798
+ /** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
6799
+ turns = Promise.resolve();
6652
6800
  async isActive(workspaceRoot) {
6653
6801
  if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
6654
6802
  const version = await this.tryGit(void 0, ["--version"]);
@@ -7005,6 +7153,62 @@ var GitWorktreeIsolation = class {
7005
7153
  return !linked.has(rel) && !linked.has(rel.split("/")[0]);
7006
7154
  });
7007
7155
  }
7156
+ async findInHead(run) {
7157
+ const found = [];
7158
+ for (const record of Object.values(run.tasks)) {
7159
+ if (record.status !== "merged" || record.inHead) continue;
7160
+ if (!await this.headContains(run, record)) continue;
7161
+ record.inHead = true;
7162
+ found.push(record.taskId);
7163
+ }
7164
+ return found;
7165
+ }
7166
+ async headContains(run, record) {
7167
+ for (const repo of run.repos) {
7168
+ const entry = record.repos[repo.path];
7169
+ if (!entry?.changed) continue;
7170
+ const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
7171
+ if (!merged.ok) return false;
7172
+ }
7173
+ return true;
7174
+ }
7175
+ async snapshotTree(workspaceRoot, exclude) {
7176
+ const scan = await this.scanGroup(workspaceRoot);
7177
+ if ("refused" in scan) return null;
7178
+ const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
7179
+ if (repos.length === 0) return null;
7180
+ const snapshot = {};
7181
+ for (const repoPath of repos) {
7182
+ const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
7183
+ if (states) snapshot[repoPath] = states;
7184
+ }
7185
+ return snapshot;
7186
+ }
7187
+ async changedSince(workspaceRoot, snapshot) {
7188
+ const changed = [];
7189
+ for (const [repoPath, before] of Object.entries(snapshot)) {
7190
+ const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
7191
+ if (!now) continue;
7192
+ for (const [file, state] of Object.entries(now)) {
7193
+ if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
7194
+ }
7195
+ }
7196
+ return changed.sort();
7197
+ }
7198
+ /**
7199
+ * Each tracked file that differs from HEAD, with what it holds. `git stash
7200
+ * create` records the working tree as a commit without touching the tree,
7201
+ * the index or the stash, so a file changed again is told apart from one
7202
+ * left as it was. Null when git cannot tell — a merge in progress, say.
7203
+ */
7204
+ async trackedStates(root) {
7205
+ const stash = await this.tryGit(root, ["stash", "create"]);
7206
+ if (!stash.ok) return null;
7207
+ const commit = stash.stdout.trim();
7208
+ if (!commit) return {};
7209
+ const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
7210
+ return diff.ok ? rawDiffStates(diff.stdout) : null;
7211
+ }
7008
7212
  /**
7009
7213
  * One section per repo with changes. A repo below the workspace root gets a
7010
7214
  * header and paths prefixed with its own, so the whole reads as one patch
@@ -7022,7 +7226,10 @@ var GitWorktreeIsolation = class {
7022
7226
  }
7023
7227
  return diff;
7024
7228
  }
7025
- async mergeIntoCheckedOut(run) {
7229
+ mergeIntoCheckedOut(run) {
7230
+ return this.inTurn(() => this.mergeAll(run));
7231
+ }
7232
+ async mergeAll(run) {
7026
7233
  const partial = await this.settleLanding(run);
7027
7234
  if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
7028
7235
  const repos = await this.reposWithWork(run);
@@ -7156,7 +7363,7 @@ var GitWorktreeIsolation = class {
7156
7363
  if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
7157
7364
  });
7158
7365
  const [entry] = this.waiting.splice(pick, 1);
7159
- entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
7366
+ entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
7160
7367
  }
7161
7368
  } finally {
7162
7369
  this.draining = false;
@@ -7208,6 +7415,10 @@ var GitWorktreeIsolation = class {
7208
7415
  return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
7209
7416
  }
7210
7417
  }
7418
+ for (const repo of changed) {
7419
+ const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
7420
+ if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
7421
+ }
7211
7422
  delete run.landing;
7212
7423
  settleStatus(record, "merged");
7213
7424
  delete record.conflictRepo;
@@ -7482,6 +7693,11 @@ var GitWorktreeIsolation = class {
7482
7693
  async abortMerge(cwd) {
7483
7694
  if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
7484
7695
  }
7696
+ inTurn(fn) {
7697
+ const turn = this.turns.then(fn, fn);
7698
+ this.turns = turn.catch(() => void 0);
7699
+ return turn;
7700
+ }
7485
7701
  admin(repoKey, fn) {
7486
7702
  const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
7487
7703
  const next = prior.then(fn, fn);
@@ -8155,8 +8371,22 @@ function researchPhaseBlock(harnessMode) {
8155
8371
  "- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
8156
8372
  ].join("\n");
8157
8373
  }
8158
- var ISOLATED_PARALLELISM_RULE = "- Each AI task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
8374
+ var ISOLATED_PARALLELISM_RULE = "- Each change task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
8159
8375
  var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
8376
+ function opsTaskSection(conversation) {
8377
+ return [
8378
+ "",
8379
+ "CHANGE AND OPS TASKS:",
8380
+ "- 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?",
8381
+ '- A change task (the default) edits repository files. Omit "ops".',
8382
+ `- 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.`,
8383
+ `- 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.`,
8384
+ '- 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.',
8385
+ "- 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.",
8386
+ "- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
8387
+ ...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.`] : []
8388
+ ];
8389
+ }
8160
8390
  var SHARED_APPEND_FILE_RULE = "- Tasks that run in parallel must not each append to the same shared file (changelogs, registries, index lists) \u2014 give that edit to the final task, or to one task the others depend on.";
8161
8391
  function repoGroupSection(isolated) {
8162
8392
  if (!isolated) return [];
@@ -8236,6 +8466,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
8236
8466
  ' "taskMode": "' + modeExamples + '",',
8237
8467
  ' "autonomy": "AFK|HITL",',
8238
8468
  ' "sliceType": "AFK|HITL",',
8469
+ ' "ops": true,',
8239
8470
  ' "userStoriesCovered": ["user story text"],',
8240
8471
  ' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
8241
8472
  " }",
@@ -8261,6 +8492,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
8261
8492
  SHARED_APPEND_FILE_RULE,
8262
8493
  ...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
8263
8494
  ...repoGroupSection(variant.isolatedExecution),
8495
+ ...opsTaskSection(true),
8264
8496
  "",
8265
8497
  "RULES:",
8266
8498
  "- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
@@ -8336,6 +8568,7 @@ function corePlannerPrompt(isolatedExecution) {
8336
8568
  ' "taskMode": "{{MODE_EXAMPLES}}",',
8337
8569
  ' "autonomy": "AFK|HITL",',
8338
8570
  ' "sliceType": "AFK|HITL",',
8571
+ ' "ops": true,',
8339
8572
  ' "userStoriesCovered": ["user story text"],',
8340
8573
  ' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
8341
8574
  " }",
@@ -8363,6 +8596,7 @@ function corePlannerPrompt(isolatedExecution) {
8363
8596
  SHARED_APPEND_FILE_RULE,
8364
8597
  isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
8365
8598
  ...repoGroupSection(isolatedExecution),
8599
+ ...opsTaskSection(false),
8366
8600
  "",
8367
8601
  "RULES:",
8368
8602
  '- Mark as type "ai" any task the coding assistant can do autonomously.',
@@ -8759,7 +8993,9 @@ function parseTask(raw, runners, runnerModes, autonomousDefault = true, inherite
8759
8993
  ),
8760
8994
  autonomy,
8761
8995
  sliceType,
8762
- userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0
8996
+ userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0,
8997
+ // A subtask runs with its parent, so only a top-level AI task carries it (ADR-0020).
8998
+ ops: !inherited && taskType === "ai" && raw.ops === true
8763
8999
  });
8764
9000
  }
8765
9001
  function validateVerticalSliceShape(tasks, raw, parentTitle) {
@@ -9013,7 +9249,7 @@ function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRu
9013
9249
  }
9014
9250
 
9015
9251
  // src/services/TaskEditValidator.ts
9016
- var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy"];
9252
+ var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy", "ops"];
9017
9253
  function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
9018
9254
  if (!catalog) return { ok: true };
9019
9255
  if (assignedModel) {
@@ -9050,6 +9286,19 @@ function typeCoherenceCheck(target, changes) {
9050
9286
  }
9051
9287
  return { ok: true, clear: target.userSteps && target.userSteps.length > 0 ? ["userSteps"] : [] };
9052
9288
  }
9289
+ var NOT_STARTED = /* @__PURE__ */ new Set(["pending", "approved", "blocked"]);
9290
+ function opsCheck(tasks, target, changes) {
9291
+ if (!("ops" in changes) || !!changes.ops === !!target.ops) return null;
9292
+ if (!NOT_STARTED.has(target.status)) {
9293
+ return { ok: false, error: `"${target.title}" has started, so whether it is an ops task is fixed` };
9294
+ }
9295
+ if (!changes.ops) return null;
9296
+ if ((changes.type ?? target.type) !== "ai") return { ok: false, error: `"${target.title}" is a manual task \u2014 only an AI task can be an ops task` };
9297
+ if (tasks.some((t) => t.subtasks?.some((s) => s.id === target.id))) {
9298
+ return { ok: false, error: `"${target.title}" is a subtask, which runs with its parent \u2014 only a top-level task can be an ops task` };
9299
+ }
9300
+ return null;
9301
+ }
9053
9302
  function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
9054
9303
  const target = tasks.find((t) => t.id === taskId);
9055
9304
  if (!target) return { ok: false, error: "Task not found" };
@@ -9063,6 +9312,8 @@ function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
9063
9312
  }
9064
9313
  const coherence = typeCoherenceCheck(target, changes);
9065
9314
  if (coherence && !coherence.ok) return coherence;
9315
+ const ops = opsCheck(tasks, target, changes);
9316
+ if (ops) return ops;
9066
9317
  const runner = changes.assignedRunner ?? target.assignedRunner;
9067
9318
  const validity = checkModelAndModeValidity(runner, changes.assignedModel, changes.taskMode, catalog);
9068
9319
  if (!validity.ok) return validity;
@@ -9082,7 +9333,8 @@ var UPDATABLE_FIELDS = [
9082
9333
  "type",
9083
9334
  "userSteps",
9084
9335
  "autonomy",
9085
- "sliceType"
9336
+ "sliceType",
9337
+ "ops"
9086
9338
  ];
9087
9339
  function taskOpsProtocol(executionNote = "") {
9088
9340
  const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
@@ -9090,7 +9342,7 @@ function taskOpsProtocol(executionNote = "") {
9090
9342
  "- To modify specific tasks, reply with ONLY this JSON object:",
9091
9343
  ' {"taskOps": [',
9092
9344
  ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
9093
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
9345
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?,"ops"?},"handle"?:"<name>"},',
9094
9346
  ' {"op":"remove","taskId":"<id or #order>"},',
9095
9347
  ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
9096
9348
  ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
@@ -9142,7 +9394,7 @@ function parseTaskOpsObject(json, text2) {
9142
9394
  }
9143
9395
  return ops;
9144
9396
  }
9145
- function resolveRef(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
9397
+ function resolveRef2(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
9146
9398
  if (typeof ref !== "string" && typeof ref !== "number") return { error: `invalid reference "${String(ref)}"` };
9147
9399
  const s = String(ref).trim();
9148
9400
  const ownerIdx = handleOwner.get(s);
@@ -9193,6 +9445,14 @@ function refCollidesWithExisting(name, originalFlatAll) {
9193
9445
  if (/^\d+$/.test(orderStr) && originalFlatAll.some((t) => t.order === Number(orderStr))) return true;
9194
9446
  return originalFlatAll.some((t) => t.title === name);
9195
9447
  }
9448
+ function updatableChanges(raw) {
9449
+ const changes = {};
9450
+ for (const key of UPDATABLE_FIELDS) {
9451
+ if (raw && key in raw) changes[key] = raw[key];
9452
+ }
9453
+ if ("ops" in changes) changes.ops = changes.ops === true ? true : void 0;
9454
+ return changes;
9455
+ }
9196
9456
  function findInTasks(tasks, id) {
9197
9457
  return flattenTasks(tasks).find((t) => t.id === id);
9198
9458
  }
@@ -9347,7 +9607,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9347
9607
  const bad = [];
9348
9608
  const liveFlat = flattenTasks(tasks);
9349
9609
  for (const d of Array.isArray(deps) ? deps : []) {
9350
- const r = resolveRef(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
9610
+ const r = resolveRef2(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
9351
9611
  if ("error" in r) {
9352
9612
  bad.push(`${String(d)} (${r.error})`);
9353
9613
  continue;
@@ -9364,7 +9624,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9364
9624
  const label = `op ${i + 1} (${op.op})`;
9365
9625
  switch (op.op) {
9366
9626
  case "update": {
9367
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9627
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9368
9628
  if ("error" in ref) {
9369
9629
  errors.push(`${label}: ${ref.error}`);
9370
9630
  break;
@@ -9374,10 +9634,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9374
9634
  errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
9375
9635
  break;
9376
9636
  }
9377
- const changes = {};
9378
- for (const key of UPDATABLE_FIELDS) {
9379
- if (op.changes && key in op.changes) changes[key] = op.changes[key];
9380
- }
9637
+ const changes = updatableChanges(op.changes);
9381
9638
  if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
9382
9639
  errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
9383
9640
  break;
@@ -9438,7 +9695,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9438
9695
  break;
9439
9696
  }
9440
9697
  case "remove": {
9441
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9698
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9442
9699
  if ("error" in ref) {
9443
9700
  errors.push(`${label}: ${ref.error}`);
9444
9701
  break;
@@ -9461,7 +9718,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9461
9718
  break;
9462
9719
  }
9463
9720
  case "reorder": {
9464
- const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
9721
+ const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
9465
9722
  const badRef = refs.find((r) => "error" in r);
9466
9723
  if (badRef) {
9467
9724
  errors.push(`${label}: ${badRef.error}`);
@@ -9483,7 +9740,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9483
9740
  break;
9484
9741
  }
9485
9742
  case "merge": {
9486
- const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
9743
+ const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
9487
9744
  const badRef = refs.find((r) => "error" in r);
9488
9745
  if (badRef) {
9489
9746
  errors.push(`${label}: ${badRef.error}`);
@@ -9529,6 +9786,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9529
9786
  taskMode: spec.taskMode ?? toMerge[0].taskMode,
9530
9787
  autonomy: spec.autonomy ?? toMerge.find((t) => t.autonomy)?.autonomy,
9531
9788
  sliceType: spec.sliceType ?? toMerge.find((t) => t.sliceType)?.sliceType,
9789
+ ops: spec.ops ?? toMerge.every((t) => t.ops),
9532
9790
  userStoriesCovered: spec.userStoriesCovered ?? (toMerge.flatMap((t) => t.userStoriesCovered ?? []).length ? [...new Set(toMerge.flatMap((t) => t.userStoriesCovered ?? []))] : void 0)
9533
9791
  });
9534
9792
  const mergedId = mergedTask.id;
@@ -9552,7 +9810,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9552
9810
  break;
9553
9811
  }
9554
9812
  case "split": {
9555
- const ref = resolveRef(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
9813
+ const ref = resolveRef2(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
9556
9814
  if ("error" in ref) {
9557
9815
  errors.push(`${label}: ${ref.error}`);
9558
9816
  break;
@@ -9609,6 +9867,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9609
9867
  taskMode: spec.taskMode ?? target.taskMode,
9610
9868
  autonomy: spec.autonomy ?? target.autonomy,
9611
9869
  sliceType: spec.sliceType ?? target.sliceType,
9870
+ ops: spec.ops ?? target.ops,
9612
9871
  userStoriesCovered: spec.userStoriesCovered ?? target.userStoriesCovered
9613
9872
  });
9614
9873
  newTasks.push(nt);
@@ -9630,7 +9889,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9630
9889
  break;
9631
9890
  }
9632
9891
  case "rearm": {
9633
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9892
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9634
9893
  if ("error" in ref) {
9635
9894
  errors.push(`${label}: ${ref.error}`);
9636
9895
  break;
@@ -9644,10 +9903,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9644
9903
  errors.push(`${label}: "${target.title}" is running and cannot be re-armed`);
9645
9904
  break;
9646
9905
  }
9647
- const changes = {};
9648
- for (const key of UPDATABLE_FIELDS) {
9649
- if (op.changes && key in op.changes) changes[key] = op.changes[key];
9650
- }
9906
+ const changes = updatableChanges(op.changes);
9651
9907
  if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
9652
9908
  errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
9653
9909
  break;
@@ -10277,14 +10533,15 @@ function nonPromptingFs(fs16) {
10277
10533
  listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
10278
10534
  bash: async (command, signal) => {
10279
10535
  const dialect = resolveResearchShell().dialect;
10280
- const { tier, reason } = classifyCommand(command, { dialect });
10536
+ const policy = { dialect, cdpathSet: !!process.env.CDPATH };
10537
+ const { tier, reason } = classifyCommand(command, policy);
10281
10538
  if (tier !== "auto") {
10282
10539
  return refused2(
10283
10540
  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.`
10284
10541
  );
10285
10542
  }
10286
- const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
10287
- if (escaping) return outsideRefusal(escaping);
10543
+ const escaping = pathRefs(command, policy).find((ref) => !resolveRef(fs16.getWorkspaceRoot(), ref).inside);
10544
+ if (escaping) return outsideRefusal(escaping.path);
10288
10545
  return fs16.bash(command, signal);
10289
10546
  }
10290
10547
  };
@@ -11477,6 +11734,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
11477
11734
  lines.join("\n") + footer + runnerNote
11478
11735
  ].join("\n");
11479
11736
  }
11737
+ function renderPreviousAttempt(output) {
11738
+ const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
11739
+ return [
11740
+ "## Previous attempt",
11741
+ "",
11742
+ "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.",
11743
+ "",
11744
+ "The end of its output:",
11745
+ tail
11746
+ ].join("\n");
11747
+ }
11480
11748
  function renderCompletionMarker(task) {
11481
11749
  return `
11482
11750
 
@@ -11528,6 +11796,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
11528
11796
  const flat = flattenTasks(allTasks);
11529
11797
  const outputs = collectDirectDependencyOutputs(task, flat);
11530
11798
  if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
11799
+ if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
11531
11800
  if (opts?.tddEnabled) {
11532
11801
  blocks.push(renderTddInstruction());
11533
11802
  }
@@ -11540,9 +11809,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
11540
11809
 
11541
11810
  ${basePrompt}${marker2}`;
11542
11811
  }
11543
- function composeContinuationPrompt(task, message) {
11812
+ function composeContinuationPrompt(task, message, opts = {}) {
11544
11813
  const reminder = [
11545
- "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
11814
+ opts.ops ? "(Ordewell) You are continuing this task in the same session, in the same checkout. What your earlier attempt did outside the repository was not undone: check what already exists before acting again." : "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
11546
11815
  ];
11547
11816
  if (isHitlTask(task)) {
11548
11817
  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}.`);
@@ -11586,6 +11855,12 @@ var VerdictEngine = class {
11586
11855
  idleSince = /* @__PURE__ */ new Map();
11587
11856
  /** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
11588
11857
  idlePaused = /* @__PURE__ */ new Set();
11858
+ /**
11859
+ * Open tool approvals per structured task. They arrive mid-turn and leave the
11860
+ * status alone (ADR-0018, W1), but a task waiting on one is waiting on the
11861
+ * user, so it is not idle either.
11862
+ */
11863
+ openApprovals = /* @__PURE__ */ new Map();
11589
11864
  onVerdict(listener) {
11590
11865
  this.listeners.push(listener);
11591
11866
  }
@@ -11614,7 +11889,7 @@ var VerdictEngine = class {
11614
11889
  }
11615
11890
  /** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
11616
11891
  touchIdle(taskId, gen) {
11617
- if (this.idlePaused.has(taskId)) return;
11892
+ if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
11618
11893
  const existing = this.idleTimers.get(taskId);
11619
11894
  if (existing) clearTimeout(existing);
11620
11895
  if (this.idleSince.get(taskId)) {
@@ -11696,7 +11971,10 @@ ${line}
11696
11971
  });
11697
11972
  if (isStructuredSession(session)) {
11698
11973
  session.onEvent((event) => {
11699
- if (event.type === "turn_start" && this.generations.get(task.id) === gen) this.resumeIdle(task.id);
11974
+ if (this.generations.get(task.id) !== gen) return;
11975
+ if (event.type === "turn_start") this.resumeIdle(task.id);
11976
+ else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
11977
+ else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
11700
11978
  });
11701
11979
  }
11702
11980
  session.onExit((exitCode) => {
@@ -11707,6 +11985,18 @@ ${line}
11707
11985
  for (const l of this.listeners) l(task.id, verdict);
11708
11986
  });
11709
11987
  }
11988
+ approvalOpened(taskId, approvalId) {
11989
+ const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
11990
+ open.add(approvalId);
11991
+ this.openApprovals.set(taskId, open);
11992
+ this.clearIdle(taskId);
11993
+ }
11994
+ approvalClosed(taskId, approvalId, gen) {
11995
+ const open = this.openApprovals.get(taskId);
11996
+ if (!open?.delete(approvalId) || open.size) return;
11997
+ this.openApprovals.delete(taskId);
11998
+ this.touchIdle(taskId, gen);
11999
+ }
11710
12000
  /** Scan only the new text plus the unmatched carry, so a long run stays linear. */
11711
12001
  scanCheckpoints(taskId, session, text2) {
11712
12002
  const scan = (this.checkpointCarry.get(taskId) ?? "") + text2;
@@ -11728,6 +12018,7 @@ ${line}
11728
12018
  this.checkpointCarry.delete(taskId);
11729
12019
  this.pausedSessions.delete(taskId);
11730
12020
  this.idlePaused.delete(taskId);
12021
+ this.openApprovals.delete(taskId);
11731
12022
  this.clearIdle(taskId);
11732
12023
  }
11733
12024
  /** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
@@ -11765,6 +12056,7 @@ ${line}
11765
12056
  this.idleTimers.clear();
11766
12057
  this.idleSince.clear();
11767
12058
  this.idlePaused.clear();
12059
+ this.openApprovals.clear();
11768
12060
  this.generations.clear();
11769
12061
  }
11770
12062
  decide(task, exitCode) {
@@ -12282,6 +12574,8 @@ var PlanStore = class {
12282
12574
  _planTasks = [];
12283
12575
  _allTasks = [];
12284
12576
  _taskMap = /* @__PURE__ */ new Map();
12577
+ /** Task id → the top-level task it hangs under, itself for a top-level task. */
12578
+ _rootMap = /* @__PURE__ */ new Map();
12285
12579
  _planRunners = ["claude-code"];
12286
12580
  _onMutate = null;
12287
12581
  _executionLog = [];
@@ -12319,6 +12613,14 @@ var PlanStore = class {
12319
12613
  get(taskId) {
12320
12614
  return this._taskMap.get(taskId);
12321
12615
  }
12616
+ /**
12617
+ * Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
12618
+ * can be one; a subtask runs with the top-level task it hangs under.
12619
+ */
12620
+ isOps(taskId) {
12621
+ const root = this._rootMap.get(taskId);
12622
+ return root?.type === "ai" && root.ops === true;
12623
+ }
12322
12624
  /** A copy of the task tree, detached from the store: later status changes do not reach it. */
12323
12625
  snapshot() {
12324
12626
  return cloneTasks(this._planTasks);
@@ -12413,6 +12715,7 @@ ${taskB.description}`,
12413
12715
  assignedRunner: taskA.assignedRunner,
12414
12716
  assignedModel: taskA.assignedModel,
12415
12717
  taskMode: taskA.taskMode,
12718
+ ops: taskA.ops === true && taskB.ops === true,
12416
12719
  order: Math.min(taskA.order, taskB.order)
12417
12720
  });
12418
12721
  const mergedId = merged.id;
@@ -12442,7 +12745,8 @@ ${taskB.description}`,
12442
12745
  taskMode: spec.taskMode ?? original.taskMode,
12443
12746
  autonomy: spec.autonomy ?? original.autonomy,
12444
12747
  sliceType: spec.sliceType ?? original.sliceType,
12445
- userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
12748
+ userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
12749
+ ops: spec.ops ?? original.ops
12446
12750
  }));
12447
12751
  });
12448
12752
  const tailId = finalized[finalized.length - 1].id;
@@ -12496,8 +12800,14 @@ ${taskB.description}`,
12496
12800
  this.setStatus(id, "pending");
12497
12801
  task.verdict = void 0;
12498
12802
  task.outputSummary = void 0;
12803
+ delete task.forcedPastGate;
12499
12804
  }
12500
12805
  }
12806
+ /** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
12807
+ setForcedPastGate(id, dependencies) {
12808
+ const task = this._taskMap.get(id);
12809
+ if (task) task.forcedPastGate = dependencies;
12810
+ }
12501
12811
  /** A reason outlives nothing: any status but `awaiting_user` drops it. */
12502
12812
  setStatus(id, status) {
12503
12813
  const task = this._taskMap.get(id);
@@ -12580,6 +12890,10 @@ ${taskB.description}`,
12580
12890
  for (const task of this._allTasks) {
12581
12891
  this._taskMap.set(task.id, task);
12582
12892
  }
12893
+ this._rootMap.clear();
12894
+ for (const root of this._planTasks) {
12895
+ for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
12896
+ }
12583
12897
  }
12584
12898
  validateAssignedRunners() {
12585
12899
  for (const task of this._allTasks) {
@@ -12633,9 +12947,9 @@ var IsolationRunController = class {
12633
12947
  /** Copied paths already reported for the current run: every task gets the same copies. */
12634
12948
  reportedCopies = /* @__PURE__ */ new Set();
12635
12949
  /**
12636
- * How the open run executes; null while no run is open. A run is one
12637
- * Execute-Plan or one manual task run, from its start until it settles or
12638
- * is stopped.
12950
+ * How the open run executes; null while no run is open, `undecided` until
12951
+ * its first change task asks. A run is one Execute-Plan or one manual task
12952
+ * run, from its start until it settles or is stopped.
12639
12953
  */
12640
12954
  mode = null;
12641
12955
  /** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
@@ -12659,6 +12973,10 @@ var IsolationRunController = class {
12659
12973
  get isOpen() {
12660
12974
  return this.mode !== null;
12661
12975
  }
12976
+ /** The open run has decided whether it isolates. */
12977
+ get decided() {
12978
+ return this.mode === "isolated" || this.mode === "shared";
12979
+ }
12662
12980
  /** The open run executes in worktrees. */
12663
12981
  get isolating() {
12664
12982
  return this.mode === "isolated" && this.run !== null;
@@ -12715,13 +13033,22 @@ var IsolationRunController = class {
12715
13033
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
12716
13034
  }
12717
13035
  }
13036
+ /** Open a run if none is, leaving whether it isolates to its first change task. */
13037
+ open() {
13038
+ if (this.mode) return;
13039
+ this.mode = "undecided";
13040
+ this.listener.opened();
13041
+ }
12718
13042
  /**
12719
- * Open a run if none is: decide once, at its start, whether it executes in
12720
- * worktrees. `resume` is what a dirty tree parks until the user chooses how
12721
- * to go on. Resolves false when the run did not start.
13043
+ * Decide once per run, as its first change task starts, whether it executes
13044
+ * in worktrees (ADR-0020): an ops task needs none, so a run of only ops
13045
+ * tasks never asks. `resume` is what a dirty tree parks until the user
13046
+ * chooses how to go on. Resolves false while the run waits on that choice.
12722
13047
  */
12723
- open(resume) {
12724
- if (this.mode) return Promise.resolve(true);
13048
+ decide(resume) {
13049
+ this.open();
13050
+ if (this.decided) return Promise.resolve(true);
13051
+ if (this.blockedStart) return Promise.resolve(false);
12725
13052
  this.opening ??= this.activate(resume).finally(() => {
12726
13053
  this.opening = null;
12727
13054
  });
@@ -12736,8 +13063,8 @@ var IsolationRunController = class {
12736
13063
  * the safe rule then.
12737
13064
  */
12738
13065
  async plannerLayout() {
12739
- if (this.mode) return this.isolating && this.run ? layoutOf(this.run) : false;
12740
- const decision = await this.decide(this.workspaceRoot());
13066
+ if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
13067
+ const decision = await this.assess(this.workspaceRoot());
12741
13068
  return decision.mode === "isolated" ? decision.layout : false;
12742
13069
  }
12743
13070
  /**
@@ -12772,7 +13099,7 @@ var IsolationRunController = class {
12772
13099
  const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
12773
13100
  return { cwd: cwd2, worktree: true };
12774
13101
  }
12775
- if (!this.isolating || !this.run) {
13102
+ if (opts.ops || !this.isolating || !this.run) {
12776
13103
  await this.release(task.id, { keep: false });
12777
13104
  return { cwd: this.workspaceRoot(), worktree: false };
12778
13105
  }
@@ -12817,18 +13144,71 @@ var IsolationRunController = class {
12817
13144
  }
12818
13145
  this.listener.changed();
12819
13146
  }
12820
- /** Close the open run. An isolated one hands its integration branch over for review. */
13147
+ /**
13148
+ * Close the open run. An isolated one hands its integration branch over for
13149
+ * review — unless the user merged everything it landed at merge gates
13150
+ * already, which leaves nothing to hand over, so it is cleared up as a Merge
13151
+ * all would have.
13152
+ */
12821
13153
  async close() {
12822
13154
  const mode = this.mode;
12823
13155
  this.mode = null;
12824
- if (mode !== "isolated" || !this.run) return;
13156
+ const run = this.run;
13157
+ if (mode !== "isolated" || !run) return;
13158
+ await this.refreshInHead();
13159
+ const records = Object.values(run.tasks);
13160
+ if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
13161
+ this.tell("info", "Everything this run landed is merged into your branch already.");
13162
+ await this.clearMerged(run);
13163
+ return;
13164
+ }
12825
13165
  try {
12826
- this.listener.handoff(await this.isolation.handoff(this.run));
13166
+ this.listener.handoff(await this.isolation.handoff(run));
12827
13167
  } catch (err) {
12828
13168
  this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
12829
13169
  }
12830
13170
  this.listener.changed();
12831
13171
  }
13172
+ /**
13173
+ * Whether a task's landed work still waits to be merged into the user's
13174
+ * branch — what holds an ops or user task at its merge gate (ADR-0020). A
13175
+ * task with no record never landed anything to wait for.
13176
+ */
13177
+ awaitsMerge(taskId) {
13178
+ const record = this.run?.tasks[taskId];
13179
+ return !!record && record.status === "merged" && !record.inHead;
13180
+ }
13181
+ /**
13182
+ * Look again for landed work the user has merged — by Merge all, or by hand
13183
+ * with git. Resolves true when some was found.
13184
+ */
13185
+ async refreshInHead() {
13186
+ const run = this.run;
13187
+ if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
13188
+ const found = await this.isolation.findInHead(run).catch(() => []);
13189
+ if (found.length > 0) this.listener.changed();
13190
+ return found.length > 0;
13191
+ }
13192
+ /**
13193
+ * The tracked state of the workspace as an ops task starts, for
13194
+ * {@link filesChangedSince} (ADR-0020). Null where the check does not run:
13195
+ * a run that shares the workspace root, where every task's edits land in
13196
+ * it, or a workspace git cannot isolate at all.
13197
+ */
13198
+ async snapshotWorkspace() {
13199
+ if (this.mode === "shared") return null;
13200
+ const root = this.workspaceRoot();
13201
+ if (!this.isolating) {
13202
+ const availability = await this.isolation.isActive(root).catch(() => null);
13203
+ if (!availability || !availability.active && availability.reason !== "dirty") return null;
13204
+ }
13205
+ return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
13206
+ }
13207
+ /** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
13208
+ async filesChangedSince(snapshot) {
13209
+ if (!snapshot || this.mode === "shared") return [];
13210
+ return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
13211
+ }
12832
13212
  /**
12833
13213
  * A stop or a plan load: the open run ends without a handoff and a parked
12834
13214
  * start is dropped. `keepOpen` spares a run the scheduler is still driving.
@@ -12859,19 +13239,23 @@ var IsolationRunController = class {
12859
13239
  }
12860
13240
  /**
12861
13241
  * "Merge all": the run's integration branches into whatever the user has
12862
- * checked out, in every repo or none. Once everything merged, the run has
12863
- * nothing left to hand over, so it is cleared up and forgotten; a branch
12864
- * the user's HEAD somehow does not contain stays for the next run's sweep.
13242
+ * checked out, in every repo or none. During a run it merges what has
13243
+ * landed so far and the run goes on (ADR-0020): its branches stay for the
13244
+ * tasks still to land. Once a settled run merged everything, it has nothing
13245
+ * left to hand over, so it is cleared up and forgotten; a branch the user's
13246
+ * HEAD somehow does not contain stays for the next run's sweep.
12865
13247
  */
12866
13248
  async merge() {
12867
13249
  const run = this.requireRun();
13250
+ const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
12868
13251
  const result = await this.isolation.mergeIntoCheckedOut(run);
12869
13252
  const branch = integrationBranchNameOf(run);
12870
13253
  const group = run.repos.some((r) => r.path !== SELF_REPO);
12871
- const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
12872
13254
  const { level, message } = describeMergeResult(result, branch, group, repaired);
12873
13255
  this.tell(level, message);
12874
- if (result.outcome === "merged") await this.clearMerged(run);
13256
+ if (result.outcome !== "merged") return result;
13257
+ await this.refreshInHead();
13258
+ if (!this.isOpen) await this.clearMerged(run);
12875
13259
  return result;
12876
13260
  }
12877
13261
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
@@ -12903,7 +13287,7 @@ var IsolationRunController = class {
12903
13287
  this.resolvers = {};
12904
13288
  this.listener.changed();
12905
13289
  }
12906
- async decide(root) {
13290
+ async assess(root) {
12907
13291
  const availability = await this.isolation.isActive(root);
12908
13292
  const continued = this.continuableRun(root);
12909
13293
  const continuing = continued !== null;
@@ -12918,7 +13302,7 @@ var IsolationRunController = class {
12918
13302
  }
12919
13303
  async activate(resume) {
12920
13304
  const root = this.workspaceRoot();
12921
- const decision = await this.decide(root);
13305
+ const decision = await this.assess(root);
12922
13306
  if (decision.mode === "blocked") {
12923
13307
  this.blockedStart = resume;
12924
13308
  this.blockedRepos = decision.repos;
@@ -12945,8 +13329,8 @@ var IsolationRunController = class {
12945
13329
  return true;
12946
13330
  }
12947
13331
  begin(mode) {
13332
+ this.open();
12948
13333
  this.mode = mode;
12949
- this.listener.opened();
12950
13334
  }
12951
13335
  /** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
12952
13336
  async sweep() {
@@ -13293,8 +13677,9 @@ var MessageQueue = class {
13293
13677
  // src/services/readiness.ts
13294
13678
  function selectReadyTasks(input) {
13295
13679
  const { store, onHold, runs, active, maxParallel } = input;
13296
- if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
13680
+ if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
13297
13681
  const availableSlots = maxParallel - active;
13682
+ const gated = [];
13298
13683
  const candidates = store.allTasks.filter((t) => {
13299
13684
  if (t.status !== "pending" && t.status !== "approved") return false;
13300
13685
  if (t.type === "user") return false;
@@ -13302,13 +13687,21 @@ function selectReadyTasks(input) {
13302
13687
  if (onHold.has(t.id)) return false;
13303
13688
  if (isBlocked(t, store)) return false;
13304
13689
  if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
13690
+ if (store.isOps(t.id)) {
13691
+ if (input.merging) return false;
13692
+ if (mergeGate(t, store, runs).length > 0) {
13693
+ gated.push(t);
13694
+ return false;
13695
+ }
13696
+ }
13305
13697
  return true;
13306
13698
  });
13307
- const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, store, onHold, runs) }));
13699
+ const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
13308
13700
  return {
13309
13701
  ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
13310
13702
  candidateCount: candidates.length,
13311
- excluded
13703
+ excluded,
13704
+ gated
13312
13705
  };
13313
13706
  }
13314
13707
  function isBlocked(task, store) {
@@ -13321,12 +13714,19 @@ function dependencyMet(depId, store, runs) {
13321
13714
  const record = runs.openRecord(depId);
13322
13715
  return !record || record.status === "merged";
13323
13716
  }
13324
- function exclusionReasons(task, store, onHold, runs) {
13717
+ function mergeGate(task, store, runs) {
13718
+ if (task.type !== "user" && !store.isOps(task.id)) return [];
13719
+ return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
13720
+ }
13721
+ function exclusionReasons(task, input) {
13722
+ const { store, onHold, runs } = input;
13325
13723
  const reasons = [];
13326
13724
  if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
13327
13725
  if (onHold.has(task.id)) reasons.push("on-hold");
13328
13726
  if (isBlocked(task, store)) reasons.push("blocked");
13329
13727
  if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
13728
+ if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
13729
+ if (input.merging && store.isOps(task.id)) reasons.push("merging");
13330
13730
  return reasons;
13331
13731
  }
13332
13732
 
@@ -15320,9 +15720,502 @@ ${item.text}` : item.text });
15320
15720
 
15321
15721
  // src/services/harness/OpenCodeAdapter.ts
15322
15722
  var import_crypto2 = require("crypto");
15723
+
15724
+ // src/services/harness/OpenCodeV2.ts
15725
+ var STREAM_CONNECT_TIMEOUT_MS = 5e3;
15726
+ var ACTIVE_POLL_INTERVAL_MS = 1e3;
15727
+ var IDLE_POLLS_BEFORE_START = 3;
15728
+ var PLANNER_RULES = [
15729
+ { action: "question", resource: "*", effect: "deny" },
15730
+ { action: "edit", resource: "*", effect: "deny" }
15731
+ ];
15732
+ var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
15733
+ var AUTO_APPROVALS = "auto";
15734
+ function newTurnState() {
15735
+ 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() };
15736
+ }
15737
+ function splitModelId(id) {
15738
+ const slash = id.indexOf("/");
15739
+ if (slash <= 0 || slash === id.length - 1) return null;
15740
+ return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
15741
+ }
15742
+ function delay(ms, signal) {
15743
+ return new Promise((resolve7) => {
15744
+ const timer = setTimeout(resolve7, ms);
15745
+ timer.unref?.();
15746
+ signal?.addEventListener("abort", () => {
15747
+ clearTimeout(timer);
15748
+ resolve7();
15749
+ }, { once: true });
15750
+ });
15751
+ }
15752
+ function contentText(content) {
15753
+ return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
15754
+ }
15755
+ function subagentDigest(output) {
15756
+ const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
15757
+ return inner ? inner[1] : output;
15758
+ }
15759
+ function permissionReply(decision) {
15760
+ if (decision.decision === "allow") return { decision: "once" };
15761
+ if (decision.decision === "allowForTask") return { decision: "always" };
15762
+ const note = decision.note?.trim();
15763
+ return note ? { decision: "reject", message: note } : { decision: "reject" };
15764
+ }
15765
+ var OpenCodeV2 = class {
15766
+ constructor(host, opts, role) {
15767
+ this.host = host;
15768
+ this.opts = opts;
15769
+ this.role = role;
15770
+ this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
15771
+ const split = opts.model ? splitModelId(opts.model) : null;
15772
+ const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
15773
+ if (split) this.model = { ...split, ...variant ? { variant } : {} };
15774
+ }
15775
+ host;
15776
+ opts;
15777
+ role;
15778
+ sessionId = null;
15779
+ model = null;
15780
+ agent;
15781
+ turn = null;
15782
+ turnHasText = false;
15783
+ interruptRequested = false;
15784
+ /** A task's requests waiting for an answer, by request id, with the session that asked. */
15785
+ openPermissions = /* @__PURE__ */ new Map();
15786
+ nativeSessionId() {
15787
+ return this.sessionId;
15788
+ }
15789
+ async start() {
15790
+ const resume = this.opts.resumeSessionId;
15791
+ if (resume) {
15792
+ const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
15793
+ if (existing?.data?.id) {
15794
+ this.sessionId = existing.data.id;
15795
+ if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
15796
+ await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
15797
+ await this.applyInstructions();
15798
+ return;
15799
+ }
15800
+ if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
15801
+ }
15802
+ const created = await this.host.json("POST", "/api/session", {
15803
+ agent: this.agent,
15804
+ ...this.model ? { model: this.model } : {},
15805
+ permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
15806
+ });
15807
+ if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
15808
+ this.sessionId = created.data.id;
15809
+ await this.applyInstructions();
15810
+ }
15811
+ /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
15812
+ async applyInstructions() {
15813
+ if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
15814
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
15815
+ if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
15816
+ }
15817
+ async send(message, onEvent, signal, onActivity) {
15818
+ if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
15819
+ if (this.host.isExited()) {
15820
+ onEvent({ type: "error", message: this.host.exitMessage() });
15821
+ return;
15822
+ }
15823
+ const state = newTurnState();
15824
+ this.turnHasText = false;
15825
+ this.interruptRequested = false;
15826
+ let markDone = () => {
15827
+ };
15828
+ const ended = new Promise((resolve7) => {
15829
+ markDone = resolve7;
15830
+ });
15831
+ const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
15832
+ turn.done = true;
15833
+ markDone();
15834
+ } };
15835
+ this.turn = turn;
15836
+ const closeStream = await this.openStream(state, turn, onEvent, onActivity);
15837
+ const poll = new AbortController();
15838
+ try {
15839
+ try {
15840
+ await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
15841
+ } catch (err) {
15842
+ if (signal?.aborted) return;
15843
+ onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
15844
+ return;
15845
+ }
15846
+ void this.pollActive(turn, poll.signal);
15847
+ const aborted = new Promise((resolve7) => {
15848
+ if (signal?.aborted) resolve7();
15849
+ signal?.addEventListener("abort", () => resolve7(), { once: true });
15850
+ });
15851
+ await Promise.race([turn.ended, aborted, this.host.processEnded]);
15852
+ if (signal?.aborted) return;
15853
+ if (!turn.done) {
15854
+ onEvent({ type: "error", message: this.host.exitMessage() });
15855
+ return;
15856
+ }
15857
+ await this.readBack(state, onEvent);
15858
+ for (const id of [...this.openPermissions.keys()]) {
15859
+ this.openPermissions.delete(id);
15860
+ onEvent({ type: "permission_cancelled", id });
15861
+ }
15862
+ if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
15863
+ else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
15864
+ else onEvent({ type: "turn_end" });
15865
+ } finally {
15866
+ this.turn = null;
15867
+ poll.abort();
15868
+ await closeStream();
15869
+ }
15870
+ }
15871
+ /**
15872
+ * The stream can drop the end frame while still delivering the rest, so the
15873
+ * turn also asks which sessions are running. A session that is not, after
15874
+ * this turn's work was seen, is over; one never seen working after a few
15875
+ * polls had already finished before the stream connected.
15876
+ */
15877
+ async pollActive(turn, signal) {
15878
+ let idlePolls = 0;
15879
+ while (!turn.done && !signal.aborted) {
15880
+ await delay(ACTIVE_POLL_INTERVAL_MS, signal);
15881
+ if (turn.done || signal.aborted) return;
15882
+ const active = await this.host.json("GET", "/api/session/active").catch(() => null);
15883
+ if (!active?.data) continue;
15884
+ if (this.sessionId && this.sessionId in active.data) {
15885
+ turn.live = true;
15886
+ idlePolls = 0;
15887
+ continue;
15888
+ }
15889
+ idlePolls++;
15890
+ if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
15891
+ const messages = await this.messages();
15892
+ const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
15893
+ if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
15894
+ turn.outcome ??= "succeeded";
15895
+ if (!turn.done) turn.finish();
15896
+ }
15897
+ }
15898
+ }
15899
+ async messages() {
15900
+ const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
15901
+ return Array.isArray(response?.data) ? response.data : null;
15902
+ }
15903
+ /**
15904
+ * Whatever of the turn's own replies the stream missed. A message whose text
15905
+ * the stream delivered adds nothing here; one that lost its text frames still
15906
+ * reaches the plain-text channel, where the done marker is looked for.
15907
+ */
15908
+ async readBack(state, onEvent) {
15909
+ const messages = await this.messages();
15910
+ if (!messages) return;
15911
+ const latestUser = messages.findIndex((m) => m.type === "user");
15912
+ const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
15913
+ for (const message of [...own].reverse()) {
15914
+ if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
15915
+ (message.content ?? []).forEach((part, index) => {
15916
+ if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
15917
+ });
15918
+ }
15919
+ }
15920
+ /**
15921
+ * Abort the running turn, keeping the server and its session. OpenCode
15922
+ * acknowledges with `session.execution.interrupted`, which also ends the turn.
15923
+ */
15924
+ async interrupt(timeoutMs) {
15925
+ if (!this.sessionId || this.host.isExited()) return false;
15926
+ const turn = this.turn;
15927
+ this.interruptRequested = true;
15928
+ const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
15929
+ if (!response) return false;
15930
+ if (!turn || turn.done) return true;
15931
+ let timer;
15932
+ const timedOut = new Promise((resolve7) => {
15933
+ timer = setTimeout(() => resolve7(false), timeoutMs);
15934
+ timer.unref?.();
15935
+ });
15936
+ const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
15937
+ clearTimeout(timer);
15938
+ return acknowledged;
15939
+ }
15940
+ answerPermission(id, decision) {
15941
+ const sessionId = this.openPermissions.get(id);
15942
+ if (sessionId === void 0) return false;
15943
+ this.openPermissions.delete(id);
15944
+ void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
15945
+ });
15946
+ return true;
15947
+ }
15948
+ async replyPermission(id, sessionId, reply) {
15949
+ await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
15950
+ }
15951
+ async openStream(state, turn, onEvent, onActivity) {
15952
+ const streamAbort = new AbortController();
15953
+ let connected = () => {
15954
+ };
15955
+ const ready = new Promise((resolve7) => {
15956
+ connected = resolve7;
15957
+ });
15958
+ const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
15959
+ await Promise.race([ready, new Promise((r) => {
15960
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
15961
+ t.unref?.();
15962
+ })]);
15963
+ return async () => {
15964
+ streamAbort.abort();
15965
+ await live.catch(() => {
15966
+ });
15967
+ };
15968
+ }
15969
+ async streamEvents(signal, onFrame, onConnected, onActivity) {
15970
+ const response = await this.host.openEvents(signal).catch(() => null);
15971
+ const body = response?.body;
15972
+ if (!body) {
15973
+ onConnected();
15974
+ return;
15975
+ }
15976
+ onConnected();
15977
+ const reader = body.getReader();
15978
+ const decoder = new TextDecoder();
15979
+ let buffer = "";
15980
+ for (; ; ) {
15981
+ const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
15982
+ if (done) return;
15983
+ onActivity?.();
15984
+ buffer += decoder.decode(value, { stream: true });
15985
+ let newline;
15986
+ while ((newline = buffer.indexOf("\n")) >= 0) {
15987
+ const line = buffer.slice(0, newline).trim();
15988
+ buffer = buffer.slice(newline + 1);
15989
+ if (!line.startsWith("data:")) continue;
15990
+ try {
15991
+ onFrame(JSON.parse(line.slice(5).trim()));
15992
+ } catch {
15993
+ }
15994
+ }
15995
+ }
15996
+ }
15997
+ /**
15998
+ * One frame. Only this session and its children are followed: the stream is
15999
+ * the whole server's, and another client's session is none of this turn's business.
16000
+ */
16001
+ onFrame(frame, state, turn, onEvent) {
16002
+ const data = frame.data;
16003
+ if (!data || !frame.type) return;
16004
+ if (frame.type === "session.created") {
16005
+ if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
16006
+ return;
16007
+ }
16008
+ const session = data.sessionID;
16009
+ if (!session) return;
16010
+ const isChild = session !== this.sessionId;
16011
+ if (isChild && !state.children.has(session)) return;
16012
+ if (!isChild) {
16013
+ if (frame.type === "session.execution.started") {
16014
+ turn.live = true;
16015
+ return;
16016
+ }
16017
+ if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
16018
+ if (!turn.live || turn.done) return;
16019
+ turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
16020
+ if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
16021
+ turn.finish();
16022
+ return;
16023
+ }
16024
+ }
16025
+ if (frame.type === "permission.asked") {
16026
+ if (this.role === "task") this.askPermission(data, session, state, onEvent);
16027
+ else this.denyPermission(data, session, state, onEvent);
16028
+ return;
16029
+ }
16030
+ if (frame.type === "permission.replied") {
16031
+ const id = data.requestID;
16032
+ if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
16033
+ return;
16034
+ }
16035
+ let subagentId;
16036
+ if (isChild) {
16037
+ const owner = state.children.get(session);
16038
+ if (!owner) {
16039
+ state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
16040
+ return;
16041
+ }
16042
+ subagentId = owner;
16043
+ }
16044
+ this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
16045
+ }
16046
+ onContentFrame(frame, data, state, turn, onEvent, subagentId) {
16047
+ const message = data.assistantMessageID ?? "";
16048
+ switch (frame.type) {
16049
+ case "session.step.started":
16050
+ if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
16051
+ return;
16052
+ case "session.reasoning.delta":
16053
+ if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
16054
+ return;
16055
+ case "session.reasoning.ended": {
16056
+ const key = `reasoning:${message}:${data.ordinal ?? 0}`;
16057
+ if (!data.text || state.seen.has(key)) return;
16058
+ state.seen.add(key);
16059
+ onEvent({ type: "thinking", text: data.text, subagentId });
16060
+ return;
16061
+ }
16062
+ case "session.text.delta":
16063
+ if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
16064
+ return;
16065
+ case "session.text.ended":
16066
+ if (subagentId || !data.text) return;
16067
+ if (message) state.streamedMessages.add(message);
16068
+ this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
16069
+ return;
16070
+ case "session.tool.input.started":
16071
+ if (data.id && data.name) state.toolNames.set(data.id, data.name);
16072
+ return;
16073
+ case "session.tool.called": {
16074
+ const id = data.id;
16075
+ if (!id || state.seen.has(`call:${id}`)) return;
16076
+ state.seen.add(`call:${id}`);
16077
+ const input = data.input ?? {};
16078
+ const name = state.toolNames.get(id) ?? "tool";
16079
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
16080
+ state.briefs.set(id, brief);
16081
+ onEvent({ type: "tool_call", id, name, args: input, subagentId });
16082
+ return;
16083
+ }
16084
+ case "session.tool.progress":
16085
+ if (!subagentId) this.adoptChild(data, state, onEvent, turn);
16086
+ return;
16087
+ case "session.tool.success":
16088
+ case "session.tool.failed": {
16089
+ const id = data.id;
16090
+ if (!id || state.seen.has(`result:${id}`)) return;
16091
+ state.seen.add(`result:${id}`);
16092
+ const success = frame.type === "session.tool.success";
16093
+ const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
16094
+ onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
16095
+ if (!subagentId) this.finishChild(id, success, output, state, onEvent);
16096
+ return;
16097
+ }
16098
+ case "session.step.ended":
16099
+ case "session.step.failed":
16100
+ this.countUsage(data, state, onEvent, subagentId);
16101
+ return;
16102
+ default:
16103
+ }
16104
+ }
16105
+ adoptChild(data, state, onEvent, turn) {
16106
+ const child = data.metadata?.sessionID;
16107
+ const callId = data.id;
16108
+ if (!child || !callId || state.children.get(child)) return;
16109
+ const model = state.stepModels.get(data.assistantMessageID ?? "");
16110
+ onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
16111
+ state.children.set(child, callId);
16112
+ const held = state.heldFrames.get(child) ?? [];
16113
+ state.heldFrames.delete(child);
16114
+ for (const frame of held) this.onFrame(frame, state, turn, onEvent);
16115
+ }
16116
+ finishChild(callId, success, output, state, onEvent) {
16117
+ if (![...state.children.values()].includes(callId)) return;
16118
+ onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
16119
+ }
16120
+ /** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
16121
+ countUsage(data, state, onEvent, subagentId) {
16122
+ const message = data.assistantMessageID;
16123
+ const tokens = data.tokens;
16124
+ if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
16125
+ state.seen.add(`usage:${message}`);
16126
+ const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
16127
+ const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
16128
+ if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
16129
+ const record = { source: "opencode", ...prompt, outputTokens };
16130
+ const model = state.stepModels.get(message);
16131
+ if (model) record.model = model;
16132
+ if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
16133
+ if (subagentId) record.subagentId = subagentId;
16134
+ onEvent({ type: "usage", record });
16135
+ }
16136
+ /** 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. */
16137
+ emitText(id, text2, state, onEvent) {
16138
+ if (state.seen.has(`text:${id}`)) return;
16139
+ state.seen.add(`text:${id}`);
16140
+ if (!text2.trim()) return;
16141
+ const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
16142
+ onEvent({ type: "assistant_text", text: `${lead}${text2}` });
16143
+ this.turnHasText = true;
16144
+ }
16145
+ /**
16146
+ * Stream one piece of a reply text. The paragraph break goes out with the
16147
+ * first visible delta, so the deltas add up to exactly the text the completed
16148
+ * run then re-sends; a run that is only whitespace so far is held back.
16149
+ */
16150
+ onTextDelta(id, delta, state, onEvent) {
16151
+ if (state.seen.has(`text:${id}`)) return;
16152
+ const run = state.textRuns.get(id) ?? { held: "", lead: null };
16153
+ state.textRuns.set(id, run);
16154
+ if (run.lead !== null) {
16155
+ onEvent({ type: "assistant_text_delta", text: delta });
16156
+ return;
16157
+ }
16158
+ run.held += delta;
16159
+ if (!run.held.trim()) return;
16160
+ run.lead = this.turnHasText ? "\n\n" : "";
16161
+ this.turnHasText = true;
16162
+ onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
16163
+ }
16164
+ permissionRequest(ask) {
16165
+ const id = ask.id;
16166
+ if (!id) return null;
16167
+ const scope = (ask.resources ?? []).join(", ");
16168
+ return {
16169
+ type: "permission_request",
16170
+ id,
16171
+ name: ask.action ?? "permission",
16172
+ detail: JSON.stringify(scope ? { scope } : {}),
16173
+ input: ask.resources ? { resources: ask.resources } : {},
16174
+ // The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
16175
+ ...ask.save?.length ? { suggestions: ask.save } : {},
16176
+ ...ask.source?.id ? { toolUseId: ask.source.id } : {}
16177
+ };
16178
+ }
16179
+ /**
16180
+ * Deny one planner request. OpenCode blocks the turn until it is answered, so
16181
+ * this must answer: `reject` rather than a silent drop is the "absent answer
16182
+ * is a denial" invariant ADR-0008 states. The refusal is announced so the
16183
+ * timeline shows the planner reaching for something it may not have.
16184
+ */
16185
+ denyPermission(ask, session, state, onEvent) {
16186
+ const request = this.permissionRequest(ask);
16187
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
16188
+ state.seen.add(`perm:${request.id}`);
16189
+ onEvent(request);
16190
+ void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
16191
+ });
16192
+ }
16193
+ /**
16194
+ * A task's request. Under a mode whose manifest sets `approvals: auto` it is
16195
+ * answered at once with what `opencode run --auto` answers, so the same plan
16196
+ * behaves the same on both transports (ADR-0001), and announced already
16197
+ * decided so the log still shows it. Any other mode leaves it open for an
16198
+ * approval card.
16199
+ */
16200
+ askPermission(ask, session, state, onEvent) {
16201
+ const request = this.permissionRequest(ask);
16202
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
16203
+ state.seen.add(`perm:${request.id}`);
16204
+ if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
16205
+ onEvent({ ...request, decided: { decision: "allow" } });
16206
+ void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
16207
+ });
16208
+ return;
16209
+ }
16210
+ this.openPermissions.set(request.id, session);
16211
+ onEvent(request);
16212
+ }
16213
+ };
16214
+
16215
+ // src/services/harness/OpenCodeAdapter.ts
15323
16216
  var SERVER_READY_TIMEOUT_MS = 3e4;
15324
16217
  var STDERR_TAIL_CHARS2 = 4e3;
15325
- var STREAM_CONNECT_TIMEOUT_MS = 5e3;
16218
+ var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
15326
16219
  var RECOVERY_POLL_INTERVAL_MS = 2e3;
15327
16220
  var RECOVERY_TIMEOUT_MS = 9e5;
15328
16221
  var STATUS_POLL_INTERVAL_MS = 1e3;
@@ -15335,8 +16228,8 @@ var DISABLED_TOOLS = {
15335
16228
  todowrite: false
15336
16229
  };
15337
16230
  var TASK_DISABLED_TOOLS = { question: false };
15338
- var AUTO_APPROVALS = "auto";
15339
- function splitModelId(id) {
16231
+ var AUTO_APPROVALS2 = "auto";
16232
+ function splitModelId2(id) {
15340
16233
  const slash = id.indexOf("/");
15341
16234
  if (slash <= 0 || slash === id.length - 1) return null;
15342
16235
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
@@ -15392,7 +16285,7 @@ function unwrapFileToolOutput(output) {
15392
16285
  const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
15393
16286
  return inner ? inner[1] : output;
15394
16287
  }
15395
- function delay(ms, signal) {
16288
+ function delay2(ms, signal) {
15396
16289
  return new Promise((resolve7) => {
15397
16290
  const timer = setTimeout(resolve7, ms);
15398
16291
  timer.unref?.();
@@ -15402,10 +16295,10 @@ function delay(ms, signal) {
15402
16295
  }, { once: true });
15403
16296
  });
15404
16297
  }
15405
- function newTurnState() {
16298
+ function newTurnState2() {
15406
16299
  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() };
15407
16300
  }
15408
- function permissionReply(decision) {
16301
+ function permissionReply2(decision) {
15409
16302
  if (decision.decision === "allow") return { reply: "once" };
15410
16303
  if (decision.decision === "allowForTask") return { reply: "always" };
15411
16304
  const note = decision.note?.trim();
@@ -15442,6 +16335,8 @@ var OpenCodeAdapter = class {
15442
16335
  interruptRequested = false;
15443
16336
  /** A task's requests waiting for an answer, by request id, with the session that asked. */
15444
16337
  openPermissions = /* @__PURE__ */ new Map();
16338
+ /** Set once the server turns out to speak the 2.x API, which then owns the session. */
16339
+ v2 = null;
15445
16340
  async start(opts) {
15446
16341
  if (opts.kind === "task") this.task = opts;
15447
16342
  else this.planner = opts;
@@ -15456,14 +16351,14 @@ var OpenCodeAdapter = class {
15456
16351
  throw new ExecutableNotFoundError("opencode", PATH);
15457
16352
  }
15458
16353
  const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
15459
- const password = opts.kind === "task" ? (0, import_crypto2.randomBytes)(24).toString("base64url") : null;
16354
+ const password = (0, import_crypto2.randomBytes)(24).toString("base64url");
15460
16355
  this.process = this.deps.spawn(launch.file, launch.args, {
15461
- env: runnerEnv(PATH, password ? { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password } : workspaceEnv),
16356
+ env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
15462
16357
  stdio: ["pipe", "pipe", "pipe"],
15463
16358
  cwd: opts.cwd,
15464
16359
  windowsVerbatimArguments: launch.verbatim
15465
16360
  });
15466
- if (password) this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
16361
+ this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
15467
16362
  this.process.stdin?.on("error", () => {
15468
16363
  });
15469
16364
  const banner = new Promise((resolve7) => {
@@ -15500,6 +16395,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
15500
16395
 
15501
16396
  ${this.stderrTail.trim()}` : ""}`);
15502
16397
  }
16398
+ if (await this.speaksV2()) {
16399
+ this.v2 = new OpenCodeV2({
16400
+ request: (method, path27, body, signal) => this.request(method, path27, body, signal),
16401
+ json: (method, path27, body, signal) => this.json(method, path27, body, signal),
16402
+ openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
16403
+ processEnded: this.processEnded,
16404
+ isExited: () => this.exited,
16405
+ exitMessage: () => this.exitMessage()
16406
+ }, opts, this.role());
16407
+ try {
16408
+ await this.v2.start();
16409
+ } catch (err) {
16410
+ this.dispose();
16411
+ throw err;
16412
+ }
16413
+ this.sessionId = this.v2.nativeSessionId();
16414
+ return;
16415
+ }
15503
16416
  if (opts.resumeSessionId) {
15504
16417
  const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
15505
16418
  if (existing?.id) {
@@ -15523,12 +16436,17 @@ ${this.stderrTail.trim()}` : ""}`);
15523
16436
  onEvent({ type: "error", message: this.exitMessage() });
15524
16437
  return;
15525
16438
  }
16439
+ if (this.v2) {
16440
+ await this.v2.send(message, onEvent, signal, onActivity);
16441
+ if (signal?.aborted) this.dispose();
16442
+ return;
16443
+ }
15526
16444
  if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
15527
- const turn = newTurnState();
16445
+ const turn = newTurnState2();
15528
16446
  this.turnHasText = false;
15529
16447
  const closeStream = await this.openStream(turn, onEvent, onActivity);
15530
16448
  try {
15531
- const model = this.planner?.model ? splitModelId(this.planner.model) : null;
16449
+ const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
15532
16450
  const body = {
15533
16451
  parts: [{ type: "text", text: message }],
15534
16452
  // The read-only guarantee: OpenCode's own plan agent has no write tools.
@@ -15569,7 +16487,7 @@ ${this.stderrTail.trim()}` : ""}`);
15569
16487
  * {@link recoverReply}, never applies.
15570
16488
  */
15571
16489
  async sendTask(task, message, onEvent, signal, onActivity) {
15572
- const state = newTurnState();
16490
+ const state = newTurnState2();
15573
16491
  this.turnHasText = false;
15574
16492
  this.interruptRequested = false;
15575
16493
  let markDone = () => {
@@ -15585,7 +16503,7 @@ ${this.stderrTail.trim()}` : ""}`);
15585
16503
  const closeStream = await this.openStream(state, onEvent, onActivity);
15586
16504
  const poll = new AbortController();
15587
16505
  try {
15588
- const model = task.model ? splitModelId(task.model) : null;
16506
+ const model = task.model ? splitModelId2(task.model) : null;
15589
16507
  const body = {
15590
16508
  parts: [{ type: "text", text: message }],
15591
16509
  // The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
@@ -15665,7 +16583,7 @@ ${this.stderrTail.trim()}` : ""}`);
15665
16583
  */
15666
16584
  async pollStatus(turn, signal) {
15667
16585
  while (!turn.done && !signal.aborted) {
15668
- await delay(STATUS_POLL_INTERVAL_MS, signal);
16586
+ await delay2(STATUS_POLL_INTERVAL_MS, signal);
15669
16587
  if (turn.done || signal.aborted) return;
15670
16588
  const busy = await this.sessionBusy();
15671
16589
  if (busy === true) turn.live = true;
@@ -15712,6 +16630,7 @@ ${this.stderrTail.trim()}` : ""}`);
15712
16630
  */
15713
16631
  async interrupt(timeoutMs) {
15714
16632
  if (!this.process || !this.sessionId || this.exited) return false;
16633
+ if (this.v2) return this.v2.interrupt(timeoutMs);
15715
16634
  const turn = this.taskTurn;
15716
16635
  this.interruptRequested = true;
15717
16636
  const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
@@ -15732,13 +16651,23 @@ ${this.stderrTail.trim()}` : ""}`);
15732
16651
  void this.processEnded.then(() => listener(this.exitCode));
15733
16652
  }
15734
16653
  answerPermission(id, decision) {
16654
+ if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
15735
16655
  const sessionId = this.openPermissions.get(id);
15736
16656
  if (sessionId === void 0 || !this.process) return false;
15737
16657
  this.openPermissions.delete(id);
15738
- void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
16658
+ void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
15739
16659
  });
15740
16660
  return true;
15741
16661
  }
16662
+ /**
16663
+ * OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
16664
+ * only 2.x answers `/api/info` with its version. A 1.x server — or one that
16665
+ * answers with anything else — keeps the 1.x protocol this class speaks.
16666
+ */
16667
+ async speaksV2() {
16668
+ const info = await this.json("GET", "/api/info").catch(() => null);
16669
+ return typeof info === "object" && info !== null && typeof info.version === "string";
16670
+ }
15742
16671
  role() {
15743
16672
  return this.task ? "task" : "planner";
15744
16673
  }
@@ -15789,7 +16718,7 @@ ${this.stderrTail.trim()}` : ""}`);
15789
16718
  if (pending?.info?.time?.completed || pending?.info?.error) return pending;
15790
16719
  if (pending) onActivity?.();
15791
16720
  if (Date.now() >= deadline) return null;
15792
- await delay(RECOVERY_POLL_INTERVAL_MS, signal);
16721
+ await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
15793
16722
  }
15794
16723
  }
15795
16724
  newestAssistantId(messages) {
@@ -16002,7 +16931,7 @@ ${this.stderrTail.trim()}` : ""}`);
16002
16931
  ...ask.always?.length ? { suggestions: ask.always } : {},
16003
16932
  ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
16004
16933
  };
16005
- if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS) {
16934
+ if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
16006
16935
  onEvent({ ...request, decided: { decision: "allow" } });
16007
16936
  void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
16008
16937
  });
@@ -16036,7 +16965,7 @@ ${this.stderrTail.trim()}` : ""}`);
16036
16965
  });
16037
16966
  const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
16038
16967
  await Promise.race([streamReady, new Promise((r) => {
16039
- const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
16968
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
16040
16969
  t.unref?.();
16041
16970
  })]);
16042
16971
  return async () => {
@@ -16217,6 +17146,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
16217
17146
  * pending and nothing ran until the user also re-ran the whole plan.
16218
17147
  */
16219
17148
  haltedByFailure = false;
17149
+ /** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
17150
+ merging = false;
17151
+ /** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
17152
+ gateNotice = "";
16220
17153
  /** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
16221
17154
  workspaceEnv;
16222
17155
  /** What the workspace env has already warned about, so a run says it once, not per task. */
@@ -16237,6 +17170,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16237
17170
  observers = [];
16238
17171
  tddEnabled;
16239
17172
  readRunnerTransport;
17173
+ previousAttemptFromLog;
16240
17174
  /**
16241
17175
  * The setting as the latest run copied it when it opened. Every spawn of
16242
17176
  * that run uses this, so flipping the setting mid-run changes the next run
@@ -16256,6 +17190,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16256
17190
  this.workspaceEnv = deps.workspaceEnv;
16257
17191
  this.tddEnabled = deps.tddEnabled;
16258
17192
  this.readRunnerTransport = deps.runnerTransport;
17193
+ this.previousAttemptFromLog = deps.previousAttemptFromLog;
16259
17194
  this.store.onMutate = () => this.emit("onTaskChanged");
16260
17195
  this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
16261
17196
  this.verifier.onCheckpoint((taskId, summary) => {
@@ -16311,7 +17246,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
16311
17246
  workspaceRoot,
16312
17247
  workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
16313
17248
  tddEnabled: options.tddEnabled ?? (() => false),
16314
- runnerTransport: options.runnerTransport ?? (() => "terminal")
17249
+ runnerTransport: options.runnerTransport ?? (() => "terminal"),
17250
+ previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
16315
17251
  });
16316
17252
  return orchestrator;
16317
17253
  }
@@ -16319,6 +17255,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
16319
17255
  getIdleSince(taskId) {
16320
17256
  return this.verifier.getIdleSince(taskId);
16321
17257
  }
17258
+ /**
17259
+ * What an ops retry is told about the attempt before. The saved log is read
17260
+ * whether or not the session was reloaded, so both behave alike; the terminal
17261
+ * buffer is the fallback for a runner that has no log. A reload clears the
17262
+ * attempt count along with the buffer, so a retry made since is what says
17263
+ * there was an attempt to report on.
17264
+ */
17265
+ opsPreviousAttempt(taskId) {
17266
+ const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
17267
+ if (saved !== void 0) return saved;
17268
+ return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
17269
+ }
16322
17270
  /** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
16323
17271
  getLiveOutput(taskId, opts) {
16324
17272
  return this.output.liveTail(taskId, opts);
@@ -16525,9 +17473,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
16525
17473
  async reviewRunDiff() {
16526
17474
  return this.runs.reviewDiff();
16527
17475
  }
16528
- /** "Merge all"; a run that merged everything is cleared up and forgotten. */
17476
+ /**
17477
+ * "Merge all"; a settled run that merged everything is cleared up and
17478
+ * forgotten. During a run it merges what has landed and the run goes on:
17479
+ * the tasks waiting at a merge gate for that work then start (ADR-0020).
17480
+ * Never while an ops task runs, and no ops task starts until it ends — the
17481
+ * one overlap the plan cannot order, because the merge is the user's.
17482
+ */
16529
17483
  async mergeRun() {
16530
- return this.runs.merge();
17484
+ const ops = [...this.attempts.values()].find((a) => a.ops);
17485
+ if (ops) {
17486
+ const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
17487
+ throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
17488
+ }
17489
+ this.merging = true;
17490
+ let result;
17491
+ try {
17492
+ result = await this.runs.merge();
17493
+ } finally {
17494
+ this.merging = false;
17495
+ }
17496
+ await this.tick();
17497
+ return result;
17498
+ }
17499
+ /**
17500
+ * The run as a surface offers Merge all mid-run (ADR-0020): what has landed
17501
+ * and is not merged, and whether the run is paused at its gates with nothing
17502
+ * else running. Null while no task waits at a gate.
17503
+ */
17504
+ mergeGateView() {
17505
+ const handoff = this.runs.view()?.handoff;
17506
+ if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
17507
+ return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
17508
+ }
17509
+ /**
17510
+ * The dependencies an ops or user task waits on at its merge gate: those
17511
+ * whose landed work is not in the user's branch yet (ADR-0020). Empty once
17512
+ * the task has started, and for every other task.
17513
+ */
17514
+ getMergeGate(taskId) {
17515
+ const task = this.store.get(taskId);
17516
+ if (!task || task.status !== "pending" && task.status !== "approved") return [];
17517
+ return mergeGate(task, this.store, this.runs);
16531
17518
  }
16532
17519
  /** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
16533
17520
  async cleanupRun() {
@@ -16627,7 +17614,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16627
17614
  this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
16628
17615
  return;
16629
17616
  }
16630
- if (!await this.runs.open(() => this.start()) || this.running) return;
17617
+ this.runs.open();
16631
17618
  console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
16632
17619
  this.running = true;
16633
17620
  this.haltedByFailure = false;
@@ -16669,13 +17656,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
16669
17656
  const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
16670
17657
  if (this.attempts.get(taskId) !== attempt) return;
16671
17658
  const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
17659
+ const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
16672
17660
  const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
16673
17661
  if (this.attempts.get(taskId) !== attempt) return;
16674
17662
  this.endAttempt(taskId, "verdict");
16675
17663
  this.store.setTaskVerdict(taskId, verdict);
16676
17664
  console.error(`[TaskOrchestrator] Output summary:
16677
17665
  ${summary || "(empty \u2014 no output captured)"}`);
16678
- if (landing) {
17666
+ if (changedFiles.length > 0) {
17667
+ this.store.markAwaitingUser(taskId, "files-changed");
17668
+ 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.`);
17669
+ } else if (landing) {
16679
17670
  await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
16680
17671
  } else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
16681
17672
  this.store.markAwaitingUser(taskId);
@@ -16777,21 +17768,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
16777
17768
  });
16778
17769
  }
16779
17770
  getReadyTasks() {
16780
- if (!this.running) return [];
17771
+ return this.readiness().ready;
17772
+ }
17773
+ readiness() {
17774
+ if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
16781
17775
  const maxParallel = this.config.maxParallelSessions;
16782
17776
  const active = this.attempts.size;
16783
- const { ready, candidateCount, excluded } = selectReadyTasks({
17777
+ const readiness = selectReadyTasks({
16784
17778
  store: this.store,
16785
17779
  onHold: this.onHold,
16786
17780
  runs: this.runs,
16787
17781
  active,
16788
- maxParallel
17782
+ maxParallel,
17783
+ merging: this.merging
16789
17784
  });
16790
- for (const { task, reasons } of excluded) {
17785
+ for (const { task, reasons } of readiness.excluded) {
16791
17786
  console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
16792
17787
  }
16793
- console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
16794
- return ready;
17788
+ console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
17789
+ return readiness;
17790
+ }
17791
+ /**
17792
+ * Open the run a task starts in, and for a change task decide whether it
17793
+ * isolates (ADR-0020): an ops task needs no worktree, so it never waits on
17794
+ * that choice. False while a dirty tree waits on the user; `resume` is what
17795
+ * their choice replays.
17796
+ */
17797
+ async openFor(taskId, resume) {
17798
+ this.runs.open();
17799
+ return this.store.isOps(taskId) || this.runs.decide(resume);
16795
17800
  }
16796
17801
  /**
16797
17802
  * Cancel a running (or scheduled) task: kill its session and return it to
@@ -16919,7 +17924,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
16919
17924
  const runner = this.store.resolveTaskRunner(task);
16920
17925
  const route = routeTransport("structured", runner, this.registry);
16921
17926
  if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
16922
- if (!await this.runs.open(() => this.continueTask(taskId, message))) return;
17927
+ if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
16923
17928
  if (this.attempts.has(taskId) || !continuability(task).ok) return;
16924
17929
  this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
16925
17930
  this.endAttempt(taskId, "retry");
@@ -16941,10 +17946,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
16941
17946
  const task = this.store.get(taskId);
16942
17947
  if (!task || task.type !== "ai") return;
16943
17948
  if (this.attempts.has(taskId)) return;
16944
- if (!await this.runs.open(() => this.forceStartTask(taskId))) return;
17949
+ this.refuseOpsDuringMerge(task);
17950
+ if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
16945
17951
  this.onHold.delete(taskId);
17952
+ this.notePastGate(task);
16946
17953
  await this.startTask(task);
16947
17954
  }
17955
+ refuseOpsDuringMerge(task) {
17956
+ if (this.merging && this.store.isOps(task.id)) {
17957
+ throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
17958
+ }
17959
+ }
17960
+ /**
17961
+ * A force start passes a merge gate (ADR-0020), and the surface confirmed it
17962
+ * first; the choice is kept on the task and said, because the work it acts
17963
+ * on is not in the user's branch.
17964
+ */
17965
+ notePastGate(task) {
17966
+ const unmerged = mergeGate(task, this.store, this.runs);
17967
+ if (unmerged.length === 0) return;
17968
+ const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
17969
+ this.store.setForcedPastGate(task.id, titles);
17970
+ this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
17971
+ }
16948
17972
  /**
16949
17973
  * Run exactly one task outside full-plan scheduling. The active/starting
16950
17974
  * session still contributes to {@link hasLiveWork} so every surface exposes
@@ -16955,8 +17979,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
16955
17979
  if (this.hasLiveWork) return;
16956
17980
  const task = this.store.get(taskId);
16957
17981
  if (!task || task.type !== "ai") return;
16958
- if (!await this.runs.open(() => this.runTask(taskId))) return;
17982
+ this.refuseOpsDuringMerge(task);
17983
+ if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
16959
17984
  this.onHold.delete(taskId);
17985
+ this.notePastGate(task);
16960
17986
  await this.startTask(task);
16961
17987
  }
16962
17988
  getCompletedCount() {
@@ -16996,7 +18022,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
16996
18022
  }
16997
18023
  return;
16998
18024
  }
16999
- const ready = this.getReadyTasks();
18025
+ let readiness = this.readiness();
18026
+ const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
18027
+ if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
18028
+ if (!this.running) return;
18029
+ const { ready } = readiness;
17000
18030
  console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
17001
18031
  if (ready.length === 0 && this.attempts.size === 0) {
17002
18032
  const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
@@ -17016,14 +18046,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
17016
18046
  this.emit("onExecutionComplete");
17017
18047
  } else {
17018
18048
  this.notifications.info("Remaining tasks require user action or are on hold.");
18049
+ this.sayGate();
17019
18050
  this.emit("onTaskChanged");
17020
18051
  this.emit("onTick");
17021
18052
  }
17022
18053
  return;
17023
18054
  }
17024
- for (const task of ready) await this.startTask(task);
18055
+ for (const task of ready) {
18056
+ if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
18057
+ await this.startTask(task);
18058
+ }
17025
18059
  this.emit("onTick");
17026
18060
  }
18061
+ /**
18062
+ * A run paused at merge gates says so, as plainly as a task that waits on
18063
+ * the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
18064
+ */
18065
+ sayGate() {
18066
+ const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
18067
+ 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.`;
18068
+ if (notice === this.gateNotice) return;
18069
+ this.gateNotice = notice;
18070
+ if (notice) this.tell("info", notice);
18071
+ }
17027
18072
  async startTask(task, continuation = null) {
17028
18073
  if (this.attempts.has(task.id)) return;
17029
18074
  const attempt = {
@@ -17037,6 +18082,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
17037
18082
  integration: null,
17038
18083
  repair: this.landing.nextRepair(task.id),
17039
18084
  continuation,
18085
+ ops: this.store.isOps(task.id),
18086
+ snapshot: null,
17040
18087
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
17041
18088
  decided: false
17042
18089
  };
@@ -17062,18 +18109,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
17062
18109
  */
17063
18110
  async spawnAttempt(task, attempt) {
17064
18111
  try {
17065
- const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
18112
+ const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
17066
18113
  attempt.cwd = cwd;
17067
18114
  attempt.worktree = worktree;
18115
+ if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
17068
18116
  if (this.attempts.get(task.id) !== attempt) {
17069
18117
  if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
17070
18118
  this.abandonSpawn(task, attempt);
17071
18119
  return false;
17072
18120
  }
17073
- const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
18121
+ const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message, { ops: attempt.ops }) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
17074
18122
  planMapEnabled: this.config.planMapEnabled,
17075
18123
  // A merge to resolve is not new behaviour to drive test-first.
17076
- tddEnabled: !attempt.repair && this.tddEnabled()
18124
+ tddEnabled: !attempt.repair && this.tddEnabled(),
18125
+ // An ops task's effects outlive a failed attempt and are never rolled
18126
+ // back, so the next one is told what the last one did (ADR-0020).
18127
+ previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
17077
18128
  });
17078
18129
  this.lingering.close(task.id);
17079
18130
  const env = await this.envForTask(cwd);
@@ -17250,6 +18301,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
17250
18301
  return ended;
17251
18302
  }
17252
18303
  };
18304
+ var OPS_RETRY_TAIL_LINES = 60;
17253
18305
  function unresumedMessage(task, why) {
17254
18306
  return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
17255
18307
  }
@@ -19707,7 +20759,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
19707
20759
  return tasks.map((t) => {
19708
20760
  const isMan = t.type === "user";
19709
20761
  const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
19710
- return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
20762
+ return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : t.ops ? "AI ops" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
19711
20763
  });
19712
20764
  }
19713
20765
  };
@@ -20163,10 +21215,11 @@ function serializeTask(t) {
20163
21215
  thinkingEffort: t.thinkingEffort || void 0,
20164
21216
  autonomy: t.autonomy || void 0,
20165
21217
  sliceType: t.sliceType || void 0,
20166
- userStoriesCovered: t.userStoriesCovered || void 0
21218
+ userStoriesCovered: t.userStoriesCovered || void 0,
21219
+ ...t.ops ? { ops: true } : {}
20167
21220
  };
20168
21221
  }
20169
- function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0) {
21222
+ function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0, mergeGate2 = []) {
20170
21223
  return {
20171
21224
  id: t.id,
20172
21225
  status: t.status,
@@ -20177,7 +21230,9 @@ function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [],
20177
21230
  ...t.status === "awaiting_user" && t.awaitingReason ? { awaitingReason: t.awaitingReason } : {},
20178
21231
  ...queued.length > 0 ? { queued: queued.map((m) => ({ ...m })) } : {},
20179
21232
  ...canContinue(t) ? { continuable: true } : {},
20180
- ...awaitingApproval > 0 ? { awaitingApproval } : {}
21233
+ ...awaitingApproval > 0 ? { awaitingApproval } : {},
21234
+ ...mergeGate2.length > 0 ? { mergeGate: [...mergeGate2] } : {},
21235
+ ...t.forcedPastGate?.length ? { forcedPastGate: [...t.forcedPastGate] } : {}
20181
21236
  };
20182
21237
  }
20183
21238
  function serializePlan(plan) {
@@ -20263,6 +21318,7 @@ var SessionEventRelay = class {
20263
21318
  this.statusOwed = this.statusHeld;
20264
21319
  if (this.statusHeld) return;
20265
21320
  plan.tasks = this.store.snapshot();
21321
+ const gate = this.orchestrator.mergeGateView();
20266
21322
  this.broadcast({
20267
21323
  type: "status_update",
20268
21324
  tasks: this.store.allTasks.map((t) => serializeTaskStatus(
@@ -20270,8 +21326,10 @@ var SessionEventRelay = class {
20270
21326
  this.orchestrator.getIdleSince(t.id),
20271
21327
  this.orchestrator.getTaskIsolation(t.id),
20272
21328
  this.orchestrator.getQueuedTaskMessages(t.id),
20273
- this.awaitingApproval(t.id)
20274
- ))
21329
+ this.awaitingApproval(t.id),
21330
+ this.orchestrator.getMergeGate(t.id)
21331
+ )),
21332
+ ...gate ? { gate } : {}
20275
21333
  });
20276
21334
  }
20277
21335
  /**
@@ -20747,6 +21805,13 @@ var PlanEditError = class extends Error {
20747
21805
  this.name = "PlanEditError";
20748
21806
  }
20749
21807
  };
21808
+ var OPS_RETRY_DIGEST_CALLS = 20;
21809
+ function lastAttemptDigest(location, taskId) {
21810
+ const attempt = listTaskLogAttempts(location, taskId).pop();
21811
+ if (attempt === void 0) return null;
21812
+ const events = readTaskLog(location, taskId, attempt);
21813
+ return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
21814
+ }
20750
21815
  function sessionRuntimeSettings(settings) {
20751
21816
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
20752
21817
  }
@@ -20831,7 +21896,8 @@ function createSession(deps) {
20831
21896
  registry: deps.registry,
20832
21897
  workspaceRoot: deps.workspaceRoot,
20833
21898
  tddEnabled: () => deps.settings().tddEnabled,
20834
- runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
21899
+ runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
21900
+ previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
20835
21901
  });
20836
21902
  const usage = new PlannerUsageLedger();
20837
21903
  const events = new SessionEventRelay({
@@ -21604,6 +22670,14 @@ var Session = class {
21604
22670
  isolationView() {
21605
22671
  return this.orchestrator.isolationView();
21606
22672
  }
22673
+ /** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
22674
+ mergeGate(taskId) {
22675
+ return this.orchestrator.getMergeGate(taskId);
22676
+ }
22677
+ /** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
22678
+ mergeGateView() {
22679
+ return this.orchestrator.mergeGateView();
22680
+ }
21607
22681
  /** The run's integration branch against its base ref, as a unified diff. */
21608
22682
  async reviewRunDiff() {
21609
22683
  if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
@@ -21613,10 +22687,12 @@ var Session = class {
21613
22687
  * "Merge all": each repo's integration branch into whatever the user has
21614
22688
  * checked out there — every repo, or none if any cannot take it. The one
21615
22689
  * irreversible step of isolated execution, so this explicit call is the
21616
- * only way it ever happens.
22690
+ * only way it ever happens. During a run it merges what has landed so far,
22691
+ * which is what opens a merge gate (ADR-0020); it waits for no ops task,
22692
+ * and refuses while one runs.
21617
22693
  */
21618
22694
  async mergeRun() {
21619
- this.requireSettledRun();
22695
+ if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
21620
22696
  const result = await this.orchestrator.mergeRun();
21621
22697
  this.broadcast({ type: "isolation_merge", result });
21622
22698
  return result;
@@ -21706,7 +22782,8 @@ var Session = class {
21706
22782
  * falls through to the no-op `store.update` below instead of throwing.
21707
22783
  */
21708
22784
  async updateTask(taskId, changes) {
21709
- if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
22785
+ if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
22786
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
21710
22787
  const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
21711
22788
  if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
21712
22789
  if (check.clear?.length) {
@@ -24409,6 +25486,7 @@ var PlannerModelMemory = class {
24409
25486
  FsPluginStore,
24410
25487
  GIT_READONLY_SUBCOMMANDS,
24411
25488
  GREP_DEFAULT_HEAD_LIMIT,
25489
+ GREP_PCRE_PROBE_ARGS,
24412
25490
  GeminiService,
24413
25491
  HeadlessRunner,
24414
25492
  HeadlessSession,
@@ -24548,6 +25626,7 @@ var PlannerModelMemory = class {
24548
25626
  dependencyCandidates,
24549
25627
  dependentsOf,
24550
25628
  describeMergeResult,
25629
+ digestTaskLog,
24551
25630
  discoverGeminiModels,
24552
25631
  drainNext,
24553
25632
  effectiveAllowlist,
@@ -24580,6 +25659,7 @@ var PlannerModelMemory = class {
24580
25659
  getStateDir,
24581
25660
  globalDataDir,
24582
25661
  grantScopeFor,
25662
+ grepRegexFromProbe,
24583
25663
  hasHiddenDetail,
24584
25664
  hasTmux,
24585
25665
  holdPrompt,