@ordewell/core 0.4.9 → 0.4.10

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.
package/dist/index.js CHANGED
@@ -96,6 +96,11 @@ __export(index_exports, {
96
96
  SettingsService: () => SettingsService,
97
97
  StdioAgentAdapter: () => StdioAgentAdapter,
98
98
  TASK_OPS_ENVELOPE_KEY: () => TASK_OPS_ENVELOPE_KEY,
99
+ TASK_QUERY_ANSWER_OR_OPS: () => TASK_QUERY_ANSWER_OR_OPS,
100
+ TASK_QUERY_ENVELOPE_KEY: () => TASK_QUERY_ENVELOPE_KEY,
101
+ TASK_QUERY_FIELDS: () => TASK_QUERY_FIELDS,
102
+ TASK_QUERY_PROTOCOL: () => TASK_QUERY_PROTOCOL,
103
+ TASK_QUERY_REMINDER: () => TASK_QUERY_REMINDER,
99
104
  TRUNCATED_PLAN_REPAIR_INSTRUCTION: () => TRUNCATED_PLAN_REPAIR_INSTRUCTION,
100
105
  TaskOrchestrator: () => TaskOrchestrator,
101
106
  TmuxRunner: () => TmuxRunner,
@@ -216,6 +221,7 @@ __export(index_exports, {
216
221
  parsePartialPlan: () => parsePartialPlan,
217
222
  parsePlanJson: () => parsePlanJson,
218
223
  parseTaskOpsJson: () => parseTaskOpsJson,
224
+ parseTaskQueryJson: () => parseTaskQueryJson,
219
225
  pendingEditRulesBlock: () => pendingEditRulesBlock,
220
226
  planDirectLaunch: () => planDirectLaunch,
221
227
  planShellLaunch: () => planShellLaunch,
@@ -224,11 +230,13 @@ __export(index_exports, {
224
230
  providerForRunner: () => providerForRunner,
225
231
  reEmitPlanPrompt: () => reEmitPlanPrompt,
226
232
  reEmitTaskOpsPrompt: () => reEmitTaskOpsPrompt,
233
+ reEmitTaskQueryPrompt: () => reEmitTaskQueryPrompt,
227
234
  readDaemonToken: () => readDaemonToken,
228
235
  referencePattern: () => referencePattern,
229
236
  removeTaskFromPlan: () => removeTaskFromPlan,
230
237
  renderPlanMap: () => renderPlanMap,
231
238
  renderPriorOutputs: () => renderPriorOutputs,
239
+ renderTaskQueryAnswer: () => renderTaskQueryAnswer,
232
240
  renumberTasks: () => renumberTasks,
233
241
  repairLoop: () => repairLoop,
234
242
  researchShellWarning: () => researchShellWarning,
@@ -262,7 +270,9 @@ __export(index_exports, {
262
270
  summarizeOutput: () => summarizeOutput,
263
271
  summarizeToolCall: () => summarizeToolCall,
264
272
  taskOpsRejectedPrompt: () => taskOpsRejectedPrompt,
273
+ taskQuerySignature: () => taskQuerySignature,
265
274
  textHasTaskOps: () => textHasTaskOps,
275
+ textHasTaskQuery: () => textHasTaskQuery,
266
276
  tmuxSessionName: () => tmuxSessionName,
267
277
  tmuxSocketName: () => tmuxSocketName,
268
278
  tmuxWindowName: () => tmuxWindowName,
@@ -4237,7 +4247,7 @@ function formatError(err) {
4237
4247
  var defaultLogger = new ConsoleLogger();
4238
4248
 
4239
4249
  // src/services/BaseAiService.ts
4240
- var import_uuid2 = require("uuid");
4250
+ var import_uuid3 = require("uuid");
4241
4251
 
4242
4252
  // src/services/ModeResolver.ts
4243
4253
  function runnerModesFrom(registry, runners) {
@@ -4360,6 +4370,321 @@ function modesFor(scope, modes) {
4360
4370
  return scoped;
4361
4371
  }
4362
4372
 
4373
+ // src/services/JsonExtractor.ts
4374
+ var PLAN_ENVELOPE_KEY = "tasks";
4375
+ var TASK_OPS_ENVELOPE_KEY = "taskOps";
4376
+ var TASK_QUERY_ENVELOPE_KEY = "taskQuery";
4377
+ var PlanParseError = class extends Error {
4378
+ raw;
4379
+ /**
4380
+ * True when the reply was cut off mid-object (output-token limit) rather
4381
+ * than malformed — the two need different repairs: a truncated emission will
4382
+ * be cut at the same point again unless the retry frees context or asks for
4383
+ * terser output.
4384
+ */
4385
+ truncated;
4386
+ /**
4387
+ * True when the JSON parsed fine and a *shape* rule rejected it. The generic
4388
+ * "could not be parsed as JSON" corrective is a lie for these, so the model
4389
+ * re-sends the same object; a semantic rejection must be repaired by naming
4390
+ * the rule it broke.
4391
+ */
4392
+ semantic;
4393
+ constructor(message, raw, opts) {
4394
+ super(message);
4395
+ this.name = "PlanParseError";
4396
+ this.raw = raw;
4397
+ this.truncated = opts?.truncated ?? false;
4398
+ this.semantic = opts?.semantic ?? false;
4399
+ }
4400
+ };
4401
+ function stripModelNoise(raw) {
4402
+ return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
4403
+ }
4404
+ function balancedObjectAt(text, start) {
4405
+ let depth = 0;
4406
+ let inString = false;
4407
+ let escaped = false;
4408
+ for (let i = start; i < text.length; i++) {
4409
+ const ch = text[i];
4410
+ if (inString) {
4411
+ if (escaped) escaped = false;
4412
+ else if (ch === "\\") escaped = true;
4413
+ else if (ch === '"') inString = false;
4414
+ continue;
4415
+ }
4416
+ if (ch === '"') inString = true;
4417
+ else if (ch === "{") depth++;
4418
+ else if (ch === "}") {
4419
+ depth--;
4420
+ if (depth === 0) return { json: text.slice(start, i + 1), balanced: true };
4421
+ }
4422
+ }
4423
+ return { json: text.slice(start), balanced: false };
4424
+ }
4425
+ function extractObjectsWithKey(raw, key) {
4426
+ const stripped = stripModelNoise(raw);
4427
+ const keyRe = new RegExp(`"${key}"\\s*:`);
4428
+ const matches = [];
4429
+ let firstObject = null;
4430
+ let sawBrace = false;
4431
+ let sawUnbalanced = false;
4432
+ let inString = false;
4433
+ let escaped = false;
4434
+ for (let i = 0; i < stripped.length; i++) {
4435
+ const ch = stripped[i];
4436
+ if (inString) {
4437
+ if (escaped) escaped = false;
4438
+ else if (ch === "\\") escaped = true;
4439
+ else if (ch === '"') inString = false;
4440
+ continue;
4441
+ }
4442
+ if (ch === '"') {
4443
+ inString = true;
4444
+ continue;
4445
+ }
4446
+ if (ch !== "{") continue;
4447
+ sawBrace = true;
4448
+ const obj = balancedObjectAt(stripped, i);
4449
+ if (obj.balanced && keyRe.test(obj.json)) {
4450
+ matches.push(obj.json);
4451
+ i += obj.json.length - 1;
4452
+ continue;
4453
+ }
4454
+ if (!obj.balanced) sawUnbalanced = true;
4455
+ if (!firstObject && obj.balanced) firstObject = obj.json;
4456
+ }
4457
+ return {
4458
+ matches,
4459
+ fallback: firstObject !== null ? { json: firstObject, balanced: true } : { json: stripped, balanced: false },
4460
+ sawBrace,
4461
+ sawUnbalanced
4462
+ };
4463
+ }
4464
+ function extractObjectWithBalance(raw) {
4465
+ const { matches, fallback, sawBrace } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
4466
+ if (matches.length > 0) return { json: matches[0], balanced: true, sawBrace };
4467
+ return { ...fallback, sawBrace };
4468
+ }
4469
+ function extractJsonObject(raw) {
4470
+ return extractObjectWithBalance(raw).json;
4471
+ }
4472
+ function stripTrailingCommas(json) {
4473
+ return json.replace(/,(\s*[}\]])/g, "$1");
4474
+ }
4475
+ function escapeControlCharsInStrings(json) {
4476
+ let out = "";
4477
+ let inString = false;
4478
+ let escaped = false;
4479
+ for (const ch of json) {
4480
+ if (inString) {
4481
+ if (escaped) {
4482
+ out += ch;
4483
+ escaped = false;
4484
+ continue;
4485
+ }
4486
+ if (ch === "\\") {
4487
+ out += ch;
4488
+ escaped = true;
4489
+ continue;
4490
+ }
4491
+ if (ch === '"') {
4492
+ out += ch;
4493
+ inString = false;
4494
+ continue;
4495
+ }
4496
+ if (ch === "\n") {
4497
+ out += "\\n";
4498
+ continue;
4499
+ }
4500
+ if (ch === "\r") {
4501
+ out += "\\r";
4502
+ continue;
4503
+ }
4504
+ if (ch === " ") {
4505
+ out += "\\t";
4506
+ continue;
4507
+ }
4508
+ out += ch;
4509
+ continue;
4510
+ }
4511
+ if (ch === '"') inString = true;
4512
+ out += ch;
4513
+ }
4514
+ return out;
4515
+ }
4516
+
4517
+ // src/services/TaskQuery.ts
4518
+ var TASK_QUERY_FIELDS = [
4519
+ "description",
4520
+ "prompt",
4521
+ "userSteps",
4522
+ "verdict",
4523
+ "outputSummary",
4524
+ "userStoriesCovered"
4525
+ ];
4526
+ function textHasTaskQuery(text) {
4527
+ return text.includes(`"${TASK_QUERY_ENVELOPE_KEY}"`);
4528
+ }
4529
+ function parseTaskQueryJson(text) {
4530
+ const { matches, fallback } = extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY);
4531
+ const candidates = matches.length > 0 ? [...matches].reverse() : [fallback.json];
4532
+ let lastError = null;
4533
+ for (const json of candidates) {
4534
+ try {
4535
+ return parseTaskQueryObject(json, text);
4536
+ } catch (err) {
4537
+ if (!(err instanceof PlanParseError)) throw err;
4538
+ if (!lastError) lastError = err;
4539
+ }
4540
+ }
4541
+ throw lastError ?? new PlanParseError("No JSON object found in task-query reply", text);
4542
+ }
4543
+ function parseTaskQueryObject(json, text) {
4544
+ let parsed;
4545
+ try {
4546
+ parsed = JSON.parse(escapeControlCharsInStrings(stripTrailingCommas(json)));
4547
+ } catch (err) {
4548
+ throw new PlanParseError(`Task-query JSON is invalid: ${err instanceof Error ? err.message : String(err)}`, text);
4549
+ }
4550
+ const raw = parsed.taskQuery;
4551
+ const body = Array.isArray(raw) ? { tasks: raw } : raw;
4552
+ if (typeof body !== "object" || body === null) {
4553
+ throw new PlanParseError('"taskQuery" must be an object (or an array of task references)', text);
4554
+ }
4555
+ const { tasks: rawTasks, fields: rawFields, catalog: rawCatalog } = body;
4556
+ const tasks = [];
4557
+ if (rawTasks !== void 0) {
4558
+ if (!Array.isArray(rawTasks)) throw new PlanParseError('"tasks" in a taskQuery must be an array of task references', text);
4559
+ for (const ref of rawTasks) {
4560
+ if (typeof ref !== "string" && typeof ref !== "number") {
4561
+ throw new PlanParseError(`Invalid task reference "${String(ref)}" in taskQuery`, text);
4562
+ }
4563
+ tasks.push(String(ref).trim());
4564
+ }
4565
+ }
4566
+ let fields;
4567
+ if (rawFields !== void 0) {
4568
+ if (!Array.isArray(rawFields)) throw new PlanParseError('"fields" in a taskQuery must be an array of field names', text);
4569
+ fields = [];
4570
+ for (const f of rawFields) {
4571
+ if (typeof f !== "string" || !TASK_QUERY_FIELDS.includes(f)) {
4572
+ throw new PlanParseError(`Unknown taskQuery field "${String(f)}" \u2014 choose from ${TASK_QUERY_FIELDS.join(", ")}`, text);
4573
+ }
4574
+ fields.push(f);
4575
+ }
4576
+ }
4577
+ const catalog = rawCatalog === true;
4578
+ if (tasks.length === 0 && !catalog) {
4579
+ throw new PlanParseError('A taskQuery must name at least one task or set "catalog": true', text);
4580
+ }
4581
+ return { tasks, fields, catalog };
4582
+ }
4583
+ function taskQuerySignature(query) {
4584
+ return JSON.stringify([query.tasks, query.fields ?? null, query.catalog]);
4585
+ }
4586
+ var TASK_QUERY_ANSWER_OR_OPS = "You have now read everything you asked for. Do not send another taskQuery this turn: answer the user in prose, or emit the taskOps JSON for the change you came to make.";
4587
+ var TASK_QUERY_PROTOCOL = [
4588
+ "READING A TASK BEFORE YOU EDIT IT:",
4589
+ "The plan block you are shown each turn carries only short fields \u2014 it never contains a task's prompt, its user steps, or a completed task's verdict. Never rewrite a field you have not read. To read one, reply with ONLY this JSON object:",
4590
+ ` {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>", "..."],"fields":[${TASK_QUERY_FIELDS.map((f) => `"${f}"`).join(", ")}],"catalog":true}}`,
4591
+ '- "tasks": one or more task references. Ask for everything you need in ONE query \u2014 each query costs a round-trip.',
4592
+ '- "fields": optional. Omit it to get every field above.',
4593
+ '- "catalog": optional. Set it to true for the full runner and model catalog \u2014 every model id with its label and thinking-effort variants, and every task mode with its description. It works before any task exists, and may be used on its own.',
4594
+ "Ordewell answers immediately with the detail and changes nothing. Then reply again with your taskOps JSON, or with prose for the user.",
4595
+ "Three queries per user message; after that every answer also tells you to land the turn. Do not ask the same question twice."
4596
+ ];
4597
+ var TASK_QUERY_REMINDER = `- To READ a task in full (prompt, user steps, verdict, output, user stories) or the whole model/mode catalog before editing, reply with ONLY {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>"],"catalog":true}} \u2014 it changes nothing, and you then reply again with your ops.`;
4598
+ function findTask(tasks, ref) {
4599
+ const byId = tasks.find((t) => t.id === ref);
4600
+ if (byId) return byId;
4601
+ const orderStr = ref.startsWith("#") ? ref.slice(1) : ref;
4602
+ if (/^\d+$/.test(orderStr)) {
4603
+ const byOrder = tasks.filter((t) => t.order === Number(orderStr));
4604
+ if (byOrder.length === 1) return byOrder[0];
4605
+ }
4606
+ return tasks.find((t) => t.title === ref);
4607
+ }
4608
+ function renderField(task, field) {
4609
+ switch (field) {
4610
+ case "description":
4611
+ return [`description: ${task.description || "(none)"}`];
4612
+ case "prompt":
4613
+ return task.prompt ? ["prompt:", task.prompt] : ["prompt: (none)"];
4614
+ case "userSteps": {
4615
+ const steps = task.userSteps ?? [];
4616
+ if (steps.length === 0) return ["userSteps: (none)"];
4617
+ return ["userSteps:", ...steps.map((s) => ` ${s.order}. [${s.completed ? "x" : " "}] ${s.instruction}`)];
4618
+ }
4619
+ case "verdict": {
4620
+ const v = task.verdict;
4621
+ if (!v) return ["verdict: (none)"];
4622
+ const checks = v.checks.map((c) => `${c.name}=${c.skipped ? "skipped" : c.passed ? "pass" : "fail"}`).join(", ");
4623
+ return [`verdict: ${v.outcome.toUpperCase()} \u2014 ${v.reason}${checks ? ` [${checks}]` : ""}`];
4624
+ }
4625
+ case "outputSummary": {
4626
+ const o = task.outputSummary;
4627
+ if (!o) return ["outputSummary: (none)"];
4628
+ return [`outputSummary: ${o.reviewReason}`, ...o.logTail ? ["log tail:", o.logTail] : []];
4629
+ }
4630
+ case "userStoriesCovered": {
4631
+ const stories = task.userStoriesCovered ?? [];
4632
+ if (stories.length === 0) return ["userStoriesCovered: (none)"];
4633
+ return ["userStoriesCovered:", ...stories.map((s) => ` - ${s}`)];
4634
+ }
4635
+ }
4636
+ }
4637
+ function renderTask(task, fields) {
4638
+ return [
4639
+ `#${task.order} id=${task.id} "${task.title}" [${task.status}] type:${task.type === "user" ? "MAN" : "AI"}`,
4640
+ ...fields.flatMap((f) => renderField(task, f)),
4641
+ ""
4642
+ ];
4643
+ }
4644
+ function renderCatalog(catalog) {
4645
+ const lines = [];
4646
+ for (const runner of catalog.runners) {
4647
+ const models = catalog.models[runner] ?? [];
4648
+ lines.push(`${runner} models:`);
4649
+ if (models.length === 0) lines.push(" (none discovered)");
4650
+ for (const m of models) {
4651
+ const variants = m.variants?.length ? ` variants: ${m.variants.map((v) => `${v.id}${v.label && v.label !== v.id ? ` (${v.label})` : ""}`).join(", ")}` : " variants: none";
4652
+ lines.push(` - ${m.modelId} \u2014 ${m.modelLabel}${variants}`);
4653
+ }
4654
+ const modes = catalog.modes[runner] ?? [];
4655
+ lines.push(`${runner} task modes:`);
4656
+ if (modes.length === 0) lines.push(" (none declared)");
4657
+ const defaultId = resolveDefaultMode(modes, catalog.autonomousDefault);
4658
+ for (const mode of modes) {
4659
+ lines.push(` - ${mode.id} \u2014 ${mode.label}: ${mode.description}${mode.id === defaultId ? " (default)" : ""}`);
4660
+ }
4661
+ }
4662
+ return lines;
4663
+ }
4664
+ function renderTaskQueryAnswer(query, tasks, catalog) {
4665
+ const fields = query.fields ?? TASK_QUERY_FIELDS;
4666
+ const blocks = [];
4667
+ if (query.tasks.length > 0) {
4668
+ const body = [];
4669
+ for (const ref of query.tasks) {
4670
+ const task = findTask(tasks, ref);
4671
+ if (!task) {
4672
+ body.push(`${ref}: no task matches this reference in the current plan.`, "");
4673
+ continue;
4674
+ }
4675
+ body.push(...renderTask(task, fields));
4676
+ }
4677
+ blocks.push(["<task_detail>", ...body, "</task_detail>"].join("\n"));
4678
+ }
4679
+ if (query.catalog) {
4680
+ blocks.push(["<runner_catalog>", ...renderCatalog(catalog), "</runner_catalog>"].join("\n"));
4681
+ }
4682
+ blocks.push(
4683
+ "That is everything you asked for. Nothing has changed. Reply now with the taskOps JSON for the edit you intended, or answer the user in prose."
4684
+ );
4685
+ return blocks.join("\n\n");
4686
+ }
4687
+
4363
4688
  // src/services/PlanPrompts.ts
4364
4689
  function buildResearchToolsPrompt(subagentsEnabled = false) {
4365
4690
  const lines = [
@@ -4449,7 +4774,8 @@ function researchPhaseBlock(harnessMode) {
4449
4774
  ];
4450
4775
  const toolSpecific = harnessMode ? [
4451
4776
  "- Explore the repository with your own tools until you understand its architecture",
4452
- "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace"
4777
+ "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
4778
+ "- If you delegate exploration to your own agents, WAIT for their results inside this reply. Do NOT launch them in the background or async and end your turn saying you will report back later: your turn ending is what hands the conversation back to the user, and anything you say after it never reaches them."
4453
4779
  ] : [
4454
4780
  "- Use list_dir with depth=2-3 for a tree overview",
4455
4781
  "- Use glob and grep to find relevant source files",
@@ -4614,6 +4940,11 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
4614
4940
  "TASK MODE:",
4615
4941
  modeGuide,
4616
4942
  "",
4943
+ // Shared by both variants on purpose: the read channel is a text envelope
4944
+ // exactly so a harness planner, which Ordewell cannot hand tools to, speaks
4945
+ // the same protocol as an API-backed one (ADR-0009).
4946
+ ...TASK_QUERY_PROTOCOL,
4947
+ "",
4617
4948
  context ? `PROJECT CONTEXT:
4618
4949
  ${context}
4619
4950
  ` : "",
@@ -4921,7 +5252,8 @@ function buildMergePrompt(taskIds, tasks) {
4921
5252
  "The merged task takes the union of their dependencies (excluding the merged tasks themselves) and preserves their user stories.",
4922
5253
  'Set "assignedRunner" and "assignedModel" on the merged task \u2014 use one of the runners and models listed in <available_models> above. Prefer the strongest model if the merged work is complex.',
4923
5254
  'Reply with ONLY a taskOps JSON object using a single "merge" op:',
4924
- ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`
5255
+ ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`,
5256
+ `If this merge needs a companion op in the same batch (e.g. an added task that should depend on the merge result), give the merge op a "handle" (any unused name) and reference it from the later op's taskId/dependencies.`
4925
5257
  ].join("\n");
4926
5258
  }
4927
5259
  function buildSplitPrompt(taskId, tasks) {
@@ -4933,153 +5265,11 @@ function buildSplitPrompt(taskId, tasks) {
4933
5265
  "The first part inherits the original task's dependencies. Each later part depends on the previous part. Tasks that depended on the original now depend on the LAST part.",
4934
5266
  'Set "assignedRunner" and "assignedModel" on each part \u2014 use the runners and models listed in <available_models> above. You may assign different models to different parts (e.g. a stronger model for a complex part, a faster one for a simple part).',
4935
5267
  'Reply with ONLY a taskOps JSON object using a single "split" op:',
4936
- ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`
5268
+ ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`,
5269
+ `If this split needs a companion op in the same batch (e.g. another task that should depend on the last part), give the split op a "handle" (any unused name) \u2014 it names the last part \u2014 and reference it from the later op's taskId/dependencies.`
4937
5270
  ].join("\n");
4938
5271
  }
4939
5272
 
4940
- // src/services/JsonExtractor.ts
4941
- var PLAN_ENVELOPE_KEY = "tasks";
4942
- var TASK_OPS_ENVELOPE_KEY = "taskOps";
4943
- var PlanParseError = class extends Error {
4944
- raw;
4945
- /**
4946
- * True when the reply was cut off mid-object (output-token limit) rather
4947
- * than malformed — the two need different repairs: a truncated emission will
4948
- * be cut at the same point again unless the retry frees context or asks for
4949
- * terser output.
4950
- */
4951
- truncated;
4952
- /**
4953
- * True when the JSON parsed fine and a *shape* rule rejected it. The generic
4954
- * "could not be parsed as JSON" corrective is a lie for these, so the model
4955
- * re-sends the same object; a semantic rejection must be repaired by naming
4956
- * the rule it broke.
4957
- */
4958
- semantic;
4959
- constructor(message, raw, opts) {
4960
- super(message);
4961
- this.name = "PlanParseError";
4962
- this.raw = raw;
4963
- this.truncated = opts?.truncated ?? false;
4964
- this.semantic = opts?.semantic ?? false;
4965
- }
4966
- };
4967
- function stripModelNoise(raw) {
4968
- return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
4969
- }
4970
- function balancedObjectAt(text, start) {
4971
- let depth = 0;
4972
- let inString = false;
4973
- let escaped = false;
4974
- for (let i = start; i < text.length; i++) {
4975
- const ch = text[i];
4976
- if (inString) {
4977
- if (escaped) escaped = false;
4978
- else if (ch === "\\") escaped = true;
4979
- else if (ch === '"') inString = false;
4980
- continue;
4981
- }
4982
- if (ch === '"') inString = true;
4983
- else if (ch === "{") depth++;
4984
- else if (ch === "}") {
4985
- depth--;
4986
- if (depth === 0) return { json: text.slice(start, i + 1), balanced: true };
4987
- }
4988
- }
4989
- return { json: text.slice(start), balanced: false };
4990
- }
4991
- function extractObjectsWithKey(raw, key) {
4992
- const stripped = stripModelNoise(raw);
4993
- const keyRe = new RegExp(`"${key}"\\s*:`);
4994
- const matches = [];
4995
- let firstObject = null;
4996
- let sawBrace = false;
4997
- let sawUnbalanced = false;
4998
- let inString = false;
4999
- let escaped = false;
5000
- for (let i = 0; i < stripped.length; i++) {
5001
- const ch = stripped[i];
5002
- if (inString) {
5003
- if (escaped) escaped = false;
5004
- else if (ch === "\\") escaped = true;
5005
- else if (ch === '"') inString = false;
5006
- continue;
5007
- }
5008
- if (ch === '"') {
5009
- inString = true;
5010
- continue;
5011
- }
5012
- if (ch !== "{") continue;
5013
- sawBrace = true;
5014
- const obj = balancedObjectAt(stripped, i);
5015
- if (obj.balanced && keyRe.test(obj.json)) {
5016
- matches.push(obj.json);
5017
- i += obj.json.length - 1;
5018
- continue;
5019
- }
5020
- if (!obj.balanced) sawUnbalanced = true;
5021
- if (!firstObject && obj.balanced) firstObject = obj.json;
5022
- }
5023
- return {
5024
- matches,
5025
- fallback: firstObject !== null ? { json: firstObject, balanced: true } : { json: stripped, balanced: false },
5026
- sawBrace,
5027
- sawUnbalanced
5028
- };
5029
- }
5030
- function extractObjectWithBalance(raw) {
5031
- const { matches, fallback, sawBrace } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
5032
- if (matches.length > 0) return { json: matches[0], balanced: true, sawBrace };
5033
- return { ...fallback, sawBrace };
5034
- }
5035
- function extractJsonObject(raw) {
5036
- return extractObjectWithBalance(raw).json;
5037
- }
5038
- function stripTrailingCommas(json) {
5039
- return json.replace(/,(\s*[}\]])/g, "$1");
5040
- }
5041
- function escapeControlCharsInStrings(json) {
5042
- let out = "";
5043
- let inString = false;
5044
- let escaped = false;
5045
- for (const ch of json) {
5046
- if (inString) {
5047
- if (escaped) {
5048
- out += ch;
5049
- escaped = false;
5050
- continue;
5051
- }
5052
- if (ch === "\\") {
5053
- out += ch;
5054
- escaped = true;
5055
- continue;
5056
- }
5057
- if (ch === '"') {
5058
- out += ch;
5059
- inString = false;
5060
- continue;
5061
- }
5062
- if (ch === "\n") {
5063
- out += "\\n";
5064
- continue;
5065
- }
5066
- if (ch === "\r") {
5067
- out += "\\r";
5068
- continue;
5069
- }
5070
- if (ch === " ") {
5071
- out += "\\t";
5072
- continue;
5073
- }
5074
- out += ch;
5075
- continue;
5076
- }
5077
- if (ch === '"') inString = true;
5078
- out += ch;
5079
- }
5080
- return out;
5081
- }
5082
-
5083
5273
  // src/services/PlanValidator.ts
5084
5274
  function parsePlanJson(raw, runners, runnerModes, autonomousDefault = true) {
5085
5275
  const { matches, fallback, sawUnbalanced } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
@@ -5320,6 +5510,171 @@ function validatePlanModification(ctx) {
5320
5510
  return { valid: allErrors.length === 0, errors: allErrors };
5321
5511
  }
5322
5512
 
5513
+ // src/services/TaskOps.ts
5514
+ var import_uuid2 = require("uuid");
5515
+
5516
+ // src/services/ModelAllowlistResolver.ts
5517
+ function effectiveAllowlist(allowlist, runner, modelsByRunner) {
5518
+ if (!allowlist || allowlist.length === 0) return void 0;
5519
+ if (!modelsByRunner) return allowlist;
5520
+ const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
5521
+ const elsewhere = /* @__PURE__ */ new Set();
5522
+ for (const [other, models] of Object.entries(modelsByRunner)) {
5523
+ if (other === runner) continue;
5524
+ for (const m of models ?? []) elsewhere.add(m.modelId);
5525
+ }
5526
+ const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
5527
+ return kept.length > 0 ? kept : void 0;
5528
+ }
5529
+ function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
5530
+ const result = {};
5531
+ for (const [runner, models] of Object.entries(modelsByRunner)) {
5532
+ if (!models) continue;
5533
+ const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5534
+ if (!allowed) {
5535
+ result[runner] = models;
5536
+ continue;
5537
+ }
5538
+ const allowedSet = new Set(allowed);
5539
+ const intersection = models.filter((m) => allowedSet.has(m.modelId));
5540
+ const covered = new Set(intersection.map((m) => m.modelId));
5541
+ const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
5542
+ result[runner] = [...intersection, ...missing];
5543
+ }
5544
+ return result;
5545
+ }
5546
+ var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
5547
+ var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
5548
+ function clampThinkingEffort(effort, variants) {
5549
+ if (!effort) return void 0;
5550
+ if (variants.length === 0) return void 0;
5551
+ const ids = new Set(variants.map((v) => v.id));
5552
+ if (ids.has(effort)) return effort;
5553
+ const canon = EFFORT_ALIASES[effort] ?? effort;
5554
+ if (ids.has(canon)) return canon;
5555
+ const idx = EFFORT_LADDER.indexOf(canon);
5556
+ if (idx < 0) return void 0;
5557
+ for (let d = 1; d < EFFORT_LADDER.length; d++) {
5558
+ const lower = EFFORT_LADDER[idx - d];
5559
+ if (lower && ids.has(lower)) return lower;
5560
+ const higher = EFFORT_LADDER[idx + d];
5561
+ if (higher && ids.has(higher)) return higher;
5562
+ }
5563
+ return void 0;
5564
+ }
5565
+ function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
5566
+ const clampVariant = (task) => {
5567
+ if (!task.assignedModel || !modelsByRunner) return task;
5568
+ const model = modelsByRunner[task.assignedRunner]?.find(
5569
+ (m) => m.modelId === task.assignedModel.modelId
5570
+ );
5571
+ if (!model) return task;
5572
+ const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
5573
+ return {
5574
+ ...task,
5575
+ assignedModel: {
5576
+ ...task.assignedModel,
5577
+ thinkingEffort: clamped,
5578
+ availableVariants: model.variants.map((v) => v.id)
5579
+ }
5580
+ };
5581
+ };
5582
+ const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5583
+ const assignmentFor = (runner, modelId) => {
5584
+ const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
5585
+ return {
5586
+ modelId,
5587
+ modelLabel: known?.modelLabel ?? modelId,
5588
+ thinkingEffort: void 0,
5589
+ ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
5590
+ };
5591
+ };
5592
+ return tasks.map((task) => {
5593
+ if (task.type === "user") return task;
5594
+ if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
5595
+ const fallbackRunner = allowedRunners[0];
5596
+ const fallbackModels = allowedFor(fallbackRunner);
5597
+ const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
5598
+ return clampVariant({
5599
+ ...task,
5600
+ assignedRunner: fallbackRunner,
5601
+ assignedModel: fallbackModel
5602
+ });
5603
+ }
5604
+ const allowlist = allowedFor(task.assignedRunner);
5605
+ if (!task.assignedModel) {
5606
+ if (allowlist && allowlist.length > 0) {
5607
+ return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
5608
+ }
5609
+ const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
5610
+ if (discovered) {
5611
+ return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
5612
+ }
5613
+ return task;
5614
+ }
5615
+ if (!allowlist || allowlist.length === 0) return clampVariant(task);
5616
+ if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
5617
+ return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
5618
+ });
5619
+ }
5620
+
5621
+ // src/services/TaskEditValidator.ts
5622
+ var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy"];
5623
+ function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
5624
+ if (!catalog) return { ok: true };
5625
+ if (assignedModel) {
5626
+ const models = catalog.modelsByRunner[runner];
5627
+ if (models) {
5628
+ const modelId = assignedModel.modelId;
5629
+ if (!models.some((m) => m.modelId === modelId)) {
5630
+ return { ok: false, error: `Runner "${runner}" does not offer model "${modelId}"` };
5631
+ }
5632
+ const allowed = effectiveAllowlist(catalog.perRunnerAllowlist?.[runner], runner, catalog.modelsByRunner);
5633
+ if (allowed && !allowed.includes(modelId)) {
5634
+ return { ok: false, error: `Model "${modelId}" is excluded by the allowlist for runner "${runner}"` };
5635
+ }
5636
+ }
5637
+ }
5638
+ if (taskMode !== void 0) {
5639
+ const modes = catalog.runnerModes[runner];
5640
+ if (modes && modes.length > 0 && !modes.some((m) => m.id === taskMode)) {
5641
+ return { ok: false, error: `Runner "${runner}" has no task mode "${taskMode}" \u2014 valid modes: ${modes.map((m) => m.id).join(", ")}` };
5642
+ }
5643
+ }
5644
+ return { ok: true };
5645
+ }
5646
+ function typeCoherenceCheck(target, changes) {
5647
+ if (!changes.type || changes.type === target.type) return null;
5648
+ if (changes.type === "user") {
5649
+ if (!Array.isArray(changes.userSteps) || changes.userSteps.length === 0) {
5650
+ return { ok: false, error: `"${target.title}" cannot become a manual task without user steps \u2014 include "userSteps" in this edit` };
5651
+ }
5652
+ return { ok: true, clear: AI_ONLY_FIELDS.filter((f) => target[f] !== void 0) };
5653
+ }
5654
+ if (!changes.prompt || !changes.prompt.trim()) {
5655
+ return { ok: false, error: `"${target.title}" cannot become an AI task without a prompt \u2014 include "prompt" in this edit` };
5656
+ }
5657
+ return { ok: true, clear: target.userSteps && target.userSteps.length > 0 ? ["userSteps"] : [] };
5658
+ }
5659
+ function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
5660
+ const target = tasks.find((t) => t.id === taskId);
5661
+ if (!target) return { ok: false, error: "Task not found" };
5662
+ if (actor === "planner") {
5663
+ if (target.status === "in_progress") return { ok: false, error: `"${target.title}" is running and cannot be modified` };
5664
+ if (target.status === "completed") return { ok: false, error: `"${target.title}" is completed and cannot be modified` };
5665
+ }
5666
+ if (changes.dependencies) {
5667
+ const depCheck = canSetDependencies(tasks, taskId, changes.dependencies);
5668
+ if (!depCheck.ok) return depCheck;
5669
+ }
5670
+ const coherence = typeCoherenceCheck(target, changes);
5671
+ if (coherence && !coherence.ok) return coherence;
5672
+ const runner = changes.assignedRunner ?? target.assignedRunner;
5673
+ const validity = checkModelAndModeValidity(runner, changes.assignedModel, changes.taskMode, catalog);
5674
+ if (!validity.ok) return validity;
5675
+ return coherence ?? { ok: true };
5676
+ }
5677
+
5323
5678
  // src/services/TaskOps.ts
5324
5679
  var UPDATABLE_FIELDS = [
5325
5680
  "title",
@@ -5332,7 +5687,8 @@ var UPDATABLE_FIELDS = [
5332
5687
  "thinkingEffort",
5333
5688
  "type",
5334
5689
  "userSteps",
5335
- "autonomy"
5690
+ "autonomy",
5691
+ "sliceType"
5336
5692
  ];
5337
5693
  function textHasTaskOps(text) {
5338
5694
  return text.includes(`"${TASK_OPS_ENVELOPE_KEY}"`);
@@ -5369,18 +5725,41 @@ function parseTaskOpsObject(json, text) {
5369
5725
  }
5370
5726
  return ops;
5371
5727
  }
5372
- function resolveRef(ref, tasks) {
5373
- if (typeof ref !== "string" && typeof ref !== "number") return void 0;
5728
+ function resolveRef(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
5729
+ if (typeof ref !== "string" && typeof ref !== "number") return { error: `invalid reference "${String(ref)}"` };
5374
5730
  const s = String(ref).trim();
5375
- const byId = tasks.find((t) => t.id === s);
5376
- if (byId) return byId;
5731
+ const ownerIdx = handleOwner.get(s);
5732
+ if (ownerIdx !== void 0) {
5733
+ if (ownerIdx >= opIndex) {
5734
+ return { error: `handle "${s}" is defined later in this batch (op ${ownerIdx + 1}) and cannot be referenced yet` };
5735
+ }
5736
+ const id = handleId.get(s);
5737
+ if (!id) return { error: `handle "${s}" was never resolved` };
5738
+ return { id };
5739
+ }
5740
+ const byLiveId = liveScope.find((t) => t.id === s);
5741
+ if (byLiveId) return { id: byLiveId.id };
5377
5742
  const orderStr = s.startsWith("#") ? s.slice(1) : s;
5378
5743
  if (/^\d+$/.test(orderStr)) {
5379
5744
  const order = Number(orderStr);
5380
- const byOrder = tasks.filter((t) => t.order === order);
5381
- if (byOrder.length === 1) return byOrder[0];
5745
+ const byOrder = originalScope.filter((t) => t.order === order);
5746
+ if (byOrder.length === 1) return { id: byOrder[0].id };
5382
5747
  }
5383
- return tasks.find((t) => t.title === s);
5748
+ const byTitle = originalScope.find((t) => t.title === s);
5749
+ if (byTitle) return { id: byTitle.id };
5750
+ return { error: `task "${s}" not found` };
5751
+ }
5752
+ function refCollidesWithExisting(name, originalFlatAll) {
5753
+ if (originalFlatAll.some((t) => t.id === name)) return true;
5754
+ const orderStr = name.startsWith("#") ? name.slice(1) : name;
5755
+ if (/^\d+$/.test(orderStr) && originalFlatAll.some((t) => t.order === Number(orderStr))) return true;
5756
+ return originalFlatAll.some((t) => t.title === name);
5757
+ }
5758
+ function findInTasks(tasks, id) {
5759
+ return flattenTasks(tasks).find((t) => t.id === id);
5760
+ }
5761
+ function findTopLevel(tasks, id) {
5762
+ return tasks.find((t) => t.id === id);
5384
5763
  }
5385
5764
  function detectCycle(tasks) {
5386
5765
  const byId = new Map(tasks.map((t) => [t.id, t]));
@@ -5404,6 +5783,55 @@ function detectCycle(tasks) {
5404
5783
  }
5405
5784
  return null;
5406
5785
  }
5786
+ function isPinned(t) {
5787
+ return t.status === "in_progress" || t.status === "completed";
5788
+ }
5789
+ function repairOrder(tasks) {
5790
+ const byId = new Map(tasks.map((t) => [t.id, t]));
5791
+ const placed = /* @__PURE__ */ new Set();
5792
+ const movable = tasks.filter((t) => !isPinned(t));
5793
+ const result = [];
5794
+ const blockerOf = (t) => {
5795
+ const id = t.dependencies.find((d) => byId.has(d) && !placed.has(d));
5796
+ return id ? byId.get(id) : void 0;
5797
+ };
5798
+ const place = (t) => {
5799
+ result.push(t);
5800
+ placed.add(t.id);
5801
+ };
5802
+ for (const occupant of tasks) {
5803
+ if (isPinned(occupant)) {
5804
+ const blocker = blockerOf(occupant);
5805
+ if (blocker) return { error: pinnedRefusal(blocker, occupant, "before") };
5806
+ place(occupant);
5807
+ continue;
5808
+ }
5809
+ const next = movable.findIndex((t) => !blockerOf(t));
5810
+ if (next < 0) {
5811
+ const [stuck, pin] = trailToPin(movable[0], blockerOf);
5812
+ return { error: pinnedRefusal(stuck, pin, "after") };
5813
+ }
5814
+ place(movable.splice(next, 1)[0]);
5815
+ }
5816
+ return { tasks: result };
5817
+ }
5818
+ function trailToPin(task, blockerOf) {
5819
+ let at = task;
5820
+ for (; ; ) {
5821
+ const blocker = blockerOf(at);
5822
+ if (!blocker || isPinned(blocker)) return [at, blocker ?? at];
5823
+ at = blocker;
5824
+ }
5825
+ }
5826
+ function describeMoves(before, after) {
5827
+ const wasAt = new Map(before.map((t, i) => [t.id, i + 1]));
5828
+ const moved = after.map((t, i) => ({ t, from: wasAt.get(t.id), to: i + 1 })).filter((m) => m.from !== m.to);
5829
+ return moved.length ? moved.map((m) => `"${m.t.title}" #${m.from}\u2192#${m.to}`).join(", ") : null;
5830
+ }
5831
+ function pinnedRefusal(other, pin, side) {
5832
+ const word = pin.status === "in_progress" ? "running" : "completed";
5833
+ return `"${other.title}" would have to run ${side} "${pin.title}", which is ${word} and cannot be moved`;
5834
+ }
5407
5835
  function canMergeTasks(tasks, selectedIds) {
5408
5836
  if (selectedIds.length < 2) return { ok: false, error: "Select at least two tasks to merge" };
5409
5837
  const idSet = new Set(selectedIds);
@@ -5420,15 +5848,6 @@ function canMergeTasks(tasks, selectedIds) {
5420
5848
  sim.push({ ...ordered[0], id: survivorId, title: "__merge_sim__", order: ordered[0].order, dependencies: unionDeps });
5421
5849
  const cycle = detectCycle(sim);
5422
5850
  if (cycle) return { ok: false, error: `Merging would create a dependency cycle involving "${cycle}"` };
5423
- const orderById = new Map(sim.map((t) => [t.id, t.order]));
5424
- for (const t of sim) {
5425
- for (const dep of t.dependencies) {
5426
- const depOrder = orderById.get(dep);
5427
- if (depOrder !== void 0 && depOrder >= t.order) {
5428
- return { ok: false, error: "Merging would break task ordering \u2014 a dependency of the merged set comes after it" };
5429
- }
5430
- }
5431
- }
5432
5851
  return { ok: true };
5433
5852
  }
5434
5853
  function dependentsOf(tasks, taskId) {
@@ -5460,17 +5879,46 @@ function canSplitTask(tasks, taskId) {
5460
5879
  if (target.status === "completed") return { ok: false, error: `"${target.title}" is completed and cannot be split` };
5461
5880
  return { ok: true };
5462
5881
  }
5463
- function applyTaskOps(currentTasks, ops, runners) {
5882
+ function applyTaskOps(currentTasks, ops, runners, catalog) {
5464
5883
  let tasks = JSON.parse(JSON.stringify(currentTasks));
5465
5884
  const errors = [];
5466
5885
  const summary = [];
5467
- const resolveDeps = (deps) => {
5886
+ const originalFlatAll = flattenTasks(currentTasks);
5887
+ const handleOwner = /* @__PURE__ */ new Map();
5888
+ const handleId = /* @__PURE__ */ new Map();
5889
+ for (const [i, op] of ops.entries()) {
5890
+ const handle = op.handle;
5891
+ if (handle === void 0) continue;
5892
+ if (typeof handle !== "string" || !handle.trim()) {
5893
+ errors.push(`op ${i + 1} (${op.op}): handle must be a non-empty string`);
5894
+ continue;
5895
+ }
5896
+ if (handleOwner.has(handle)) {
5897
+ errors.push(`op ${i + 1} (${op.op}): handle "${handle}" is already used earlier in this batch`);
5898
+ continue;
5899
+ }
5900
+ if (refCollidesWithExisting(handle, originalFlatAll)) {
5901
+ errors.push(`op ${i + 1} (${op.op}): handle "${handle}" collides with an existing task reference`);
5902
+ continue;
5903
+ }
5904
+ handleOwner.set(handle, i);
5905
+ }
5906
+ if (errors.length) return { ok: false, tasks: currentTasks, errors, summary: [] };
5907
+ const resolveDeps = (deps, opIndex) => {
5468
5908
  const ids = [];
5469
5909
  const bad = [];
5910
+ const liveFlat = flattenTasks(tasks);
5470
5911
  for (const d of Array.isArray(deps) ? deps : []) {
5471
- const t = resolveRef(d, flattenTasks(tasks));
5472
- if (t) ids.push(t.id);
5473
- else bad.push(String(d));
5912
+ const r = resolveRef(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
5913
+ if ("error" in r) {
5914
+ bad.push(`${String(d)} (${r.error})`);
5915
+ continue;
5916
+ }
5917
+ if (!liveFlat.some((t) => t.id === r.id)) {
5918
+ bad.push(`${String(d)} (no longer exists in this batch)`);
5919
+ continue;
5920
+ }
5921
+ ids.push(r.id);
5474
5922
  }
5475
5923
  return { ids, bad };
5476
5924
  };
@@ -5478,41 +5926,47 @@ function applyTaskOps(currentTasks, ops, runners) {
5478
5926
  const label = `op ${i + 1} (${op.op})`;
5479
5927
  switch (op.op) {
5480
5928
  case "update": {
5481
- const target = resolveRef(op.taskId, flattenTasks(tasks));
5482
- if (!target) {
5483
- errors.push(`${label}: task "${op.taskId}" not found`);
5484
- break;
5485
- }
5486
- if (target.status === "in_progress") {
5487
- errors.push(`${label}: "${target.title}" is running and cannot be modified`);
5929
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
5930
+ if ("error" in ref) {
5931
+ errors.push(`${label}: ${ref.error}`);
5488
5932
  break;
5489
5933
  }
5490
- if (target.status === "completed") {
5491
- errors.push(`${label}: "${target.title}" is completed and cannot be modified`);
5934
+ const target = findInTasks(tasks, ref.id);
5935
+ if (!target) {
5936
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
5492
5937
  break;
5493
5938
  }
5494
5939
  const changes = {};
5495
5940
  for (const key of UPDATABLE_FIELDS) {
5496
5941
  if (op.changes && key in op.changes) changes[key] = op.changes[key];
5497
5942
  }
5943
+ if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
5944
+ errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
5945
+ break;
5946
+ }
5947
+ const changesForCheck = { ...changes };
5948
+ delete changesForCheck.dependencies;
5949
+ const check = validateTaskEdit("planner", flattenTasks(tasks), target.id, changesForCheck, catalog);
5950
+ if (!check.ok) {
5951
+ errors.push(`${label}: ${check.error}`);
5952
+ break;
5953
+ }
5954
+ for (const field of check.clear ?? []) changes[field] = void 0;
5498
5955
  if ("dependencies" in changes) {
5499
- const { ids, bad } = resolveDeps(changes.dependencies);
5956
+ const { ids, bad } = resolveDeps(changes.dependencies, i);
5500
5957
  if (bad.length) {
5501
5958
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
5502
5959
  break;
5503
5960
  }
5504
5961
  changes.dependencies = ids.filter((id) => id !== target.id);
5505
5962
  }
5506
- if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
5507
- errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
5508
- break;
5509
- }
5510
5963
  if (Object.keys(changes).length === 0) {
5511
5964
  errors.push(`${label}: no valid changes provided`);
5512
5965
  break;
5513
5966
  }
5514
5967
  tasks = updateTaskInPlan(tasks, target.id, changes);
5515
- summary.push(`Updated "${target.title}" (${Object.keys(changes).join(", ")})`);
5968
+ const clearedNote = check.clear?.length ? `; cleared ${check.clear.join(", ")}` : "";
5969
+ summary.push(`Updated "${target.title}" (${Object.keys(changes).join(", ")})${clearedNote}`);
5516
5970
  break;
5517
5971
  }
5518
5972
  case "add": {
@@ -5521,27 +5975,39 @@ function applyTaskOps(currentTasks, ops, runners) {
5521
5975
  errors.push(`${label}: new task needs a title`);
5522
5976
  break;
5523
5977
  }
5524
- const { ids, bad } = resolveDeps(spec.dependencies);
5978
+ const { ids, bad } = resolveDeps(spec.dependencies, i);
5525
5979
  if (bad.length) {
5526
5980
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
5527
5981
  break;
5528
5982
  }
5529
5983
  const runner = spec.assignedRunner && runners.includes(spec.assignedRunner) ? spec.assignedRunner : runners[0];
5984
+ const validity = checkModelAndModeValidity(runner, spec.assignedModel, spec.taskMode, catalog);
5985
+ if (!validity.ok) {
5986
+ errors.push(`${label}: ${validity.error}`);
5987
+ break;
5988
+ }
5989
+ const newId = (0, import_uuid2.v4)();
5530
5990
  tasks = addTaskToPlan(tasks, {
5531
5991
  ...spec,
5532
- id: void 0,
5992
+ id: newId,
5533
5993
  dependencies: ids,
5534
5994
  assignedRunner: runner,
5535
5995
  description: spec.description ?? spec.title,
5536
5996
  prompt: spec.prompt ?? spec.description ?? spec.title
5537
5997
  });
5998
+ if (op.handle) handleId.set(op.handle, newId);
5538
5999
  summary.push(`Added "${spec.title}"`);
5539
6000
  break;
5540
6001
  }
5541
6002
  case "remove": {
5542
- const target = resolveRef(op.taskId, flattenTasks(tasks));
6003
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
6004
+ if ("error" in ref) {
6005
+ errors.push(`${label}: ${ref.error}`);
6006
+ break;
6007
+ }
6008
+ const target = findInTasks(tasks, ref.id);
5543
6009
  if (!target) {
5544
- errors.push(`${label}: task "${op.taskId}" not found`);
6010
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
5545
6011
  break;
5546
6012
  }
5547
6013
  if (target.status === "in_progress") {
@@ -5557,7 +6023,13 @@ function applyTaskOps(currentTasks, ops, runners) {
5557
6023
  break;
5558
6024
  }
5559
6025
  case "reorder": {
5560
- const resolved = (op.taskIds ?? []).map((r) => resolveRef(r, tasks));
6026
+ const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
6027
+ const badRef = refs.find((r) => "error" in r);
6028
+ if (badRef) {
6029
+ errors.push(`${label}: ${badRef.error}`);
6030
+ break;
6031
+ }
6032
+ const resolved = refs.map((r) => findTopLevel(tasks, r.id));
5561
6033
  if (resolved.some((t) => !t)) {
5562
6034
  errors.push(`${label}: unknown task in reorder list`);
5563
6035
  break;
@@ -5573,9 +6045,15 @@ function applyTaskOps(currentTasks, ops, runners) {
5573
6045
  break;
5574
6046
  }
5575
6047
  case "merge": {
5576
- const resolved = (op.taskIds ?? []).map((r) => resolveRef(r, tasks));
6048
+ const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
6049
+ const badRef = refs.find((r) => "error" in r);
6050
+ if (badRef) {
6051
+ errors.push(`${label}: ${badRef.error}`);
6052
+ break;
6053
+ }
6054
+ const resolved = refs.map((r) => findTopLevel(tasks, r.id));
5577
6055
  if (resolved.some((t) => !t)) {
5578
- errors.push(`${label}: unknown task in merge list`);
6056
+ errors.push(`${label}: unknown task in merge list (already removed, merged, or split by an earlier op)`);
5579
6057
  break;
5580
6058
  }
5581
6059
  const toMerge = resolved.map((t) => t).sort((a, b) => a.order - b.order);
@@ -5590,7 +6068,7 @@ function applyTaskOps(currentTasks, ops, runners) {
5590
6068
  }
5591
6069
  const idSet = new Set(toMerge.map((t) => t.id));
5592
6070
  const unionDepsRaw = [...new Set(toMerge.flatMap((t) => t.dependencies))].filter((d) => !idSet.has(d));
5593
- const { ids, bad } = resolveDeps(unionDepsRaw);
6071
+ const { ids, bad } = resolveDeps(unionDepsRaw, i);
5594
6072
  if (bad.length) {
5595
6073
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
5596
6074
  break;
@@ -5631,11 +6109,17 @@ function applyTaskOps(currentTasks, ops, runners) {
5631
6109
  }
5632
6110
  if (!inserted) result.push(mergedTask);
5633
6111
  tasks = renumberTasks(result);
6112
+ if (op.handle) handleId.set(op.handle, mergedId);
5634
6113
  summary.push(`Merged ${toMerge.length} tasks into "${spec.title}"`);
5635
6114
  break;
5636
6115
  }
5637
6116
  case "split": {
5638
- const target = resolveRef(op.taskId, tasks);
6117
+ const ref = resolveRef(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
6118
+ if ("error" in ref) {
6119
+ errors.push(`${label}: ${ref.error}`);
6120
+ break;
6121
+ }
6122
+ const target = findTopLevel(tasks, ref.id);
5639
6123
  if (!target) {
5640
6124
  errors.push(`${label}: task "${op.taskId}" not found`);
5641
6125
  break;
@@ -5656,16 +6140,16 @@ function applyTaskOps(currentTasks, ops, runners) {
5656
6140
  const newTasks = [];
5657
6141
  let prevId;
5658
6142
  let buildError = false;
5659
- for (let i2 = 0; i2 < parts.length; i2++) {
5660
- const spec = parts[i2];
6143
+ for (let p = 0; p < parts.length; p++) {
6144
+ const spec = parts[p];
5661
6145
  if (!spec || !spec.title) {
5662
- errors.push(`${label}: split part ${i2 + 1} needs a title`);
6146
+ errors.push(`${label}: split part ${p + 1} needs a title`);
5663
6147
  buildError = true;
5664
6148
  break;
5665
6149
  }
5666
6150
  let deps;
5667
- if (i2 === 0) {
5668
- const { ids, bad } = resolveDeps(target.dependencies);
6151
+ if (p === 0) {
6152
+ const { ids, bad } = resolveDeps(target.dependencies, i);
5669
6153
  if (bad.length) {
5670
6154
  errors.push(`${label}: split part 1 unknown dependencies: ${bad.join(", ")}`);
5671
6155
  buildError = true;
@@ -5703,9 +6187,60 @@ function applyTaskOps(currentTasks, ops, runners) {
5703
6187
  result.push({ ...t, dependencies: t.dependencies.map((d) => d === target.id ? tailId : d) });
5704
6188
  }
5705
6189
  tasks = renumberTasks(result);
6190
+ if (op.handle) handleId.set(op.handle, tailId);
5706
6191
  summary.push(`Split "${target.title}" into ${newTasks.length} tasks`);
5707
6192
  break;
5708
6193
  }
6194
+ case "rearm": {
6195
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
6196
+ if ("error" in ref) {
6197
+ errors.push(`${label}: ${ref.error}`);
6198
+ break;
6199
+ }
6200
+ const target = findInTasks(tasks, ref.id);
6201
+ if (!target) {
6202
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
6203
+ break;
6204
+ }
6205
+ if (target.status === "in_progress") {
6206
+ errors.push(`${label}: "${target.title}" is running and cannot be re-armed`);
6207
+ break;
6208
+ }
6209
+ const changes = {};
6210
+ for (const key of UPDATABLE_FIELDS) {
6211
+ if (op.changes && key in op.changes) changes[key] = op.changes[key];
6212
+ }
6213
+ if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
6214
+ errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
6215
+ break;
6216
+ }
6217
+ const changesForCheck = { ...changes };
6218
+ delete changesForCheck.dependencies;
6219
+ const check = validateTaskEdit("direct", flattenTasks(tasks), target.id, changesForCheck, catalog);
6220
+ if (!check.ok) {
6221
+ errors.push(`${label}: ${check.error}`);
6222
+ break;
6223
+ }
6224
+ for (const field of check.clear ?? []) changes[field] = void 0;
6225
+ if ("dependencies" in changes) {
6226
+ const { ids, bad } = resolveDeps(changes.dependencies, i);
6227
+ if (bad.length) {
6228
+ errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
6229
+ break;
6230
+ }
6231
+ changes.dependencies = ids.filter((id) => id !== target.id);
6232
+ }
6233
+ tasks = updateTaskInPlan(tasks, target.id, { ...changes, status: "pending", verdict: void 0, outputSummary: void 0 });
6234
+ for (const dep of flattenTasks(tasks)) {
6235
+ if (dep.status === "blocked" && dep.dependencies.includes(target.id)) {
6236
+ tasks = updateTaskInPlan(tasks, dep.id, { status: "pending" });
6237
+ }
6238
+ }
6239
+ const clearedNote = check.clear?.length ? `; cleared ${check.clear.join(", ")}` : "";
6240
+ const changedNote = Object.keys(changes).length ? ` (${Object.keys(changes).join(", ")})` : "";
6241
+ summary.push(`Re-armed "${target.title}"${changedNote}${clearedNote}`);
6242
+ break;
6243
+ }
5709
6244
  default:
5710
6245
  errors.push(`${label}: unknown op "${op.op}"`);
5711
6246
  }
@@ -5721,13 +6256,14 @@ function applyTaskOps(currentTasks, ops, runners) {
5721
6256
  }
5722
6257
  const cycleAt = detectCycle(flat);
5723
6258
  if (cycleAt) errors.push(`Dependency cycle detected involving "${cycleAt}"`);
5724
- const orderById = new Map(tasks.map((t) => [t.id, t.order]));
5725
- for (const t of tasks) {
5726
- for (const dep of t.dependencies) {
5727
- const depOrder = orderById.get(dep);
5728
- if (depOrder !== void 0 && depOrder >= t.order) {
5729
- errors.push(`"${t.title}" is ordered before its dependency; move the dependency earlier`);
5730
- }
6259
+ if (!errors.length) {
6260
+ const repaired = repairOrder(tasks);
6261
+ if ("error" in repaired) {
6262
+ errors.push(repaired.error);
6263
+ } else {
6264
+ const moves = describeMoves(tasks, repaired.tasks);
6265
+ if (moves) summary.push(`Reordered to keep dependencies first: ${moves}`);
6266
+ tasks = renumberTasks(repaired.tasks);
5731
6267
  }
5732
6268
  }
5733
6269
  }
@@ -5757,6 +6293,9 @@ function truncatedPlanReEmitPrompt(historyCompacted) {
5757
6293
  function reEmitTaskOpsPrompt(detail) {
5758
6294
  return `Your task edits could not be applied: ${detail}. Re-emit ONLY a corrected {"${TASK_OPS_ENVELOPE_KEY}":[...]} JSON object \u2014 no prose, no code fences \u2014 or reply in prose if you did not intend to edit tasks.`;
5759
6295
  }
6296
+ function reEmitTaskQueryPrompt(detail) {
6297
+ return `Your task-detail request could not be read: ${detail}. Re-emit ONLY a corrected {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>"],"fields"?:[...],"catalog"?:true}} JSON object \u2014 no prose, no code fences \u2014 or reply in prose if you did not mean to look anything up.`;
6298
+ }
5760
6299
  function taskOpsRejectedPrompt(errors) {
5761
6300
  return `Those task edits were rejected:
5762
6301
  - ${errors.join("\n- ")}
@@ -5783,6 +6322,16 @@ function classifyPlannerReply(text, opts) {
5783
6322
  }
5784
6323
  }
5785
6324
  }
6325
+ if (textHasTaskQuery(text)) {
6326
+ try {
6327
+ return { kind: "task_query", query: parseTaskQueryJson(text) };
6328
+ } catch (err) {
6329
+ if (!(err instanceof PlanParseError)) throw err;
6330
+ if (extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY).matches.length > 0) {
6331
+ return { kind: "broken_task_query", error: err };
6332
+ }
6333
+ }
6334
+ }
5786
6335
  if (text.includes(`"${PLAN_ENVELOPE_KEY}"`)) {
5787
6336
  try {
5788
6337
  return { kind: "plan", tasks: parsePlanJson(text, opts.runners, opts.runnerModes, opts.autonomousDefault) };
@@ -6426,7 +6975,7 @@ var BaseAiService = class _BaseAiService {
6426
6975
  const parallelIndexes = toolCalls.flatMap((tc, i) => PARALLEL_SAFE_TOOLS.has(tc.name) ? [i] : []);
6427
6976
  await Promise.all([
6428
6977
  spawnIndexes.length > 0 ? mapWithConcurrency(spawnIndexes, SUBAGENT_LIMITS.maxConcurrent, async (i) => {
6429
- const subagentId = (0, import_uuid2.v4)();
6978
+ const subagentId = (0, import_uuid3.v4)();
6430
6979
  spawnSubagentIds.set(i, subagentId);
6431
6980
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
6432
6981
  precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs10, onProgress, signal, subagentId));
@@ -6581,6 +7130,10 @@ ${llmOutput}
6581
7130
  switch (reply.kind) {
6582
7131
  case "task_ops":
6583
7132
  return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
7133
+ // A read is settled by the Session (it owns the plan and the catalog),
7134
+ // so it leaves this loop the same way a plan or an edit does.
7135
+ case "task_query":
7136
+ return { kind: "task_query", query: reply.query, text: turn.text, researchLog };
6584
7137
  case "plan":
6585
7138
  if (ctx.prdEnabled && !ctx.prdCaptured && !ctx.prdNudgeSent && !signal?.aborted) {
6586
7139
  ctx.prdNudgeSent = true;
@@ -6603,6 +7156,13 @@ ${llmOutput}
6603
7156
  continue;
6604
7157
  }
6605
7158
  break;
7159
+ case "broken_task_query":
7160
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
7161
+ jsonRepairAttempts++;
7162
+ pending = reEmitTaskQueryPrompt(reply.error.message);
7163
+ continue;
7164
+ }
7165
+ break;
6606
7166
  case "broken_plan":
6607
7167
  if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
6608
7168
  jsonRepairAttempts++;
@@ -8582,111 +9142,6 @@ ${output.slice(-3e3)}`);
8582
9142
  }
8583
9143
  };
8584
9144
 
8585
- // src/services/ModelAllowlistResolver.ts
8586
- function effectiveAllowlist(allowlist, runner, modelsByRunner) {
8587
- if (!allowlist || allowlist.length === 0) return void 0;
8588
- if (!modelsByRunner) return allowlist;
8589
- const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
8590
- const elsewhere = /* @__PURE__ */ new Set();
8591
- for (const [other, models] of Object.entries(modelsByRunner)) {
8592
- if (other === runner) continue;
8593
- for (const m of models ?? []) elsewhere.add(m.modelId);
8594
- }
8595
- const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
8596
- return kept.length > 0 ? kept : void 0;
8597
- }
8598
- function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
8599
- const result = {};
8600
- for (const [runner, models] of Object.entries(modelsByRunner)) {
8601
- if (!models) continue;
8602
- const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
8603
- if (!allowed) {
8604
- result[runner] = models;
8605
- continue;
8606
- }
8607
- const allowedSet = new Set(allowed);
8608
- const intersection = models.filter((m) => allowedSet.has(m.modelId));
8609
- const covered = new Set(intersection.map((m) => m.modelId));
8610
- const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
8611
- result[runner] = [...intersection, ...missing];
8612
- }
8613
- return result;
8614
- }
8615
- var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
8616
- var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
8617
- function clampThinkingEffort(effort, variants) {
8618
- if (!effort) return void 0;
8619
- if (variants.length === 0) return void 0;
8620
- const ids = new Set(variants.map((v) => v.id));
8621
- if (ids.has(effort)) return effort;
8622
- const canon = EFFORT_ALIASES[effort] ?? effort;
8623
- if (ids.has(canon)) return canon;
8624
- const idx = EFFORT_LADDER.indexOf(canon);
8625
- if (idx < 0) return void 0;
8626
- for (let d = 1; d < EFFORT_LADDER.length; d++) {
8627
- const lower = EFFORT_LADDER[idx - d];
8628
- if (lower && ids.has(lower)) return lower;
8629
- const higher = EFFORT_LADDER[idx + d];
8630
- if (higher && ids.has(higher)) return higher;
8631
- }
8632
- return void 0;
8633
- }
8634
- function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
8635
- const clampVariant = (task) => {
8636
- if (!task.assignedModel || !modelsByRunner) return task;
8637
- const model = modelsByRunner[task.assignedRunner]?.find(
8638
- (m) => m.modelId === task.assignedModel.modelId
8639
- );
8640
- if (!model) return task;
8641
- const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
8642
- return {
8643
- ...task,
8644
- assignedModel: {
8645
- ...task.assignedModel,
8646
- thinkingEffort: clamped,
8647
- availableVariants: model.variants.map((v) => v.id)
8648
- }
8649
- };
8650
- };
8651
- const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
8652
- const assignmentFor = (runner, modelId) => {
8653
- const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
8654
- return {
8655
- modelId,
8656
- modelLabel: known?.modelLabel ?? modelId,
8657
- thinkingEffort: void 0,
8658
- ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
8659
- };
8660
- };
8661
- return tasks.map((task) => {
8662
- if (task.type === "user") return task;
8663
- if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
8664
- const fallbackRunner = allowedRunners[0];
8665
- const fallbackModels = allowedFor(fallbackRunner);
8666
- const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
8667
- return clampVariant({
8668
- ...task,
8669
- assignedRunner: fallbackRunner,
8670
- assignedModel: fallbackModel
8671
- });
8672
- }
8673
- const allowlist = allowedFor(task.assignedRunner);
8674
- if (!task.assignedModel) {
8675
- if (allowlist && allowlist.length > 0) {
8676
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
8677
- }
8678
- const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
8679
- if (discovered) {
8680
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
8681
- }
8682
- return task;
8683
- }
8684
- if (!allowlist || allowlist.length === 0) return clampVariant(task);
8685
- if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
8686
- return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
8687
- });
8688
- }
8689
-
8690
9145
  // src/services/Planner.ts
8691
9146
  function plannerModesWith(req) {
8692
9147
  const modes = req.modes ?? DEFAULT_PLANNER_MODES;
@@ -10398,12 +10853,19 @@ var StdioAgentAdapter = class {
10398
10853
  /** Set for the duration of a turn. Outside one, events are buffered rather than dropped. */
10399
10854
  turnEmit = null;
10400
10855
  /**
10401
- * Events the agent produced between turns — in practice the startup warnings
10402
- * that arrive during the handshake. Dropping them hid the one diagnostic
10403
- * that explains a planner which cannot read the workspace, so they are held
10404
- * until a turn exists to show them in.
10856
+ * Events the agent produced before its first turn — in practice the startup
10857
+ * warnings that arrive during the handshake. Dropping them hid the one
10858
+ * diagnostic that explains a planner which cannot read the workspace, so they
10859
+ * are held until a turn exists to show them in.
10860
+ *
10861
+ * Only before the *first* turn. Once one has run, anything arriving outside a
10862
+ * turn is that closed turn's straggling work — a backgrounded subagent
10863
+ * finishing, a late read — and replaying it into the next turn presents it as
10864
+ * research done for the user's new message.
10405
10865
  */
10406
10866
  betweenTurns = [];
10867
+ /** Set once a turn has ended: after that, out-of-turn events are stale, not startup. */
10868
+ hadTurn = false;
10407
10869
  disposed = false;
10408
10870
  /** Resolves when the process ends, so a handshake can lose the race instead of waiting out its timeout. */
10409
10871
  processEnded;
@@ -10438,7 +10900,7 @@ var StdioAgentAdapter = class {
10438
10900
  this.process.stdout?.on("data", (chunk) => {
10439
10901
  this.stdout.push(chunk.toString(), (line) => this.handleLine(line, (event) => {
10440
10902
  if (this.turnEmit) this.turnEmit(event);
10441
- else if (!TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
10903
+ else if (!this.hadTurn && !TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
10442
10904
  this.betweenTurns.push(event);
10443
10905
  }
10444
10906
  }));
@@ -10470,6 +10932,7 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS);
10470
10932
  if (settled) return;
10471
10933
  settled = true;
10472
10934
  this.turnEmit = null;
10935
+ this.hadTurn = true;
10473
10936
  signal?.removeEventListener("abort", onAbort);
10474
10937
  this.process?.removeListener("exit", onExit);
10475
10938
  this.process?.removeListener("error", onExit);
@@ -10548,6 +11011,7 @@ ${tail}` : ""}`;
10548
11011
 
10549
11012
  // src/services/harness/ClaudeCodeAdapter.ts
10550
11013
  var DISALLOWED_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "KillShell"];
11014
+ var ASYNC_LAUNCH_MARKER = "Async agent launched successfully";
10551
11015
  function flattenContent(content) {
10552
11016
  if (typeof content === "string") return content;
10553
11017
  if (Array.isArray(content)) {
@@ -10557,6 +11021,8 @@ function flattenContent(content) {
10557
11021
  }
10558
11022
  var ClaudeCodeAdapter = class extends StdioAgentAdapter {
10559
11023
  agentId = "claude-code";
11024
+ /** Whether this turn has already emitted reply text — see {@link handleLine}. */
11025
+ turnHasText = false;
10560
11026
  spawnSpec(opts) {
10561
11027
  const args = [
10562
11028
  "-p",
@@ -10581,6 +11047,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
10581
11047
  return { command: "claude", args };
10582
11048
  }
10583
11049
  turnPayload(message) {
11050
+ this.turnHasText = false;
10584
11051
  return `${JSON.stringify({
10585
11052
  type: "user",
10586
11053
  message: { role: "user", content: [{ type: "text", text: message }] }
@@ -10591,6 +11058,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
10591
11058
  const msg = StdioAgentAdapter.parse(line);
10592
11059
  if (!msg) return;
10593
11060
  if (msg.session_id) this.sessionId = msg.session_id;
11061
+ if (msg.parent_tool_use_id) return;
10594
11062
  switch (msg.type) {
10595
11063
  // The control channel: Claude asks whether a tool may run when its mode
10596
11064
  // cannot decide alone. A read-only planner answers "deny", every time —
@@ -10615,8 +11083,12 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
10615
11083
  }
10616
11084
  case "assistant":
10617
11085
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
10618
- if (block.type === "text" && block.text) emit({ type: "assistant_text", text: block.text });
10619
- else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
11086
+ if (block.type === "text" && block.text) {
11087
+ emit({ type: "assistant_text", text: this.turnHasText ? `
11088
+
11089
+ ${block.text}` : block.text });
11090
+ this.turnHasText = true;
11091
+ } else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
10620
11092
  else if (block.type === "tool_use" && block.name) {
10621
11093
  emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {} });
10622
11094
  }
@@ -10625,11 +11097,15 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
10625
11097
  case "user":
10626
11098
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
10627
11099
  if (block.type !== "tool_result") continue;
11100
+ const output = flattenContent(block.content);
11101
+ if (output.includes(ASYNC_LAUNCH_MARKER)) {
11102
+ emit({ type: "background_agent", id: block.tool_use_id ?? "" });
11103
+ }
10628
11104
  emit({
10629
11105
  type: "tool_result",
10630
11106
  id: block.tool_use_id ?? "",
10631
11107
  name: "",
10632
- output: flattenContent(block.content),
11108
+ output,
10633
11109
  success: block.is_error !== true
10634
11110
  });
10635
11111
  }
@@ -11049,6 +11525,8 @@ var OpenCodeAdapter = class {
11049
11525
  exited = false;
11050
11526
  disposed = false;
11051
11527
  opts = null;
11528
+ /** Whether this turn has already emitted reply text — see {@link emitPart}. */
11529
+ turnHasText = false;
11052
11530
  async start(opts) {
11053
11531
  this.opts = opts;
11054
11532
  assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
@@ -11116,6 +11594,7 @@ ${this.stderrTail.trim()}` : ""}`);
11116
11594
  return;
11117
11595
  }
11118
11596
  const seen = /* @__PURE__ */ new Set();
11597
+ this.turnHasText = false;
11119
11598
  const streamAbort = new AbortController();
11120
11599
  let connected = () => {
11121
11600
  };
@@ -11184,7 +11663,10 @@ ${this.stderrTail.trim()}` : ""}`);
11184
11663
  if (part.type === "text" && part.text) {
11185
11664
  if (seen.has(`text:${id}`)) return;
11186
11665
  seen.add(`text:${id}`);
11187
- onEvent({ type: "assistant_text", text: part.text });
11666
+ onEvent({ type: "assistant_text", text: this.turnHasText ? `
11667
+
11668
+ ${part.text}` : part.text });
11669
+ this.turnHasText = true;
11188
11670
  return;
11189
11671
  }
11190
11672
  if (part.type === "reasoning" && part.text) {
@@ -11351,6 +11833,7 @@ function normalizeAgentArgs(tool, args) {
11351
11833
 
11352
11834
  // src/services/harness/CliAgentAiService.ts
11353
11835
  var MAX_JSON_REPAIRS = 2;
11836
+ var MAX_AGENT_WAITS = 2;
11354
11837
  var LOG_MAX_CHARS = 1e4;
11355
11838
  function defaultAdapter(runner, deps) {
11356
11839
  switch (runner) {
@@ -11491,13 +11974,16 @@ var CliAgentAiService = class {
11491
11974
  let pending = message;
11492
11975
  let emptyNudgeSent = false;
11493
11976
  let jsonRepairAttempts = 0;
11977
+ let agentWaits = 0;
11978
+ const carried = [];
11979
+ const replyText = (text) => [...carried, text].filter((part) => part.trim()).join("\n\n");
11494
11980
  try {
11495
11981
  for (; ; ) {
11496
11982
  const turn = await this.runTurn(pending, onProgress, combined);
11497
11983
  researchLog.push(...turn.researchLog);
11498
11984
  if (turn.aborted || combined?.aborted) {
11499
11985
  onProgress({ type: "interrupted" });
11500
- return { kind: "message", text: turn.text, researchLog };
11986
+ return { kind: "message", text: replyText(turn.text), researchLog };
11501
11987
  }
11502
11988
  if (turn.error) {
11503
11989
  return { kind: "message", text: turn.error, researchLog };
@@ -11515,6 +12001,18 @@ var CliAgentAiService = class {
11515
12001
  researchLog
11516
12002
  };
11517
12003
  }
12004
+ if (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && !combined?.aborted) {
12005
+ agentWaits++;
12006
+ carried.push(turn.text);
12007
+ pending = [
12008
+ `You ended your turn with ${turn.backgroundAgents} subagent(s) still running in the background.`,
12009
+ "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
12010
+ "the results you promised to report would be lost.",
12011
+ "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
12012
+ "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
12013
+ ].join(" ");
12014
+ continue;
12015
+ }
11518
12016
  if (extractPrdBlock(turn.text)) conversation.prdCaptured = true;
11519
12017
  const reply = classifyPlannerReply(turn.text, {
11520
12018
  runners: conversation.runners,
@@ -11523,7 +12021,11 @@ var CliAgentAiService = class {
11523
12021
  });
11524
12022
  switch (reply.kind) {
11525
12023
  case "task_ops":
11526
- return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
12024
+ return { kind: "task_ops", ops: reply.ops, text: replyText(turn.text), researchLog };
12025
+ // The read channel is a text envelope precisely so it reaches here
12026
+ // too: a harness planner has no Ordewell tool loop to call into.
12027
+ case "task_query":
12028
+ return { kind: "task_query", query: reply.query, text: replyText(turn.text), researchLog };
11527
12029
  case "plan":
11528
12030
  if (conversation.prdEnabled && !conversation.prdCaptured && !conversation.prdNudgeSent && !combined?.aborted) {
11529
12031
  conversation.prdNudgeSent = true;
@@ -11536,7 +12038,7 @@ var CliAgentAiService = class {
11536
12038
  continue;
11537
12039
  }
11538
12040
  this.conversation = null;
11539
- return { kind: "plan", tasks: reply.tasks, text: turn.text, researchLog };
12041
+ return { kind: "plan", tasks: reply.tasks, text: replyText(turn.text), researchLog };
11540
12042
  case "broken_task_ops":
11541
12043
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
11542
12044
  jsonRepairAttempts++;
@@ -11544,6 +12046,13 @@ var CliAgentAiService = class {
11544
12046
  continue;
11545
12047
  }
11546
12048
  break;
12049
+ case "broken_task_query":
12050
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
12051
+ jsonRepairAttempts++;
12052
+ pending = reEmitTaskQueryPrompt(reply.error.message);
12053
+ continue;
12054
+ }
12055
+ break;
11547
12056
  case "broken_plan":
11548
12057
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
11549
12058
  jsonRepairAttempts++;
@@ -11554,7 +12063,7 @@ var CliAgentAiService = class {
11554
12063
  case "prose":
11555
12064
  break;
11556
12065
  }
11557
- return { kind: "message", text: turn.text, researchLog };
12066
+ return { kind: "message", text: replyText(turn.text), researchLog };
11558
12067
  }
11559
12068
  } finally {
11560
12069
  this.activeAbort = null;
@@ -11574,6 +12083,7 @@ var CliAgentAiService = class {
11574
12083
  let text = "";
11575
12084
  let error;
11576
12085
  let stepIndex = 0;
12086
+ let backgroundAgents = 0;
11577
12087
  const truncate = (value) => value.length > LOG_MAX_CHARS ? `${value.slice(0, LOG_MAX_CHARS)}
11578
12088
  [... truncated, total ${value.length} chars]` : value;
11579
12089
  const settle = (id, rawOutput, success, outcome) => {
@@ -11613,6 +12123,9 @@ var CliAgentAiService = class {
11613
12123
  case "tool_result":
11614
12124
  settle(event.id, event.output, event.success, event.success ? "success" : "failure");
11615
12125
  return;
12126
+ case "background_agent":
12127
+ backgroundAgents++;
12128
+ return;
11616
12129
  case "permission_request": {
11617
12130
  const mapped = mapAgentTool(event.name);
11618
12131
  const args = JSON.stringify({ detail: event.detail });
@@ -11643,7 +12156,7 @@ var CliAgentAiService = class {
11643
12156
  adapter.dispose();
11644
12157
  this.adapter = null;
11645
12158
  }
11646
- return { text, researchLog, error, aborted: signal?.aborted };
12159
+ return { text, researchLog, error, aborted: signal?.aborted, backgroundAgents };
11647
12160
  }
11648
12161
  // --- Process lifecycle ---
11649
12162
  async startAdapter(opts) {
@@ -12250,6 +12763,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
12250
12763
  }
12251
12764
 
12252
12765
  // src/services/createSession.ts
12766
+ var CATALOG_MODEL_CAP = 100;
12767
+ var MAX_TASK_QUERIES = 3;
12768
+ var MAX_TASK_QUERIES_HARD = 6;
12769
+ function freshReadBudget() {
12770
+ return { answered: 0, seen: /* @__PURE__ */ new Set() };
12771
+ }
12253
12772
  var PlanEditError = class extends Error {
12254
12773
  constructor(message) {
12255
12774
  super(message);
@@ -12486,6 +13005,21 @@ var Session = class {
12486
13005
  runnerModesFor(runners) {
12487
13006
  return runnerModesFrom(this.registry, runners);
12488
13007
  }
13008
+ /**
13009
+ * The catalog a model/task-mode edit is checked against — the same
13010
+ * discovered models and manifest modes the per-turn catalog block shows the
13011
+ * planner, so a refusal here can never name something invalid that the
13012
+ * planner was never told about. `modelsCache` is read live, not filtered by
13013
+ * the allowlist here — {@link checkModelAndModeValidity} narrows by
13014
+ * allowlist itself, the same way `coerceAssignments` does.
13015
+ */
13016
+ editCatalog() {
13017
+ return {
13018
+ modelsByRunner: this.modelsCache,
13019
+ runnerModes: this.runnerModesFor(this.plan?.runners ?? []),
13020
+ perRunnerAllowlist: this.settingsFn().modelAllowlist ?? this.currentAllowlist
13021
+ };
13022
+ }
12489
13023
  get planState() {
12490
13024
  return this.plan;
12491
13025
  }
@@ -12665,10 +13199,12 @@ var Session = class {
12665
13199
  async continueConversation(userMessage, options) {
12666
13200
  if (!this.plan) throw new Error("No planning conversation to continue");
12667
13201
  const priorHistory = this.plan.conversationHistory ?? [];
13202
+ const priorResearchLog = this.plan.researchLog ?? [];
12668
13203
  const now = (/* @__PURE__ */ new Date()).toISOString();
12669
13204
  this.appendTranscript("user", userMessage, now);
13205
+ const historyAfterOwnAppend = this.plan.conversationHistory;
12670
13206
  this.plan.researchLog = [...this.plan.researchLog ?? [], { id: `up-${Date.now()}`, type: "user_prompt", content: userMessage, timestamp: now }];
12671
- const contextBlock = this.planContextBlock();
13207
+ const contextBlock = [this.catalogBlock(), this.planContextBlock()].filter(Boolean).join("\n\n");
12672
13208
  const outgoing = contextBlock ? `${contextBlock}
12673
13209
 
12674
13210
  ${userMessage}` : userMessage;
@@ -12681,6 +13217,8 @@ ${userMessage}` : userMessage;
12681
13217
  (p) => this.translateProgress(p),
12682
13218
  options?.signal
12683
13219
  ) : await this.resumeConversation(outgoing, priorHistory, options);
13220
+ const reads = freshReadBudget();
13221
+ turn = await this.drainTaskQueries(turn, reads, options);
12684
13222
  if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
12685
13223
  this.queueMessage(userMessage);
12686
13224
  this.plan.queuedMessages = this.getQueuedMessages();
@@ -12690,11 +13228,74 @@ ${userMessage}` : userMessage;
12690
13228
  researchLog: turn.researchLog
12691
13229
  };
12692
13230
  }
12693
- return await this.settleTurn(turn, options);
13231
+ return await this.settleTurn(turn, options, reads);
13232
+ } catch (err) {
13233
+ if (this.plan && this.plan.conversationHistory === historyAfterOwnAppend) {
13234
+ this.plan.conversationHistory = priorHistory;
13235
+ this.plan.researchLog = priorResearchLog;
13236
+ }
13237
+ throw err;
12694
13238
  } finally {
12695
13239
  releaseAbort();
12696
13240
  }
12697
13241
  }
13242
+ /**
13243
+ * Answer every read the planner emits until it says something else.
13244
+ *
13245
+ * The channel is a text envelope rather than a registered tool because the
13246
+ * protocol has to be identical on both planner backends (ADR-0009): Ordewell
13247
+ * owns a tool loop only in the API case, and a harness planner running as a
13248
+ * coding-agent subprocess can only be reached this way.
13249
+ *
13250
+ * Draining here — outside {@link repairLoop} — is what keeps reads free of
13251
+ * the repair budget. A planner that looks a task up and *then* fumbles its
13252
+ * ops JSON still gets its two corrective retries; charging it for the read
13253
+ * would cost it the chance to fix the edit.
13254
+ */
13255
+ async drainTaskQueries(turn, reads, options) {
13256
+ const carried = [];
13257
+ let current = turn;
13258
+ while (current.kind === "task_query") {
13259
+ carried.push(...current.researchLog);
13260
+ if (reads.answered >= MAX_TASK_QUERIES_HARD || !this.aiService.hasActiveConversation() || options?.signal?.aborted) {
13261
+ return {
13262
+ kind: "message",
13263
+ text: "The planner kept asking to read tasks instead of answering. Nothing was changed \u2014 ask again, or be more specific about the edit you want.",
13264
+ researchLog: carried
13265
+ };
13266
+ }
13267
+ const signature = taskQuerySignature(current.query);
13268
+ const insist = reads.seen.has(signature) || reads.answered >= MAX_TASK_QUERIES;
13269
+ reads.seen.add(signature);
13270
+ reads.answered++;
13271
+ const answer = this.taskQueryAnswer(current.query);
13272
+ current = await this.aiService.continueConversation(
13273
+ insist ? `${answer}
13274
+
13275
+ ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
13276
+ (p) => this.translateProgress(p),
13277
+ options?.signal
13278
+ );
13279
+ }
13280
+ return carried.length > 0 ? { ...current, researchLog: [...carried, ...current.researchLog] } : current;
13281
+ }
13282
+ /**
13283
+ * Render one read out of live state. Never persisted to the transcript: the
13284
+ * detail is context for the planner's next reply, and re-sending it on every
13285
+ * later turn is exactly the cost this channel exists to avoid.
13286
+ */
13287
+ taskQueryAnswer(query) {
13288
+ const runners = this.plan?.runners ?? [];
13289
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
13290
+ return renderTaskQueryAnswer(query, this.store.planTasks, {
13291
+ runners,
13292
+ // Allowlist-filtered like the always-on block: a read must not offer a
13293
+ // model the planner is forbidden to assign.
13294
+ models: filterModelsForPrompt(this.modelsCache, allowlist),
13295
+ modes: this.runnerModesFor(runners),
13296
+ autonomousDefault: this.config.autonomousMode
13297
+ });
13298
+ }
12698
13299
  /**
12699
13300
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
12700
13301
  * atomically; validation failures are fed back to the model for up to 2
@@ -12702,7 +13303,7 @@ ${userMessage}` : userMessage;
12702
13303
  * first turn (startPlanning) and every later turn route through here — one
12703
13304
  * path, not two.
12704
13305
  */
12705
- async settleTurn(turn, options) {
13306
+ async settleTurn(turn, options, reads = freshReadBudget()) {
12706
13307
  const invalidOps = (errors, researchLog) => ({
12707
13308
  turn: {
12708
13309
  kind: "message",
@@ -12714,11 +13315,15 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
12714
13315
  }
12715
13316
  });
12716
13317
  const settled = await repairLoop({
12717
- first: () => turn,
12718
- resend: (corrective) => this.aiService.continueConversation(
12719
- corrective,
12720
- (p) => this.translateProgress(p),
12721
- options?.signal
13318
+ first: () => this.drainTaskQueries(turn, reads, options),
13319
+ resend: async (corrective) => this.drainTaskQueries(
13320
+ await this.aiService.continueConversation(
13321
+ corrective,
13322
+ (p) => this.translateProgress(p),
13323
+ options?.signal
13324
+ ),
13325
+ reads,
13326
+ options
12722
13327
  ),
12723
13328
  interpret: (t) => {
12724
13329
  if (t.kind !== "task_ops") return { done: { turn: t } };
@@ -12738,6 +13343,52 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
12738
13343
  }
12739
13344
  return this.applyConversationTurn(settled.turn);
12740
13345
  }
13346
+ /**
13347
+ * The catalog the planner may actually draw from — model ids and task-mode
13348
+ * ids, per selected runner — emitted on EVERY turn, before any plan exists
13349
+ * or after. The system prompt shows this once at conversation start; a long
13350
+ * grill-me interview or PRD negotiation outlives that single showing and the
13351
+ * planner starts misquoting it, so this re-states it per turn instead.
13352
+ *
13353
+ * Built from the allowlist-filtered view (`filterModelsForPrompt`), the same
13354
+ * one the system prompt used — a restricted allowlist stays a hard bound on
13355
+ * every turn, not just the first. `this.modelsCache` itself is never
13356
+ * filtered: `coerceAssignments` needs the full discovered catalog alongside
13357
+ * the allowlist to resolve labels and clamp effort to real variants.
13358
+ *
13359
+ * Reads `this.plan.runners` live, so a runner admitted mid-session by a
13360
+ * retarget (`admitRunner`) is filtered and shown like every other — there is
13361
+ * no separate "originally selected" list to fall stale.
13362
+ */
13363
+ catalogBlock() {
13364
+ if (!this.plan) return null;
13365
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
13366
+ const filteredModels = filterModelsForPrompt(this.modelsCache, allowlist);
13367
+ const runnerModes = this.runnerModesFor(this.plan.runners);
13368
+ const autonomousDefault = this.config.autonomousMode;
13369
+ const modelLines = this.plan.runners.map((runner) => {
13370
+ const models = filteredModels[runner] ?? [];
13371
+ const capped = models.slice(0, CATALOG_MODEL_CAP);
13372
+ const remainder = models.length - capped.length;
13373
+ const ids = capped.map((m) => m.modelId).join(", ") || "(no models discovered)";
13374
+ return `${runner}: ${ids}${remainder > 0 ? ` \u2026 +${remainder} more not shown` : ""}`;
13375
+ });
13376
+ const modeLines = this.plan.runners.map((runner) => {
13377
+ const modes = runnerModes[runner] ?? [];
13378
+ if (modes.length === 0) return `${runner}: (no modes declared)`;
13379
+ const defaultId = resolveDefaultMode(modes, autonomousDefault);
13380
+ const ids = modes.map((m) => `${m.id}${m.id === defaultId ? " (default)" : ""}`).join(", ");
13381
+ return `${runner}: ${ids}`;
13382
+ });
13383
+ return [
13384
+ "<available_models>",
13385
+ ...modelLines,
13386
+ "</available_models>",
13387
+ "<available_task_modes>",
13388
+ ...modeLines,
13389
+ "</available_task_modes>"
13390
+ ].join("\n");
13391
+ }
12741
13392
  /**
12742
13393
  * The "you are here" block for post-plan chat: current tasks with stable
12743
13394
  * references, plus the task-ops protocol. Injected per turn (never
@@ -12747,44 +13398,48 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
12747
13398
  planContextBlock() {
12748
13399
  if (!this.plan || this.store.planTasks.length === 0) return null;
12749
13400
  const orderOf = new Map(this.store.planTasks.map((t) => [t.id, `#${t.order}`]));
12750
- const lines = this.store.planTasks.map(
12751
- (t) => `#${t.order} id=${t.id} "${t.title}" [${t.status}] runner:${t.assignedRunner}${t.assignedModel ? ` model:${t.assignedModel.modelId}` : ""} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`
12752
- );
13401
+ const lines = this.store.planTasks.map((t) => {
13402
+ const isMan = t.type === "user";
13403
+ const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
13404
+ 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(", ")}]`;
13405
+ });
12753
13406
  const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
12754
13407
  const execNote = this.hasLiveWork ? `
12755
13408
  Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.map((t) => `#${t.order}`).join(", ")}` : ""}. Any task edits you emit will be queued and applied between batches.` : "";
12756
- const modelLines = [];
12757
- for (const runner of this.plan.runners) {
12758
- const models = this.modelsCache[runner] ?? [];
12759
- const ids = models.map((m) => m.modelId).join(", ");
12760
- modelLines.push(`${runner}: ${ids || "(no models discovered)"}`);
12761
- }
12762
- const modelBlock = modelLines.length > 0 ? ["<available_models>", ...modelLines, "</available_models>"].join("\n") : "";
13409
+ const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
12763
13410
  return [
12764
13411
  "<current_plan>",
12765
13412
  ...lines,
12766
13413
  "</current_plan>",
12767
- modelBlock,
12768
- "The block above is the CURRENT task plan. Choose how to respond:",
13414
+ "The block above is the CURRENT task plan \u2014 short fields only. Choose how to respond:",
12769
13415
  "- To answer a question or discuss, reply in plain prose (no JSON).",
13416
+ // The reminder text is owned by TaskQuery beside the parser, so the
13417
+ // protocol the planner reads and the one Ordewell accepts stay one thing.
13418
+ TASK_QUERY_REMINDER,
12770
13419
  "- To modify specific tasks, reply with ONLY this JSON object:",
12771
13420
  ' {"taskOps": [',
12772
- ' {"op":"update","taskId":"<id or #order>","changes":{"title"?,"description"?,"prompt"?,"dependencies"?:["<id or #order>"],"assignedRunner"?,"assignedModel"?,"taskMode"?}},',
12773
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?}},',
13421
+ ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
13422
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
12774
13423
  ' {"op":"remove","taskId":"<id or #order>"},',
12775
- ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]},',
12776
- ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}},',
12777
- ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...]}',
13424
+ ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
13425
+ ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
13426
+ ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...],"handle"?:"<name>"},',
13427
+ ` {"op":"rearm","taskId":"<id or #order>","changes"?:{${updateChanges}}}`,
12778
13428
  " ]}",
12779
13429
  '- For sweeping changes, you may instead emit a full {"tasks":[...]} plan JSON.',
13430
+ 'Every "<id or #order>" ref in a batch resolves against the plan shown above, before any op in the batch runs \u2014 an earlier remove/merge/split never shifts what a later "#N" means.',
13431
+ 'Give "add", "merge", or "split" a "handle" (any name you choose, unused elsewhere in this batch) to let a LATER op in the same batch reference the task it produces \u2014 for "split", the handle names its last part. A handle used before its defining op is rejected.',
12780
13432
  'When creating or changing tasks, set "assignedRunner" and "assignedModel" ({"modelId","modelLabel","thinkingEffort"?}) using only the runners and models listed in <available_models>.',
12781
- "Keep dependencies consistent: no cycles, no references to removed tasks, dependencies must come before dependents, and never touch running or completed tasks." + execNote
13433
+ 'Keep dependencies consistent: no cycles, no references to removed tasks, and never touch running or completed tasks \u2014 "rearm" is the one exception, below.',
13434
+ 'Just declare the dependencies you want \u2014 display order is repaired for you afterwards, so a rewire or a newly added prerequisite never needs a "reorder" op. Only a task that is running or completed cannot be shifted, so an edit that would need one to move is rejected.' + execNote,
13435
+ 'Flipping "type" between "ai" and "user" is a content change, not just a label: an update to "user" needs "userSteps" in the SAME op, and an update to "ai" needs "prompt" in the SAME op \u2014 the model/mode/effort/autonomy fields (flipping to "user") or the userSteps (flipping to "ai") are cleared automatically.',
13436
+ '"rearm" puts a failed OR completed task back to pending \u2014 verdict and output summary are cleared, any dependents it had blocked are released, and it may carry field changes (e.g. a corrected "prompt") applied in the same op. A running task cannot be re-armed. Never rearm a task just to relabel it \u2014 only when it should actually run again.'
12782
13437
  ].filter(Boolean).join("\n");
12783
13438
  }
12784
13439
  /** Validate + commit a task_ops turn atomically. Returns the errors on rejection (plan untouched). */
12785
13440
  applyTaskOpsTurn(turn) {
12786
13441
  if (!this.plan) throw new Error("No active plan state");
12787
- const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners);
13442
+ const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners, this.editCatalog());
12788
13443
  if (!result.ok) return { errors: result.errors };
12789
13444
  const now = (/* @__PURE__ */ new Date()).toISOString();
12790
13445
  const content = `Tasks updated:
@@ -13051,15 +13706,22 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
13051
13706
  return plan;
13052
13707
  }
13053
13708
  /**
13054
- * Patch one task's fields. A hand-set dependency list is the one patch that
13055
- * can leave the plan unschedulable, so it goes through the same
13056
- * {@link canSetDependencies} guard the planner's pickers use rather than a
13057
- * second copy of the rule — and throws, so the surface can say why.
13709
+ * Patch one task's fields. A hand-set dependency list, or a type flip
13710
+ * between AI and MAN, are the patches that can leave a task incoherent
13711
+ * (unschedulable, or carrying fields that mean nothing for its new type),
13712
+ * so both go through the same {@link validateTaskEdit} guard the planner's
13713
+ * task-ops applier uses (as the 'direct' actor, which skips the lock rule)
13714
+ * rather than a second copy of the rules — and throws, so the surface can
13715
+ * say why. Gated on the task existing so an edit to an unknown id still
13716
+ * falls through to the no-op `store.update` below instead of throwing.
13058
13717
  */
13059
13718
  async updateTask(taskId, changes) {
13060
- if (changes.dependencies) {
13061
- const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
13062
- if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
13719
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
13720
+ const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
13721
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
13722
+ if (check.clear?.length) {
13723
+ changes = { ...changes, ...Object.fromEntries(check.clear.map((f) => [f, void 0])) };
13724
+ }
13063
13725
  }
13064
13726
  return this.editPlan(
13065
13727
  () => Boolean(this.store.update(taskId, changes)),
@@ -15157,6 +15819,11 @@ var PlannerModelMemory = class {
15157
15819
  SettingsService,
15158
15820
  StdioAgentAdapter,
15159
15821
  TASK_OPS_ENVELOPE_KEY,
15822
+ TASK_QUERY_ANSWER_OR_OPS,
15823
+ TASK_QUERY_ENVELOPE_KEY,
15824
+ TASK_QUERY_FIELDS,
15825
+ TASK_QUERY_PROTOCOL,
15826
+ TASK_QUERY_REMINDER,
15160
15827
  TRUNCATED_PLAN_REPAIR_INSTRUCTION,
15161
15828
  TaskOrchestrator,
15162
15829
  TmuxRunner,
@@ -15277,6 +15944,7 @@ var PlannerModelMemory = class {
15277
15944
  parsePartialPlan,
15278
15945
  parsePlanJson,
15279
15946
  parseTaskOpsJson,
15947
+ parseTaskQueryJson,
15280
15948
  pendingEditRulesBlock,
15281
15949
  planDirectLaunch,
15282
15950
  planShellLaunch,
@@ -15285,11 +15953,13 @@ var PlannerModelMemory = class {
15285
15953
  providerForRunner,
15286
15954
  reEmitPlanPrompt,
15287
15955
  reEmitTaskOpsPrompt,
15956
+ reEmitTaskQueryPrompt,
15288
15957
  readDaemonToken,
15289
15958
  referencePattern,
15290
15959
  removeTaskFromPlan,
15291
15960
  renderPlanMap,
15292
15961
  renderPriorOutputs,
15962
+ renderTaskQueryAnswer,
15293
15963
  renumberTasks,
15294
15964
  repairLoop,
15295
15965
  researchShellWarning,
@@ -15323,7 +15993,9 @@ var PlannerModelMemory = class {
15323
15993
  summarizeOutput,
15324
15994
  summarizeToolCall,
15325
15995
  taskOpsRejectedPrompt,
15996
+ taskQuerySignature,
15326
15997
  textHasTaskOps,
15998
+ textHasTaskQuery,
15327
15999
  tmuxSessionName,
15328
16000
  tmuxSocketName,
15329
16001
  tmuxWindowName,