@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.mjs CHANGED
@@ -13,23 +13,30 @@ import {
13
13
  resolveTaskMode,
14
14
  runnerModesFrom,
15
15
  validatePlanModification
16
- } from "./chunk-YXAIQKVE.mjs";
16
+ } from "./chunk-TUGJXD4G.mjs";
17
17
  import {
18
+ UPDATABLE_FIELDS,
18
19
  applyTaskOps,
19
20
  canMergeTasks,
20
21
  canSetDependencies,
21
22
  canSplitTask,
23
+ clampThinkingEffort,
22
24
  classifyOutcome,
25
+ coerceAssignments,
23
26
  dependencyCandidates,
24
27
  dependentsOf,
28
+ effectiveAllowlist,
29
+ filterModelsForPrompt,
25
30
  parseTaskOpsJson,
26
31
  summarizeToolCall,
27
- textHasTaskOps
28
- } from "./chunk-4KIWPW4K.mjs";
32
+ textHasTaskOps,
33
+ validateTaskEdit
34
+ } from "./chunk-YI4ZDZEE.mjs";
29
35
  import {
30
36
  PLAN_ENVELOPE_KEY,
31
37
  PlanParseError,
32
38
  TASK_OPS_ENVELOPE_KEY,
39
+ TASK_QUERY_ENVELOPE_KEY,
33
40
  addTaskToPlan,
34
41
  createEmptyPlan,
35
42
  createTask,
@@ -49,7 +56,7 @@ import {
49
56
  updateTaskInPlan,
50
57
  validateModifiedPlan,
51
58
  warningsText
52
- } from "./chunk-SRGQBGI5.mjs";
59
+ } from "./chunk-IO6PEEN6.mjs";
53
60
 
54
61
  // src/interfaces/IFileSystem.ts
55
62
  var GREP_DEFAULT_HEAD_LIMIT = 100;
@@ -3766,6 +3773,177 @@ function modesFor(scope, modes) {
3766
3773
  return scoped;
3767
3774
  }
3768
3775
 
3776
+ // src/services/TaskQuery.ts
3777
+ var TASK_QUERY_FIELDS = [
3778
+ "description",
3779
+ "prompt",
3780
+ "userSteps",
3781
+ "verdict",
3782
+ "outputSummary",
3783
+ "userStoriesCovered"
3784
+ ];
3785
+ function textHasTaskQuery(text) {
3786
+ return text.includes(`"${TASK_QUERY_ENVELOPE_KEY}"`);
3787
+ }
3788
+ function parseTaskQueryJson(text) {
3789
+ const { matches, fallback } = extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY);
3790
+ const candidates = matches.length > 0 ? [...matches].reverse() : [fallback.json];
3791
+ let lastError = null;
3792
+ for (const json of candidates) {
3793
+ try {
3794
+ return parseTaskQueryObject(json, text);
3795
+ } catch (err) {
3796
+ if (!(err instanceof PlanParseError)) throw err;
3797
+ if (!lastError) lastError = err;
3798
+ }
3799
+ }
3800
+ throw lastError ?? new PlanParseError("No JSON object found in task-query reply", text);
3801
+ }
3802
+ function parseTaskQueryObject(json, text) {
3803
+ let parsed;
3804
+ try {
3805
+ parsed = JSON.parse(escapeControlCharsInStrings(stripTrailingCommas(json)));
3806
+ } catch (err) {
3807
+ throw new PlanParseError(`Task-query JSON is invalid: ${err instanceof Error ? err.message : String(err)}`, text);
3808
+ }
3809
+ const raw = parsed.taskQuery;
3810
+ const body = Array.isArray(raw) ? { tasks: raw } : raw;
3811
+ if (typeof body !== "object" || body === null) {
3812
+ throw new PlanParseError('"taskQuery" must be an object (or an array of task references)', text);
3813
+ }
3814
+ const { tasks: rawTasks, fields: rawFields, catalog: rawCatalog } = body;
3815
+ const tasks = [];
3816
+ if (rawTasks !== void 0) {
3817
+ if (!Array.isArray(rawTasks)) throw new PlanParseError('"tasks" in a taskQuery must be an array of task references', text);
3818
+ for (const ref of rawTasks) {
3819
+ if (typeof ref !== "string" && typeof ref !== "number") {
3820
+ throw new PlanParseError(`Invalid task reference "${String(ref)}" in taskQuery`, text);
3821
+ }
3822
+ tasks.push(String(ref).trim());
3823
+ }
3824
+ }
3825
+ let fields;
3826
+ if (rawFields !== void 0) {
3827
+ if (!Array.isArray(rawFields)) throw new PlanParseError('"fields" in a taskQuery must be an array of field names', text);
3828
+ fields = [];
3829
+ for (const f of rawFields) {
3830
+ if (typeof f !== "string" || !TASK_QUERY_FIELDS.includes(f)) {
3831
+ throw new PlanParseError(`Unknown taskQuery field "${String(f)}" \u2014 choose from ${TASK_QUERY_FIELDS.join(", ")}`, text);
3832
+ }
3833
+ fields.push(f);
3834
+ }
3835
+ }
3836
+ const catalog = rawCatalog === true;
3837
+ if (tasks.length === 0 && !catalog) {
3838
+ throw new PlanParseError('A taskQuery must name at least one task or set "catalog": true', text);
3839
+ }
3840
+ return { tasks, fields, catalog };
3841
+ }
3842
+ function taskQuerySignature(query) {
3843
+ return JSON.stringify([query.tasks, query.fields ?? null, query.catalog]);
3844
+ }
3845
+ 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.";
3846
+ var TASK_QUERY_PROTOCOL = [
3847
+ "READING A TASK BEFORE YOU EDIT IT:",
3848
+ "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:",
3849
+ ` {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>", "..."],"fields":[${TASK_QUERY_FIELDS.map((f) => `"${f}"`).join(", ")}],"catalog":true}}`,
3850
+ '- "tasks": one or more task references. Ask for everything you need in ONE query \u2014 each query costs a round-trip.',
3851
+ '- "fields": optional. Omit it to get every field above.',
3852
+ '- "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.',
3853
+ "Ordewell answers immediately with the detail and changes nothing. Then reply again with your taskOps JSON, or with prose for the user.",
3854
+ "Three queries per user message; after that every answer also tells you to land the turn. Do not ask the same question twice."
3855
+ ];
3856
+ 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.`;
3857
+ function findTask(tasks, ref) {
3858
+ const byId = tasks.find((t) => t.id === ref);
3859
+ if (byId) return byId;
3860
+ const orderStr = ref.startsWith("#") ? ref.slice(1) : ref;
3861
+ if (/^\d+$/.test(orderStr)) {
3862
+ const byOrder = tasks.filter((t) => t.order === Number(orderStr));
3863
+ if (byOrder.length === 1) return byOrder[0];
3864
+ }
3865
+ return tasks.find((t) => t.title === ref);
3866
+ }
3867
+ function renderField(task, field) {
3868
+ switch (field) {
3869
+ case "description":
3870
+ return [`description: ${task.description || "(none)"}`];
3871
+ case "prompt":
3872
+ return task.prompt ? ["prompt:", task.prompt] : ["prompt: (none)"];
3873
+ case "userSteps": {
3874
+ const steps = task.userSteps ?? [];
3875
+ if (steps.length === 0) return ["userSteps: (none)"];
3876
+ return ["userSteps:", ...steps.map((s) => ` ${s.order}. [${s.completed ? "x" : " "}] ${s.instruction}`)];
3877
+ }
3878
+ case "verdict": {
3879
+ const v = task.verdict;
3880
+ if (!v) return ["verdict: (none)"];
3881
+ const checks = v.checks.map((c) => `${c.name}=${c.skipped ? "skipped" : c.passed ? "pass" : "fail"}`).join(", ");
3882
+ return [`verdict: ${v.outcome.toUpperCase()} \u2014 ${v.reason}${checks ? ` [${checks}]` : ""}`];
3883
+ }
3884
+ case "outputSummary": {
3885
+ const o = task.outputSummary;
3886
+ if (!o) return ["outputSummary: (none)"];
3887
+ return [`outputSummary: ${o.reviewReason}`, ...o.logTail ? ["log tail:", o.logTail] : []];
3888
+ }
3889
+ case "userStoriesCovered": {
3890
+ const stories = task.userStoriesCovered ?? [];
3891
+ if (stories.length === 0) return ["userStoriesCovered: (none)"];
3892
+ return ["userStoriesCovered:", ...stories.map((s) => ` - ${s}`)];
3893
+ }
3894
+ }
3895
+ }
3896
+ function renderTask(task, fields) {
3897
+ return [
3898
+ `#${task.order} id=${task.id} "${task.title}" [${task.status}] type:${task.type === "user" ? "MAN" : "AI"}`,
3899
+ ...fields.flatMap((f) => renderField(task, f)),
3900
+ ""
3901
+ ];
3902
+ }
3903
+ function renderCatalog(catalog) {
3904
+ const lines = [];
3905
+ for (const runner of catalog.runners) {
3906
+ const models = catalog.models[runner] ?? [];
3907
+ lines.push(`${runner} models:`);
3908
+ if (models.length === 0) lines.push(" (none discovered)");
3909
+ for (const m of models) {
3910
+ const variants = m.variants?.length ? ` variants: ${m.variants.map((v) => `${v.id}${v.label && v.label !== v.id ? ` (${v.label})` : ""}`).join(", ")}` : " variants: none";
3911
+ lines.push(` - ${m.modelId} \u2014 ${m.modelLabel}${variants}`);
3912
+ }
3913
+ const modes = catalog.modes[runner] ?? [];
3914
+ lines.push(`${runner} task modes:`);
3915
+ if (modes.length === 0) lines.push(" (none declared)");
3916
+ const defaultId = resolveDefaultMode(modes, catalog.autonomousDefault);
3917
+ for (const mode of modes) {
3918
+ lines.push(` - ${mode.id} \u2014 ${mode.label}: ${mode.description}${mode.id === defaultId ? " (default)" : ""}`);
3919
+ }
3920
+ }
3921
+ return lines;
3922
+ }
3923
+ function renderTaskQueryAnswer(query, tasks, catalog) {
3924
+ const fields = query.fields ?? TASK_QUERY_FIELDS;
3925
+ const blocks = [];
3926
+ if (query.tasks.length > 0) {
3927
+ const body = [];
3928
+ for (const ref of query.tasks) {
3929
+ const task = findTask(tasks, ref);
3930
+ if (!task) {
3931
+ body.push(`${ref}: no task matches this reference in the current plan.`, "");
3932
+ continue;
3933
+ }
3934
+ body.push(...renderTask(task, fields));
3935
+ }
3936
+ blocks.push(["<task_detail>", ...body, "</task_detail>"].join("\n"));
3937
+ }
3938
+ if (query.catalog) {
3939
+ blocks.push(["<runner_catalog>", ...renderCatalog(catalog), "</runner_catalog>"].join("\n"));
3940
+ }
3941
+ blocks.push(
3942
+ "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."
3943
+ );
3944
+ return blocks.join("\n\n");
3945
+ }
3946
+
3769
3947
  // src/services/PlanPrompts.ts
3770
3948
  function buildResearchToolsPrompt(subagentsEnabled = false) {
3771
3949
  const lines = [
@@ -3855,7 +4033,8 @@ function researchPhaseBlock(harnessMode) {
3855
4033
  ];
3856
4034
  const toolSpecific = harnessMode ? [
3857
4035
  "- Explore the repository with your own tools until you understand its architecture",
3858
- "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace"
4036
+ "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
4037
+ "- 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."
3859
4038
  ] : [
3860
4039
  "- Use list_dir with depth=2-3 for a tree overview",
3861
4040
  "- Use glob and grep to find relevant source files",
@@ -4020,6 +4199,11 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
4020
4199
  "TASK MODE:",
4021
4200
  modeGuide,
4022
4201
  "",
4202
+ // Shared by both variants on purpose: the read channel is a text envelope
4203
+ // exactly so a harness planner, which Ordewell cannot hand tools to, speaks
4204
+ // the same protocol as an API-backed one (ADR-0009).
4205
+ ...TASK_QUERY_PROTOCOL,
4206
+ "",
4023
4207
  context ? `PROJECT CONTEXT:
4024
4208
  ${context}
4025
4209
  ` : "",
@@ -4327,7 +4511,8 @@ function buildMergePrompt(taskIds, tasks) {
4327
4511
  "The merged task takes the union of their dependencies (excluding the merged tasks themselves) and preserves their user stories.",
4328
4512
  '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.',
4329
4513
  'Reply with ONLY a taskOps JSON object using a single "merge" op:',
4330
- ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`
4514
+ ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`,
4515
+ `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.`
4331
4516
  ].join("\n");
4332
4517
  }
4333
4518
  function buildSplitPrompt(taskId, tasks) {
@@ -4339,7 +4524,8 @@ function buildSplitPrompt(taskId, tasks) {
4339
4524
  "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.",
4340
4525
  '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).',
4341
4526
  'Reply with ONLY a taskOps JSON object using a single "split" op:',
4342
- ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`
4527
+ ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`,
4528
+ `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.`
4343
4529
  ].join("\n");
4344
4530
  }
4345
4531
 
@@ -4366,6 +4552,9 @@ function truncatedPlanReEmitPrompt(historyCompacted) {
4366
4552
  function reEmitTaskOpsPrompt(detail) {
4367
4553
  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.`;
4368
4554
  }
4555
+ function reEmitTaskQueryPrompt(detail) {
4556
+ 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.`;
4557
+ }
4369
4558
  function taskOpsRejectedPrompt(errors) {
4370
4559
  return `Those task edits were rejected:
4371
4560
  - ${errors.join("\n- ")}
@@ -4392,6 +4581,16 @@ function classifyPlannerReply(text, opts) {
4392
4581
  }
4393
4582
  }
4394
4583
  }
4584
+ if (textHasTaskQuery(text)) {
4585
+ try {
4586
+ return { kind: "task_query", query: parseTaskQueryJson(text) };
4587
+ } catch (err) {
4588
+ if (!(err instanceof PlanParseError)) throw err;
4589
+ if (extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY).matches.length > 0) {
4590
+ return { kind: "broken_task_query", error: err };
4591
+ }
4592
+ }
4593
+ }
4395
4594
  if (text.includes(`"${PLAN_ENVELOPE_KEY}"`)) {
4396
4595
  try {
4397
4596
  return { kind: "plan", tasks: parsePlanJson(text, opts.runners, opts.runnerModes, opts.autonomousDefault) };
@@ -5121,6 +5320,10 @@ ${llmOutput}
5121
5320
  switch (reply.kind) {
5122
5321
  case "task_ops":
5123
5322
  return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
5323
+ // A read is settled by the Session (it owns the plan and the catalog),
5324
+ // so it leaves this loop the same way a plan or an edit does.
5325
+ case "task_query":
5326
+ return { kind: "task_query", query: reply.query, text: turn.text, researchLog };
5124
5327
  case "plan":
5125
5328
  if (ctx.prdEnabled && !ctx.prdCaptured && !ctx.prdNudgeSent && !signal?.aborted) {
5126
5329
  ctx.prdNudgeSent = true;
@@ -5143,6 +5346,13 @@ ${llmOutput}
5143
5346
  continue;
5144
5347
  }
5145
5348
  break;
5349
+ case "broken_task_query":
5350
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
5351
+ jsonRepairAttempts++;
5352
+ pending = reEmitTaskQueryPrompt(reply.error.message);
5353
+ continue;
5354
+ }
5355
+ break;
5146
5356
  case "broken_plan":
5147
5357
  if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
5148
5358
  jsonRepairAttempts++;
@@ -7124,111 +7334,6 @@ ${output.slice(-3e3)}`);
7124
7334
  }
7125
7335
  };
7126
7336
 
7127
- // src/services/ModelAllowlistResolver.ts
7128
- function effectiveAllowlist(allowlist, runner, modelsByRunner) {
7129
- if (!allowlist || allowlist.length === 0) return void 0;
7130
- if (!modelsByRunner) return allowlist;
7131
- const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
7132
- const elsewhere = /* @__PURE__ */ new Set();
7133
- for (const [other, models] of Object.entries(modelsByRunner)) {
7134
- if (other === runner) continue;
7135
- for (const m of models ?? []) elsewhere.add(m.modelId);
7136
- }
7137
- const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
7138
- return kept.length > 0 ? kept : void 0;
7139
- }
7140
- function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
7141
- const result = {};
7142
- for (const [runner, models] of Object.entries(modelsByRunner)) {
7143
- if (!models) continue;
7144
- const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
7145
- if (!allowed) {
7146
- result[runner] = models;
7147
- continue;
7148
- }
7149
- const allowedSet = new Set(allowed);
7150
- const intersection = models.filter((m) => allowedSet.has(m.modelId));
7151
- const covered = new Set(intersection.map((m) => m.modelId));
7152
- const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
7153
- result[runner] = [...intersection, ...missing];
7154
- }
7155
- return result;
7156
- }
7157
- var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
7158
- var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
7159
- function clampThinkingEffort(effort, variants) {
7160
- if (!effort) return void 0;
7161
- if (variants.length === 0) return void 0;
7162
- const ids = new Set(variants.map((v) => v.id));
7163
- if (ids.has(effort)) return effort;
7164
- const canon = EFFORT_ALIASES[effort] ?? effort;
7165
- if (ids.has(canon)) return canon;
7166
- const idx = EFFORT_LADDER.indexOf(canon);
7167
- if (idx < 0) return void 0;
7168
- for (let d = 1; d < EFFORT_LADDER.length; d++) {
7169
- const lower = EFFORT_LADDER[idx - d];
7170
- if (lower && ids.has(lower)) return lower;
7171
- const higher = EFFORT_LADDER[idx + d];
7172
- if (higher && ids.has(higher)) return higher;
7173
- }
7174
- return void 0;
7175
- }
7176
- function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
7177
- const clampVariant = (task) => {
7178
- if (!task.assignedModel || !modelsByRunner) return task;
7179
- const model = modelsByRunner[task.assignedRunner]?.find(
7180
- (m) => m.modelId === task.assignedModel.modelId
7181
- );
7182
- if (!model) return task;
7183
- const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
7184
- return {
7185
- ...task,
7186
- assignedModel: {
7187
- ...task.assignedModel,
7188
- thinkingEffort: clamped,
7189
- availableVariants: model.variants.map((v) => v.id)
7190
- }
7191
- };
7192
- };
7193
- const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
7194
- const assignmentFor = (runner, modelId) => {
7195
- const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
7196
- return {
7197
- modelId,
7198
- modelLabel: known?.modelLabel ?? modelId,
7199
- thinkingEffort: void 0,
7200
- ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
7201
- };
7202
- };
7203
- return tasks.map((task) => {
7204
- if (task.type === "user") return task;
7205
- if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
7206
- const fallbackRunner = allowedRunners[0];
7207
- const fallbackModels = allowedFor(fallbackRunner);
7208
- const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
7209
- return clampVariant({
7210
- ...task,
7211
- assignedRunner: fallbackRunner,
7212
- assignedModel: fallbackModel
7213
- });
7214
- }
7215
- const allowlist = allowedFor(task.assignedRunner);
7216
- if (!task.assignedModel) {
7217
- if (allowlist && allowlist.length > 0) {
7218
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
7219
- }
7220
- const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
7221
- if (discovered) {
7222
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
7223
- }
7224
- return task;
7225
- }
7226
- if (!allowlist || allowlist.length === 0) return clampVariant(task);
7227
- if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
7228
- return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
7229
- });
7230
- }
7231
-
7232
7337
  // src/services/Planner.ts
7233
7338
  function plannerModesWith(req) {
7234
7339
  const modes = req.modes ?? DEFAULT_PLANNER_MODES;
@@ -8940,12 +9045,19 @@ var StdioAgentAdapter = class {
8940
9045
  /** Set for the duration of a turn. Outside one, events are buffered rather than dropped. */
8941
9046
  turnEmit = null;
8942
9047
  /**
8943
- * Events the agent produced between turns — in practice the startup warnings
8944
- * that arrive during the handshake. Dropping them hid the one diagnostic
8945
- * that explains a planner which cannot read the workspace, so they are held
8946
- * until a turn exists to show them in.
9048
+ * Events the agent produced before its first turn — in practice the startup
9049
+ * warnings that arrive during the handshake. Dropping them hid the one
9050
+ * diagnostic that explains a planner which cannot read the workspace, so they
9051
+ * are held until a turn exists to show them in.
9052
+ *
9053
+ * Only before the *first* turn. Once one has run, anything arriving outside a
9054
+ * turn is that closed turn's straggling work — a backgrounded subagent
9055
+ * finishing, a late read — and replaying it into the next turn presents it as
9056
+ * research done for the user's new message.
8947
9057
  */
8948
9058
  betweenTurns = [];
9059
+ /** Set once a turn has ended: after that, out-of-turn events are stale, not startup. */
9060
+ hadTurn = false;
8949
9061
  disposed = false;
8950
9062
  /** Resolves when the process ends, so a handshake can lose the race instead of waiting out its timeout. */
8951
9063
  processEnded;
@@ -8980,7 +9092,7 @@ var StdioAgentAdapter = class {
8980
9092
  this.process.stdout?.on("data", (chunk) => {
8981
9093
  this.stdout.push(chunk.toString(), (line) => this.handleLine(line, (event) => {
8982
9094
  if (this.turnEmit) this.turnEmit(event);
8983
- else if (!TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
9095
+ else if (!this.hadTurn && !TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
8984
9096
  this.betweenTurns.push(event);
8985
9097
  }
8986
9098
  }));
@@ -9012,6 +9124,7 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS);
9012
9124
  if (settled) return;
9013
9125
  settled = true;
9014
9126
  this.turnEmit = null;
9127
+ this.hadTurn = true;
9015
9128
  signal?.removeEventListener("abort", onAbort);
9016
9129
  this.process?.removeListener("exit", onExit);
9017
9130
  this.process?.removeListener("error", onExit);
@@ -9090,6 +9203,7 @@ ${tail}` : ""}`;
9090
9203
 
9091
9204
  // src/services/harness/ClaudeCodeAdapter.ts
9092
9205
  var DISALLOWED_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "KillShell"];
9206
+ var ASYNC_LAUNCH_MARKER = "Async agent launched successfully";
9093
9207
  function flattenContent(content) {
9094
9208
  if (typeof content === "string") return content;
9095
9209
  if (Array.isArray(content)) {
@@ -9099,6 +9213,8 @@ function flattenContent(content) {
9099
9213
  }
9100
9214
  var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9101
9215
  agentId = "claude-code";
9216
+ /** Whether this turn has already emitted reply text — see {@link handleLine}. */
9217
+ turnHasText = false;
9102
9218
  spawnSpec(opts) {
9103
9219
  const args = [
9104
9220
  "-p",
@@ -9123,6 +9239,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9123
9239
  return { command: "claude", args };
9124
9240
  }
9125
9241
  turnPayload(message) {
9242
+ this.turnHasText = false;
9126
9243
  return `${JSON.stringify({
9127
9244
  type: "user",
9128
9245
  message: { role: "user", content: [{ type: "text", text: message }] }
@@ -9133,6 +9250,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9133
9250
  const msg = StdioAgentAdapter.parse(line);
9134
9251
  if (!msg) return;
9135
9252
  if (msg.session_id) this.sessionId = msg.session_id;
9253
+ if (msg.parent_tool_use_id) return;
9136
9254
  switch (msg.type) {
9137
9255
  // The control channel: Claude asks whether a tool may run when its mode
9138
9256
  // cannot decide alone. A read-only planner answers "deny", every time —
@@ -9157,8 +9275,12 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9157
9275
  }
9158
9276
  case "assistant":
9159
9277
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
9160
- if (block.type === "text" && block.text) emit({ type: "assistant_text", text: block.text });
9161
- else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
9278
+ if (block.type === "text" && block.text) {
9279
+ emit({ type: "assistant_text", text: this.turnHasText ? `
9280
+
9281
+ ${block.text}` : block.text });
9282
+ this.turnHasText = true;
9283
+ } else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
9162
9284
  else if (block.type === "tool_use" && block.name) {
9163
9285
  emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {} });
9164
9286
  }
@@ -9167,11 +9289,15 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9167
9289
  case "user":
9168
9290
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
9169
9291
  if (block.type !== "tool_result") continue;
9292
+ const output = flattenContent(block.content);
9293
+ if (output.includes(ASYNC_LAUNCH_MARKER)) {
9294
+ emit({ type: "background_agent", id: block.tool_use_id ?? "" });
9295
+ }
9170
9296
  emit({
9171
9297
  type: "tool_result",
9172
9298
  id: block.tool_use_id ?? "",
9173
9299
  name: "",
9174
- output: flattenContent(block.content),
9300
+ output,
9175
9301
  success: block.is_error !== true
9176
9302
  });
9177
9303
  }
@@ -9591,6 +9717,8 @@ var OpenCodeAdapter = class {
9591
9717
  exited = false;
9592
9718
  disposed = false;
9593
9719
  opts = null;
9720
+ /** Whether this turn has already emitted reply text — see {@link emitPart}. */
9721
+ turnHasText = false;
9594
9722
  async start(opts) {
9595
9723
  this.opts = opts;
9596
9724
  assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
@@ -9658,6 +9786,7 @@ ${this.stderrTail.trim()}` : ""}`);
9658
9786
  return;
9659
9787
  }
9660
9788
  const seen = /* @__PURE__ */ new Set();
9789
+ this.turnHasText = false;
9661
9790
  const streamAbort = new AbortController();
9662
9791
  let connected = () => {
9663
9792
  };
@@ -9726,7 +9855,10 @@ ${this.stderrTail.trim()}` : ""}`);
9726
9855
  if (part.type === "text" && part.text) {
9727
9856
  if (seen.has(`text:${id}`)) return;
9728
9857
  seen.add(`text:${id}`);
9729
- onEvent({ type: "assistant_text", text: part.text });
9858
+ onEvent({ type: "assistant_text", text: this.turnHasText ? `
9859
+
9860
+ ${part.text}` : part.text });
9861
+ this.turnHasText = true;
9730
9862
  return;
9731
9863
  }
9732
9864
  if (part.type === "reasoning" && part.text) {
@@ -9893,6 +10025,7 @@ function normalizeAgentArgs(tool, args) {
9893
10025
 
9894
10026
  // src/services/harness/CliAgentAiService.ts
9895
10027
  var MAX_JSON_REPAIRS = 2;
10028
+ var MAX_AGENT_WAITS = 2;
9896
10029
  var LOG_MAX_CHARS = 1e4;
9897
10030
  function defaultAdapter(runner, deps) {
9898
10031
  switch (runner) {
@@ -10033,13 +10166,16 @@ var CliAgentAiService = class {
10033
10166
  let pending = message;
10034
10167
  let emptyNudgeSent = false;
10035
10168
  let jsonRepairAttempts = 0;
10169
+ let agentWaits = 0;
10170
+ const carried = [];
10171
+ const replyText = (text) => [...carried, text].filter((part) => part.trim()).join("\n\n");
10036
10172
  try {
10037
10173
  for (; ; ) {
10038
10174
  const turn = await this.runTurn(pending, onProgress, combined);
10039
10175
  researchLog.push(...turn.researchLog);
10040
10176
  if (turn.aborted || combined?.aborted) {
10041
10177
  onProgress({ type: "interrupted" });
10042
- return { kind: "message", text: turn.text, researchLog };
10178
+ return { kind: "message", text: replyText(turn.text), researchLog };
10043
10179
  }
10044
10180
  if (turn.error) {
10045
10181
  return { kind: "message", text: turn.error, researchLog };
@@ -10057,6 +10193,18 @@ var CliAgentAiService = class {
10057
10193
  researchLog
10058
10194
  };
10059
10195
  }
10196
+ if (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && !combined?.aborted) {
10197
+ agentWaits++;
10198
+ carried.push(turn.text);
10199
+ pending = [
10200
+ `You ended your turn with ${turn.backgroundAgents} subagent(s) still running in the background.`,
10201
+ "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
10202
+ "the results you promised to report would be lost.",
10203
+ "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
10204
+ "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
10205
+ ].join(" ");
10206
+ continue;
10207
+ }
10060
10208
  if (extractPrdBlock(turn.text)) conversation.prdCaptured = true;
10061
10209
  const reply = classifyPlannerReply(turn.text, {
10062
10210
  runners: conversation.runners,
@@ -10065,7 +10213,11 @@ var CliAgentAiService = class {
10065
10213
  });
10066
10214
  switch (reply.kind) {
10067
10215
  case "task_ops":
10068
- return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
10216
+ return { kind: "task_ops", ops: reply.ops, text: replyText(turn.text), researchLog };
10217
+ // The read channel is a text envelope precisely so it reaches here
10218
+ // too: a harness planner has no Ordewell tool loop to call into.
10219
+ case "task_query":
10220
+ return { kind: "task_query", query: reply.query, text: replyText(turn.text), researchLog };
10069
10221
  case "plan":
10070
10222
  if (conversation.prdEnabled && !conversation.prdCaptured && !conversation.prdNudgeSent && !combined?.aborted) {
10071
10223
  conversation.prdNudgeSent = true;
@@ -10078,7 +10230,7 @@ var CliAgentAiService = class {
10078
10230
  continue;
10079
10231
  }
10080
10232
  this.conversation = null;
10081
- return { kind: "plan", tasks: reply.tasks, text: turn.text, researchLog };
10233
+ return { kind: "plan", tasks: reply.tasks, text: replyText(turn.text), researchLog };
10082
10234
  case "broken_task_ops":
10083
10235
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10084
10236
  jsonRepairAttempts++;
@@ -10086,6 +10238,13 @@ var CliAgentAiService = class {
10086
10238
  continue;
10087
10239
  }
10088
10240
  break;
10241
+ case "broken_task_query":
10242
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10243
+ jsonRepairAttempts++;
10244
+ pending = reEmitTaskQueryPrompt(reply.error.message);
10245
+ continue;
10246
+ }
10247
+ break;
10089
10248
  case "broken_plan":
10090
10249
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10091
10250
  jsonRepairAttempts++;
@@ -10096,7 +10255,7 @@ var CliAgentAiService = class {
10096
10255
  case "prose":
10097
10256
  break;
10098
10257
  }
10099
- return { kind: "message", text: turn.text, researchLog };
10258
+ return { kind: "message", text: replyText(turn.text), researchLog };
10100
10259
  }
10101
10260
  } finally {
10102
10261
  this.activeAbort = null;
@@ -10116,6 +10275,7 @@ var CliAgentAiService = class {
10116
10275
  let text = "";
10117
10276
  let error;
10118
10277
  let stepIndex = 0;
10278
+ let backgroundAgents = 0;
10119
10279
  const truncate = (value) => value.length > LOG_MAX_CHARS ? `${value.slice(0, LOG_MAX_CHARS)}
10120
10280
  [... truncated, total ${value.length} chars]` : value;
10121
10281
  const settle = (id, rawOutput, success, outcome) => {
@@ -10155,6 +10315,9 @@ var CliAgentAiService = class {
10155
10315
  case "tool_result":
10156
10316
  settle(event.id, event.output, event.success, event.success ? "success" : "failure");
10157
10317
  return;
10318
+ case "background_agent":
10319
+ backgroundAgents++;
10320
+ return;
10158
10321
  case "permission_request": {
10159
10322
  const mapped = mapAgentTool(event.name);
10160
10323
  const args = JSON.stringify({ detail: event.detail });
@@ -10185,7 +10348,7 @@ var CliAgentAiService = class {
10185
10348
  adapter.dispose();
10186
10349
  this.adapter = null;
10187
10350
  }
10188
- return { text, researchLog, error, aborted: signal?.aborted };
10351
+ return { text, researchLog, error, aborted: signal?.aborted, backgroundAgents };
10189
10352
  }
10190
10353
  // --- Process lifecycle ---
10191
10354
  async startAdapter(opts) {
@@ -10792,6 +10955,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
10792
10955
  }
10793
10956
 
10794
10957
  // src/services/createSession.ts
10958
+ var CATALOG_MODEL_CAP = 100;
10959
+ var MAX_TASK_QUERIES = 3;
10960
+ var MAX_TASK_QUERIES_HARD = 6;
10961
+ function freshReadBudget() {
10962
+ return { answered: 0, seen: /* @__PURE__ */ new Set() };
10963
+ }
10795
10964
  var PlanEditError = class extends Error {
10796
10965
  constructor(message) {
10797
10966
  super(message);
@@ -11028,6 +11197,21 @@ var Session = class {
11028
11197
  runnerModesFor(runners) {
11029
11198
  return runnerModesFrom(this.registry, runners);
11030
11199
  }
11200
+ /**
11201
+ * The catalog a model/task-mode edit is checked against — the same
11202
+ * discovered models and manifest modes the per-turn catalog block shows the
11203
+ * planner, so a refusal here can never name something invalid that the
11204
+ * planner was never told about. `modelsCache` is read live, not filtered by
11205
+ * the allowlist here — {@link checkModelAndModeValidity} narrows by
11206
+ * allowlist itself, the same way `coerceAssignments` does.
11207
+ */
11208
+ editCatalog() {
11209
+ return {
11210
+ modelsByRunner: this.modelsCache,
11211
+ runnerModes: this.runnerModesFor(this.plan?.runners ?? []),
11212
+ perRunnerAllowlist: this.settingsFn().modelAllowlist ?? this.currentAllowlist
11213
+ };
11214
+ }
11031
11215
  get planState() {
11032
11216
  return this.plan;
11033
11217
  }
@@ -11207,10 +11391,12 @@ var Session = class {
11207
11391
  async continueConversation(userMessage, options) {
11208
11392
  if (!this.plan) throw new Error("No planning conversation to continue");
11209
11393
  const priorHistory = this.plan.conversationHistory ?? [];
11394
+ const priorResearchLog = this.plan.researchLog ?? [];
11210
11395
  const now = (/* @__PURE__ */ new Date()).toISOString();
11211
11396
  this.appendTranscript("user", userMessage, now);
11397
+ const historyAfterOwnAppend = this.plan.conversationHistory;
11212
11398
  this.plan.researchLog = [...this.plan.researchLog ?? [], { id: `up-${Date.now()}`, type: "user_prompt", content: userMessage, timestamp: now }];
11213
- const contextBlock = this.planContextBlock();
11399
+ const contextBlock = [this.catalogBlock(), this.planContextBlock()].filter(Boolean).join("\n\n");
11214
11400
  const outgoing = contextBlock ? `${contextBlock}
11215
11401
 
11216
11402
  ${userMessage}` : userMessage;
@@ -11223,6 +11409,8 @@ ${userMessage}` : userMessage;
11223
11409
  (p) => this.translateProgress(p),
11224
11410
  options?.signal
11225
11411
  ) : await this.resumeConversation(outgoing, priorHistory, options);
11412
+ const reads = freshReadBudget();
11413
+ turn = await this.drainTaskQueries(turn, reads, options);
11226
11414
  if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
11227
11415
  this.queueMessage(userMessage);
11228
11416
  this.plan.queuedMessages = this.getQueuedMessages();
@@ -11232,11 +11420,74 @@ ${userMessage}` : userMessage;
11232
11420
  researchLog: turn.researchLog
11233
11421
  };
11234
11422
  }
11235
- return await this.settleTurn(turn, options);
11423
+ return await this.settleTurn(turn, options, reads);
11424
+ } catch (err) {
11425
+ if (this.plan && this.plan.conversationHistory === historyAfterOwnAppend) {
11426
+ this.plan.conversationHistory = priorHistory;
11427
+ this.plan.researchLog = priorResearchLog;
11428
+ }
11429
+ throw err;
11236
11430
  } finally {
11237
11431
  releaseAbort();
11238
11432
  }
11239
11433
  }
11434
+ /**
11435
+ * Answer every read the planner emits until it says something else.
11436
+ *
11437
+ * The channel is a text envelope rather than a registered tool because the
11438
+ * protocol has to be identical on both planner backends (ADR-0009): Ordewell
11439
+ * owns a tool loop only in the API case, and a harness planner running as a
11440
+ * coding-agent subprocess can only be reached this way.
11441
+ *
11442
+ * Draining here — outside {@link repairLoop} — is what keeps reads free of
11443
+ * the repair budget. A planner that looks a task up and *then* fumbles its
11444
+ * ops JSON still gets its two corrective retries; charging it for the read
11445
+ * would cost it the chance to fix the edit.
11446
+ */
11447
+ async drainTaskQueries(turn, reads, options) {
11448
+ const carried = [];
11449
+ let current = turn;
11450
+ while (current.kind === "task_query") {
11451
+ carried.push(...current.researchLog);
11452
+ if (reads.answered >= MAX_TASK_QUERIES_HARD || !this.aiService.hasActiveConversation() || options?.signal?.aborted) {
11453
+ return {
11454
+ kind: "message",
11455
+ 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.",
11456
+ researchLog: carried
11457
+ };
11458
+ }
11459
+ const signature = taskQuerySignature(current.query);
11460
+ const insist = reads.seen.has(signature) || reads.answered >= MAX_TASK_QUERIES;
11461
+ reads.seen.add(signature);
11462
+ reads.answered++;
11463
+ const answer = this.taskQueryAnswer(current.query);
11464
+ current = await this.aiService.continueConversation(
11465
+ insist ? `${answer}
11466
+
11467
+ ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
11468
+ (p) => this.translateProgress(p),
11469
+ options?.signal
11470
+ );
11471
+ }
11472
+ return carried.length > 0 ? { ...current, researchLog: [...carried, ...current.researchLog] } : current;
11473
+ }
11474
+ /**
11475
+ * Render one read out of live state. Never persisted to the transcript: the
11476
+ * detail is context for the planner's next reply, and re-sending it on every
11477
+ * later turn is exactly the cost this channel exists to avoid.
11478
+ */
11479
+ taskQueryAnswer(query) {
11480
+ const runners = this.plan?.runners ?? [];
11481
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
11482
+ return renderTaskQueryAnswer(query, this.store.planTasks, {
11483
+ runners,
11484
+ // Allowlist-filtered like the always-on block: a read must not offer a
11485
+ // model the planner is forbidden to assign.
11486
+ models: filterModelsForPrompt(this.modelsCache, allowlist),
11487
+ modes: this.runnerModesFor(runners),
11488
+ autonomousDefault: this.config.autonomousMode
11489
+ });
11490
+ }
11240
11491
  /**
11241
11492
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
11242
11493
  * atomically; validation failures are fed back to the model for up to 2
@@ -11244,7 +11495,7 @@ ${userMessage}` : userMessage;
11244
11495
  * first turn (startPlanning) and every later turn route through here — one
11245
11496
  * path, not two.
11246
11497
  */
11247
- async settleTurn(turn, options) {
11498
+ async settleTurn(turn, options, reads = freshReadBudget()) {
11248
11499
  const invalidOps = (errors, researchLog) => ({
11249
11500
  turn: {
11250
11501
  kind: "message",
@@ -11256,11 +11507,15 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11256
11507
  }
11257
11508
  });
11258
11509
  const settled = await repairLoop({
11259
- first: () => turn,
11260
- resend: (corrective) => this.aiService.continueConversation(
11261
- corrective,
11262
- (p) => this.translateProgress(p),
11263
- options?.signal
11510
+ first: () => this.drainTaskQueries(turn, reads, options),
11511
+ resend: async (corrective) => this.drainTaskQueries(
11512
+ await this.aiService.continueConversation(
11513
+ corrective,
11514
+ (p) => this.translateProgress(p),
11515
+ options?.signal
11516
+ ),
11517
+ reads,
11518
+ options
11264
11519
  ),
11265
11520
  interpret: (t) => {
11266
11521
  if (t.kind !== "task_ops") return { done: { turn: t } };
@@ -11280,6 +11535,52 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11280
11535
  }
11281
11536
  return this.applyConversationTurn(settled.turn);
11282
11537
  }
11538
+ /**
11539
+ * The catalog the planner may actually draw from — model ids and task-mode
11540
+ * ids, per selected runner — emitted on EVERY turn, before any plan exists
11541
+ * or after. The system prompt shows this once at conversation start; a long
11542
+ * grill-me interview or PRD negotiation outlives that single showing and the
11543
+ * planner starts misquoting it, so this re-states it per turn instead.
11544
+ *
11545
+ * Built from the allowlist-filtered view (`filterModelsForPrompt`), the same
11546
+ * one the system prompt used — a restricted allowlist stays a hard bound on
11547
+ * every turn, not just the first. `this.modelsCache` itself is never
11548
+ * filtered: `coerceAssignments` needs the full discovered catalog alongside
11549
+ * the allowlist to resolve labels and clamp effort to real variants.
11550
+ *
11551
+ * Reads `this.plan.runners` live, so a runner admitted mid-session by a
11552
+ * retarget (`admitRunner`) is filtered and shown like every other — there is
11553
+ * no separate "originally selected" list to fall stale.
11554
+ */
11555
+ catalogBlock() {
11556
+ if (!this.plan) return null;
11557
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
11558
+ const filteredModels = filterModelsForPrompt(this.modelsCache, allowlist);
11559
+ const runnerModes = this.runnerModesFor(this.plan.runners);
11560
+ const autonomousDefault = this.config.autonomousMode;
11561
+ const modelLines = this.plan.runners.map((runner) => {
11562
+ const models = filteredModels[runner] ?? [];
11563
+ const capped = models.slice(0, CATALOG_MODEL_CAP);
11564
+ const remainder = models.length - capped.length;
11565
+ const ids = capped.map((m) => m.modelId).join(", ") || "(no models discovered)";
11566
+ return `${runner}: ${ids}${remainder > 0 ? ` \u2026 +${remainder} more not shown` : ""}`;
11567
+ });
11568
+ const modeLines = this.plan.runners.map((runner) => {
11569
+ const modes = runnerModes[runner] ?? [];
11570
+ if (modes.length === 0) return `${runner}: (no modes declared)`;
11571
+ const defaultId = resolveDefaultMode(modes, autonomousDefault);
11572
+ const ids = modes.map((m) => `${m.id}${m.id === defaultId ? " (default)" : ""}`).join(", ");
11573
+ return `${runner}: ${ids}`;
11574
+ });
11575
+ return [
11576
+ "<available_models>",
11577
+ ...modelLines,
11578
+ "</available_models>",
11579
+ "<available_task_modes>",
11580
+ ...modeLines,
11581
+ "</available_task_modes>"
11582
+ ].join("\n");
11583
+ }
11283
11584
  /**
11284
11585
  * The "you are here" block for post-plan chat: current tasks with stable
11285
11586
  * references, plus the task-ops protocol. Injected per turn (never
@@ -11289,44 +11590,48 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11289
11590
  planContextBlock() {
11290
11591
  if (!this.plan || this.store.planTasks.length === 0) return null;
11291
11592
  const orderOf = new Map(this.store.planTasks.map((t) => [t.id, `#${t.order}`]));
11292
- const lines = this.store.planTasks.map(
11293
- (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(", ")}]`
11294
- );
11593
+ const lines = this.store.planTasks.map((t) => {
11594
+ const isMan = t.type === "user";
11595
+ const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
11596
+ 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(", ")}]`;
11597
+ });
11295
11598
  const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
11296
11599
  const execNote = this.hasLiveWork ? `
11297
11600
  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.` : "";
11298
- const modelLines = [];
11299
- for (const runner of this.plan.runners) {
11300
- const models = this.modelsCache[runner] ?? [];
11301
- const ids = models.map((m) => m.modelId).join(", ");
11302
- modelLines.push(`${runner}: ${ids || "(no models discovered)"}`);
11303
- }
11304
- const modelBlock = modelLines.length > 0 ? ["<available_models>", ...modelLines, "</available_models>"].join("\n") : "";
11601
+ const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
11305
11602
  return [
11306
11603
  "<current_plan>",
11307
11604
  ...lines,
11308
11605
  "</current_plan>",
11309
- modelBlock,
11310
- "The block above is the CURRENT task plan. Choose how to respond:",
11606
+ "The block above is the CURRENT task plan \u2014 short fields only. Choose how to respond:",
11311
11607
  "- To answer a question or discuss, reply in plain prose (no JSON).",
11608
+ // The reminder text is owned by TaskQuery beside the parser, so the
11609
+ // protocol the planner reads and the one Ordewell accepts stay one thing.
11610
+ TASK_QUERY_REMINDER,
11312
11611
  "- To modify specific tasks, reply with ONLY this JSON object:",
11313
11612
  ' {"taskOps": [',
11314
- ' {"op":"update","taskId":"<id or #order>","changes":{"title"?,"description"?,"prompt"?,"dependencies"?:["<id or #order>"],"assignedRunner"?,"assignedModel"?,"taskMode"?}},',
11315
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?}},',
11613
+ ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
11614
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
11316
11615
  ' {"op":"remove","taskId":"<id or #order>"},',
11317
- ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]},',
11318
- ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}},',
11319
- ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...]}',
11616
+ ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
11617
+ ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
11618
+ ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...],"handle"?:"<name>"},',
11619
+ ` {"op":"rearm","taskId":"<id or #order>","changes"?:{${updateChanges}}}`,
11320
11620
  " ]}",
11321
11621
  '- For sweeping changes, you may instead emit a full {"tasks":[...]} plan JSON.',
11622
+ '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.',
11623
+ '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.',
11322
11624
  'When creating or changing tasks, set "assignedRunner" and "assignedModel" ({"modelId","modelLabel","thinkingEffort"?}) using only the runners and models listed in <available_models>.',
11323
- "Keep dependencies consistent: no cycles, no references to removed tasks, dependencies must come before dependents, and never touch running or completed tasks." + execNote
11625
+ 'Keep dependencies consistent: no cycles, no references to removed tasks, and never touch running or completed tasks \u2014 "rearm" is the one exception, below.',
11626
+ '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,
11627
+ '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.',
11628
+ '"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.'
11324
11629
  ].filter(Boolean).join("\n");
11325
11630
  }
11326
11631
  /** Validate + commit a task_ops turn atomically. Returns the errors on rejection (plan untouched). */
11327
11632
  applyTaskOpsTurn(turn) {
11328
11633
  if (!this.plan) throw new Error("No active plan state");
11329
- const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners);
11634
+ const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners, this.editCatalog());
11330
11635
  if (!result.ok) return { errors: result.errors };
11331
11636
  const now = (/* @__PURE__ */ new Date()).toISOString();
11332
11637
  const content = `Tasks updated:
@@ -11593,15 +11898,22 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
11593
11898
  return plan;
11594
11899
  }
11595
11900
  /**
11596
- * Patch one task's fields. A hand-set dependency list is the one patch that
11597
- * can leave the plan unschedulable, so it goes through the same
11598
- * {@link canSetDependencies} guard the planner's pickers use rather than a
11599
- * second copy of the rule — and throws, so the surface can say why.
11901
+ * Patch one task's fields. A hand-set dependency list, or a type flip
11902
+ * between AI and MAN, are the patches that can leave a task incoherent
11903
+ * (unschedulable, or carrying fields that mean nothing for its new type),
11904
+ * so both go through the same {@link validateTaskEdit} guard the planner's
11905
+ * task-ops applier uses (as the 'direct' actor, which skips the lock rule)
11906
+ * rather than a second copy of the rules — and throws, so the surface can
11907
+ * say why. Gated on the task existing so an edit to an unknown id still
11908
+ * falls through to the no-op `store.update` below instead of throwing.
11600
11909
  */
11601
11910
  async updateTask(taskId, changes) {
11602
- if (changes.dependencies) {
11603
- const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
11604
- if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
11911
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
11912
+ const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
11913
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
11914
+ if (check.clear?.length) {
11915
+ changes = { ...changes, ...Object.fromEntries(check.clear.map((f) => [f, void 0])) };
11916
+ }
11605
11917
  }
11606
11918
  return this.editPlan(
11607
11919
  () => Boolean(this.store.update(taskId, changes)),
@@ -13636,6 +13948,11 @@ export {
13636
13948
  SettingsService,
13637
13949
  StdioAgentAdapter,
13638
13950
  TASK_OPS_ENVELOPE_KEY,
13951
+ TASK_QUERY_ANSWER_OR_OPS,
13952
+ TASK_QUERY_ENVELOPE_KEY,
13953
+ TASK_QUERY_FIELDS,
13954
+ TASK_QUERY_PROTOCOL,
13955
+ TASK_QUERY_REMINDER,
13639
13956
  TRUNCATED_PLAN_REPAIR_INSTRUCTION,
13640
13957
  TaskOrchestrator,
13641
13958
  TmuxRunner,
@@ -13756,6 +14073,7 @@ export {
13756
14073
  parsePartialPlan,
13757
14074
  parsePlanJson,
13758
14075
  parseTaskOpsJson,
14076
+ parseTaskQueryJson,
13759
14077
  pendingEditRulesBlock,
13760
14078
  planDirectLaunch,
13761
14079
  planShellLaunch,
@@ -13764,11 +14082,13 @@ export {
13764
14082
  providerForRunner,
13765
14083
  reEmitPlanPrompt,
13766
14084
  reEmitTaskOpsPrompt,
14085
+ reEmitTaskQueryPrompt,
13767
14086
  readDaemonToken,
13768
14087
  referencePattern,
13769
14088
  removeTaskFromPlan,
13770
14089
  renderPlanMap,
13771
14090
  renderPriorOutputs,
14091
+ renderTaskQueryAnswer,
13772
14092
  renumberTasks,
13773
14093
  repairLoop,
13774
14094
  researchShellWarning,
@@ -13802,7 +14122,9 @@ export {
13802
14122
  summarizeOutput,
13803
14123
  summarizeToolCall,
13804
14124
  taskOpsRejectedPrompt,
14125
+ taskQuerySignature,
13805
14126
  textHasTaskOps,
14127
+ textHasTaskQuery,
13806
14128
  tmuxSessionName,
13807
14129
  tmuxSocketName,
13808
14130
  tmuxWindowName,