@ordewell/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/{IFileSystem-DpaaFdit.d.ts → IFileSystem-BCJBHFwB.d.ts} +3 -1
  2. package/dist/{IFileSystem-_m7HdoxJ.d.mts → IFileSystem-Bj6fy1cG.d.mts} +3 -1
  3. package/dist/{IRunner-BiQio8gJ.d.ts → IRunner-C7_SSpzG.d.ts} +614 -77
  4. package/dist/{IRunner-CxiymkP_.d.mts → IRunner-hWAJC4hg.d.mts} +614 -77
  5. package/dist/ModeResolver-m1d8Cb-j.d.mts +35 -0
  6. package/dist/ModeResolver-u5ZhRC8x.d.ts +35 -0
  7. package/dist/{Task-1Q19y7sZ.d.mts → Task-CxwnCJLi.d.mts} +346 -127
  8. package/dist/{Task-1Q19y7sZ.d.ts → Task-CxwnCJLi.d.ts} +346 -127
  9. package/dist/{chunk-UUU46PVQ.mjs → chunk-2IIRSBEQ.mjs} +109 -142
  10. package/dist/chunk-2IIRSBEQ.mjs.map +1 -0
  11. package/dist/{chunk-QUARDKVT.mjs → chunk-CH5IGWFS.mjs} +546 -967
  12. package/dist/chunk-CH5IGWFS.mjs.map +1 -0
  13. package/dist/chunk-DJAPDO6E.mjs +1358 -0
  14. package/dist/chunk-DJAPDO6E.mjs.map +1 -0
  15. package/dist/chunk-DNFT4SQG.mjs +97 -0
  16. package/dist/chunk-DNFT4SQG.mjs.map +1 -0
  17. package/dist/index.d.mts +3204 -3926
  18. package/dist/index.d.ts +3204 -3926
  19. package/dist/index.js +22283 -16472
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +20220 -14690
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/parsing.d.mts +55 -3
  24. package/dist/parsing.d.ts +55 -3
  25. package/dist/parsing.js +311 -124
  26. package/dist/parsing.js.map +1 -1
  27. package/dist/parsing.mjs +86 -6
  28. package/dist/parsing.mjs.map +1 -1
  29. package/dist/plan-utils-B6yJdpgi.d.ts +2902 -0
  30. package/dist/plan-utils-CKXdD1AD.d.mts +2902 -0
  31. package/dist/plan-utils.d.mts +4 -5
  32. package/dist/plan-utils.d.ts +4 -5
  33. package/dist/plan-utils.js +471 -86
  34. package/dist/plan-utils.js.map +1 -1
  35. package/dist/plan-utils.mjs +68 -19
  36. package/dist/testing.d.mts +22 -4
  37. package/dist/testing.d.ts +22 -4
  38. package/dist/testing.js +46 -9
  39. package/dist/testing.js.map +1 -1
  40. package/dist/testing.mjs +47 -11
  41. package/dist/testing.mjs.map +1 -1
  42. package/package.json +2 -10
  43. package/skills/tdd/SKILL.md +0 -1
  44. package/dist/ModeResolver-DE-o2lRF.d.mts +0 -48
  45. package/dist/ModeResolver-EWlpmVAy.d.ts +0 -48
  46. package/dist/chunk-2M4X3VB7.mjs +0 -432
  47. package/dist/chunk-2M4X3VB7.mjs.map +0 -1
  48. package/dist/chunk-H5O74L77.mjs +0 -185
  49. package/dist/chunk-H5O74L77.mjs.map +0 -1
  50. package/dist/chunk-QUARDKVT.mjs.map +0 -1
  51. package/dist/chunk-UUU46PVQ.mjs.map +0 -1
  52. package/dist/chunk-V3JHS7AX.mjs +0 -27
  53. package/dist/chunk-V3JHS7AX.mjs.map +0 -1
  54. package/dist/order-labels.d.mts +0 -30
  55. package/dist/order-labels.d.ts +0 -30
  56. package/dist/order-labels.js +0 -54
  57. package/dist/order-labels.js.map +0 -1
  58. package/dist/order-labels.mjs +0 -13
  59. package/dist/order-labels.mjs.map +0 -1
  60. package/dist/parsing-CZdp2p_n.d.ts +0 -154
  61. package/dist/parsing-GgUJoYKs.d.mts +0 -154
  62. package/dist/plan-utils-6rUr7ZOx.d.ts +0 -1222
  63. package/dist/plan-utils-P3PIEwNa.d.mts +0 -1222
@@ -1,3 +1,55 @@
1
- export { a as PartialPlanTask, b as PlanParseError, l as extractJsonObject, q as parsePartialPlan, r as parsePlanJson } from './parsing-GgUJoYKs.mjs';
2
- import './Task-1Q19y7sZ.mjs';
3
- import './ModeResolver-DE-o2lRF.mjs';
1
+ import { $ as RunnerId, ac as Task } from './Task-CxwnCJLi.mjs';
2
+ import { R as RunnerModeInfo } from './ModeResolver-m1d8Cb-j.mjs';
3
+
4
+ declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
5
+
6
+ /** Thrown when an LLM plan response cannot be parsed into a valid plan. Carries the raw text so callers can log it or retry. */
7
+ declare class PlanParseError extends Error {
8
+ readonly raw: string;
9
+ /**
10
+ * True when the reply was cut off mid-object (output-token limit) rather
11
+ * than malformed — the two need different repairs: a truncated emission will
12
+ * be cut at the same point again unless the retry frees context or asks for
13
+ * terser output.
14
+ */
15
+ readonly truncated: boolean;
16
+ /**
17
+ * True when the JSON parsed fine and a *shape* rule rejected it. The generic
18
+ * "could not be parsed as JSON" corrective is a lie for these, so the model
19
+ * re-sends the same object; a semantic rejection must be repaired by naming
20
+ * the rule it broke.
21
+ */
22
+ readonly semantic: boolean;
23
+ constructor(message: string, raw: string, opts?: {
24
+ truncated?: boolean;
25
+ semantic?: boolean;
26
+ });
27
+ }
28
+ /**
29
+ * Pull the JSON object out of an LLM response. Strips markdown code fences and
30
+ * `<think>` blocks, prefers the object containing a `tasks` key, and tolerates a
31
+ * prose preamble and trailing prose. Returns the fence-stripped text unchanged
32
+ * if no `{` is present so the caller's JSON.parse produces a clear failure.
33
+ */
34
+ declare function extractJsonObject(raw: string): string;
35
+
36
+ /** A task as seen in a still-streaming plan: its title (possibly partial) and whether its object has closed. */
37
+ interface PartialPlanTask {
38
+ title: string;
39
+ status: 'streaming' | 'complete';
40
+ /** Assigned model label, once it has streamed in — for the live row's chip. */
41
+ model?: string;
42
+ /** Set once `"readOnly": true` has streamed in — for the live row's chip. */
43
+ readOnly?: true;
44
+ }
45
+ /**
46
+ * Parse a partial (still-streaming) plan-JSON string into an ordered list of task
47
+ * rows for the live progress view. Pure and lenient: it walks the characters of the
48
+ * `tasks` array, emitting one row per task object once it has opened, with the title
49
+ * read incrementally and the status flipping to `complete` when the object's brace
50
+ * closes. The currently-streaming object stays `streaming` even with a partial title.
51
+ * The React view is a thin renderer over this — it never parses JSON itself.
52
+ */
53
+ declare function parsePartialPlan(partial: string): PartialPlanTask[];
54
+
55
+ export { type PartialPlanTask, PlanParseError, extractJsonObject, parsePartialPlan, parsePlanJson };
package/dist/parsing.d.ts CHANGED
@@ -1,3 +1,55 @@
1
- export { a as PartialPlanTask, b as PlanParseError, l as extractJsonObject, q as parsePartialPlan, r as parsePlanJson } from './parsing-CZdp2p_n.js';
2
- import './Task-1Q19y7sZ.js';
3
- import './ModeResolver-EWlpmVAy.js';
1
+ import { $ as RunnerId, ac as Task } from './Task-CxwnCJLi.js';
2
+ import { R as RunnerModeInfo } from './ModeResolver-u5ZhRC8x.js';
3
+
4
+ declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
5
+
6
+ /** Thrown when an LLM plan response cannot be parsed into a valid plan. Carries the raw text so callers can log it or retry. */
7
+ declare class PlanParseError extends Error {
8
+ readonly raw: string;
9
+ /**
10
+ * True when the reply was cut off mid-object (output-token limit) rather
11
+ * than malformed — the two need different repairs: a truncated emission will
12
+ * be cut at the same point again unless the retry frees context or asks for
13
+ * terser output.
14
+ */
15
+ readonly truncated: boolean;
16
+ /**
17
+ * True when the JSON parsed fine and a *shape* rule rejected it. The generic
18
+ * "could not be parsed as JSON" corrective is a lie for these, so the model
19
+ * re-sends the same object; a semantic rejection must be repaired by naming
20
+ * the rule it broke.
21
+ */
22
+ readonly semantic: boolean;
23
+ constructor(message: string, raw: string, opts?: {
24
+ truncated?: boolean;
25
+ semantic?: boolean;
26
+ });
27
+ }
28
+ /**
29
+ * Pull the JSON object out of an LLM response. Strips markdown code fences and
30
+ * `<think>` blocks, prefers the object containing a `tasks` key, and tolerates a
31
+ * prose preamble and trailing prose. Returns the fence-stripped text unchanged
32
+ * if no `{` is present so the caller's JSON.parse produces a clear failure.
33
+ */
34
+ declare function extractJsonObject(raw: string): string;
35
+
36
+ /** A task as seen in a still-streaming plan: its title (possibly partial) and whether its object has closed. */
37
+ interface PartialPlanTask {
38
+ title: string;
39
+ status: 'streaming' | 'complete';
40
+ /** Assigned model label, once it has streamed in — for the live row's chip. */
41
+ model?: string;
42
+ /** Set once `"readOnly": true` has streamed in — for the live row's chip. */
43
+ readOnly?: true;
44
+ }
45
+ /**
46
+ * Parse a partial (still-streaming) plan-JSON string into an ordered list of task
47
+ * rows for the live progress view. Pure and lenient: it walks the characters of the
48
+ * `tasks` array, emitting one row per task object once it has opened, with the title
49
+ * read incrementally and the status flipping to `complete` when the object's brace
50
+ * closes. The currently-streaming object stays `streaming` even with a partial title.
51
+ * The React view is a thin renderer over this — it never parses JSON itself.
52
+ */
53
+ declare function parsePartialPlan(partial: string): PartialPlanTask[];
54
+
55
+ export { type PartialPlanTask, PlanParseError, extractJsonObject, parsePartialPlan, parsePlanJson };
package/dist/parsing.js CHANGED
@@ -34,6 +34,11 @@ var import_uuid = require("uuid");
34
34
  var SKILL_NAME_PATTERN = "[a-z0-9][a-z0-9_-]*";
35
35
  var SKILL_TOKEN = new RegExp(`(^|\\s)\\/(${SKILL_NAME_PATTERN})([,.!?;:]*)(?=\\s|$)`, "gi");
36
36
 
37
+ // src/services/TaskRefs.ts
38
+ function taskOrderLabel(task, parent) {
39
+ return parent ? `${parent.label ?? parent.order}.${task.order}` : String(task.order);
40
+ }
41
+
37
42
  // src/models/Task.ts
38
43
  function createTask(overrides = {}) {
39
44
  const skills = skillNames(overrides.skills);
@@ -50,15 +55,16 @@ function createTask(overrides = {}) {
50
55
  subtasks: overrides.subtasks ?? [],
51
56
  verdict: overrides.verdict,
52
57
  outputSummary: overrides.outputSummary,
58
+ ...overrides.attemptUsage ? { attemptUsage: structuredClone(overrides.attemptUsage) } : {},
53
59
  assignedModel: overrides.assignedModel,
54
60
  assignedRunner: overrides.assignedRunner ?? "claude-code",
55
- thinkingEffort: overrides.thinkingEffort,
56
61
  taskMode: overrides.taskMode ?? "build",
57
62
  autonomy: overrides.autonomy,
58
63
  sliceType: overrides.sliceType,
59
64
  userStoriesCovered: overrides.userStoriesCovered,
60
65
  ...opsFlag(overrides.ops, overrides.type) ? { ops: true } : {},
61
- ...skills ? { skills } : {}
66
+ ...skills ? { skills } : {},
67
+ ...overrides.addedBy === "supervisor" ? { addedBy: "supervisor" } : {}
62
68
  };
63
69
  }
64
70
  var SKILL_NAME = new RegExp(`^${SKILL_NAME_PATTERN}$`);
@@ -70,9 +76,23 @@ function skillNames(value) {
70
76
  const names = [...new Set(value.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(isSkillName))];
71
77
  return names.length > 0 ? names : void 0;
72
78
  }
79
+ function inheritedSkills(chosen, inherited) {
80
+ if (Array.isArray(chosen) && chosen.length === 0) return void 0;
81
+ return skillNames(chosen) ?? skillNames(inherited);
82
+ }
73
83
  function opsFlag(value, type = "ai") {
74
84
  return value === true && type === "ai" ? true : void 0;
75
85
  }
86
+ function flattenTasksWithParents(tasks) {
87
+ const rows = [];
88
+ const walk = (t, parent, parentLabel) => {
89
+ const label = taskOrderLabel(t, parent ? { order: parent.order, label: parentLabel } : void 0);
90
+ rows.push({ task: t, parent, label, ref: `#${label}` });
91
+ for (const sub of t.subtasks ?? []) walk(sub, t, label);
92
+ };
93
+ for (const t of tasks) walk(t, null);
94
+ return rows;
95
+ }
76
96
 
77
97
  // src/services/JsonExtractor.ts
78
98
  var PLAN_ENVELOPE_KEY = "tasks";
@@ -103,12 +123,12 @@ var PlanParseError = class extends Error {
103
123
  function stripModelNoise(raw) {
104
124
  return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
105
125
  }
106
- function balancedObjectAt(text, start) {
126
+ function balancedObjectAt(text2, start) {
107
127
  let depth = 0;
108
128
  let inString = false;
109
129
  let escaped = false;
110
- for (let i = start; i < text.length; i++) {
111
- const ch = text[i];
130
+ for (let i = start; i < text2.length; i++) {
131
+ const ch = text2[i];
112
132
  if (inString) {
113
133
  if (escaped) escaped = false;
114
134
  else if (ch === "\\") escaped = true;
@@ -119,10 +139,10 @@ function balancedObjectAt(text, start) {
119
139
  else if (ch === "{") depth++;
120
140
  else if (ch === "}") {
121
141
  depth--;
122
- if (depth === 0) return { json: text.slice(start, i + 1), balanced: true };
142
+ if (depth === 0) return { json: text2.slice(start, i + 1), balanced: true };
123
143
  }
124
144
  }
125
- return { json: text.slice(start), balanced: false };
145
+ return { json: text2.slice(start), balanced: false };
126
146
  }
127
147
  function extractObjectsWithKey(raw, key) {
128
148
  const stripped = stripModelNoise(raw);
@@ -216,6 +236,9 @@ function escapeControlCharsInStrings(json) {
216
236
  return out;
217
237
  }
218
238
 
239
+ // src/services/PlannedTask.ts
240
+ var import_uuid2 = require("uuid");
241
+
219
242
  // src/services/ModeResolver.ts
220
243
  function resolveDefaultMode(modes, autonomousDefault = true) {
221
244
  if (!modes || modes.length === 0) return void 0;
@@ -223,32 +246,241 @@ function resolveDefaultMode(modes, autonomousDefault = true) {
223
246
  const tagged = modes.find((m) => m[tag]);
224
247
  if (tagged) return tagged.id;
225
248
  for (let i = modes.length - 1; i >= 0; i--) {
226
- if (modes[i].id !== "plan") return modes[i].id;
249
+ if (!modes[i].readOnly && modes[i].id !== "plan") return modes[i].id;
227
250
  }
228
251
  return void 0;
229
252
  }
230
- function modeConflictsWithToggle(mode, autonomousDefault) {
231
- if (autonomousDefault) return !!mode.safe && !mode.autonomous;
232
- return !!mode.autonomous && !mode.safe;
233
- }
234
- function resolveTaskMode(emittedMode, runnerId, runnerModes, autonomousDefault = true) {
235
- const rawMode = emittedMode || "build";
236
- if (!runnerModes) return rawMode === "plan" ? "plan" : "build";
237
- const modes = runnerModes[runnerId];
238
- if (!modes || modes.length === 0) return rawMode;
239
- if (!emittedMode) return resolveDefaultMode(modes, autonomousDefault) ?? "build";
240
- const validIds = new Set(modes.map((m) => m.id));
241
- if (rawMode === "build" || validIds.has(rawMode)) {
242
- if (rawMode === "plan" || rawMode === "build") return rawMode;
243
- const modeObj = modes.find((m) => m.id === rawMode);
244
- if (modeObj && modeConflictsWithToggle(modeObj, autonomousDefault)) {
245
- const fallback2 = resolveDefaultMode(modes, autonomousDefault);
246
- return fallback2 ?? "build";
253
+ function taggedReadOnlyMode(modes) {
254
+ return modes?.find((m) => m.readOnly)?.id;
255
+ }
256
+ function readOnlyMode(modes) {
257
+ const mode = modes?.find((m) => m.readOnly);
258
+ return mode && !mode.unavailable ? mode.id : void 0;
259
+ }
260
+ function unavailableModeReason(modes, id) {
261
+ return id === void 0 ? void 0 : modes?.find((m) => m.id === id)?.unavailable;
262
+ }
263
+ function unavailableModeError(runner, id, reason) {
264
+ return `Mode "${id}" of runner "${runner}" is unavailable on this host: ${reason}`;
265
+ }
266
+ function plannedMode(runner, runnerModes, { readOnly, current }, autonomousDefault = true) {
267
+ if (!runnerModes) return { ok: true, mode: void 0 };
268
+ const modes = runnerModes[runner] ?? [];
269
+ const readOnlyId = readOnlyMode(modes);
270
+ if (readOnly === true) {
271
+ if (readOnlyId) return { ok: true, mode: readOnlyId };
272
+ const able = readOnlyRunners(runnerModes);
273
+ const reason2 = unavailableModeReason(modes, taggedReadOnlyMode(modes));
274
+ return {
275
+ ok: false,
276
+ error: `Runner "${runner}" has no read-only mode${reason2 ? " available on this host" : ""} \u2014 assign a read-only task to ${able.length > 0 ? `one of: ${able.join(", ")}` : "no runner: none of the enabled runners has one"}, or drop "readOnly"${reason2 ? `. ${reason2}` : ""}`
277
+ };
278
+ }
279
+ const keep = current !== void 0 && modes.some((m) => m.id === current) && !(readOnly === false && current === taggedReadOnlyMode(modes));
280
+ const mode = keep ? current : resolveDefaultMode(modes, autonomousDefault);
281
+ const reason = unavailableModeReason(modes, mode);
282
+ if (mode !== void 0 && reason) return { ok: false, error: unavailableModeError(runner, mode, reason) };
283
+ return { ok: true, mode };
284
+ }
285
+ function readOnlyRunners(runnerModes) {
286
+ return Object.entries(runnerModes).filter(([, ms]) => readOnlyMode(ms)).map(([id]) => id);
287
+ }
288
+
289
+ // src/services/ModelAllowlistResolver.ts
290
+ function effectiveAllowlist(allowlist, runner, modelsByRunner) {
291
+ if (!allowlist || allowlist.length === 0) return void 0;
292
+ if (!modelsByRunner) return allowlist;
293
+ const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
294
+ const elsewhere = /* @__PURE__ */ new Set();
295
+ for (const [other, models] of Object.entries(modelsByRunner)) {
296
+ if (other === runner) continue;
297
+ for (const m of models ?? []) elsewhere.add(m.modelId);
298
+ }
299
+ const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
300
+ return kept.length > 0 ? kept : void 0;
301
+ }
302
+
303
+ // src/services/TaskEditValidator.ts
304
+ function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
305
+ if (!catalog) return { ok: true };
306
+ if (assignedModel) {
307
+ const models = catalog.modelsByRunner[runner];
308
+ if (models) {
309
+ const modelId = assignedModel.modelId;
310
+ const allowed = effectiveAllowlist(catalog.perRunnerAllowlist?.[runner], runner, catalog.modelsByRunner);
311
+ if (!models.some((m) => m.modelId === modelId) && !allowed?.includes(modelId)) {
312
+ return { ok: false, error: `Runner "${runner}" does not offer model "${modelId}"` };
313
+ }
314
+ if (allowed && !allowed.includes(modelId)) {
315
+ return { ok: false, error: `Model "${modelId}" is excluded by the allowlist for runner "${runner}"` };
316
+ }
247
317
  }
248
- return rawMode;
249
318
  }
250
- const fallback = resolveDefaultMode(modes, autonomousDefault);
251
- return fallback ?? "build";
319
+ if (taskMode !== void 0) {
320
+ const modes = catalog.runnerModes[runner];
321
+ if (modes && modes.length > 0 && !modes.some((m) => m.id === taskMode)) {
322
+ return { ok: false, error: `Runner "${runner}" has no task mode "${taskMode}" \u2014 valid modes: ${modes.map((m) => m.id).join(", ")}` };
323
+ }
324
+ const unavailable = unavailableModeReason(modes, taskMode);
325
+ if (unavailable) return { ok: false, error: `Mode "${taskMode}" of runner "${runner}" is unavailable on this host: ${unavailable}` };
326
+ }
327
+ return { ok: true };
328
+ }
329
+
330
+ // src/services/PlannedTask.ts
331
+ var MODE_NOT_THE_PLANNERS = `"taskMode" is the user's to choose \u2014 set "readOnly": true on a task that must not change files, and leave it out otherwise`;
332
+ var MANUAL_NOT_READ_ONLY = '"readOnly" is for AI tasks \u2014 a manual task runs in no mode';
333
+ var OPS_NOT_READ_ONLY = `"readOnly" is not for an ops task \u2014 it runs commands, in the session's mode, and a read-only mode refuses them`;
334
+ function isRecord(value) {
335
+ return typeof value === "object" && value !== null && !Array.isArray(value);
336
+ }
337
+ function text(value) {
338
+ return typeof value === "string" || typeof value === "number" ? String(value) : void 0;
339
+ }
340
+ function opsPlacementRefusal(type, isSubtask) {
341
+ if (type !== "ai") return "is a manual task \u2014 only an AI task can be an ops task";
342
+ if (isSubtask) return "is a subtask, which runs with its parent \u2014 only a top-level task can be an ops task";
343
+ return null;
344
+ }
345
+ function taskWhere(title, parentTitle) {
346
+ return parentTitle !== void 0 ? `subtask "${title}" of "${parentTitle}"` : `Task "${title}"`;
347
+ }
348
+ var skillsAsked = /* @__PURE__ */ new WeakMap();
349
+ function modelOf(raw, from) {
350
+ const effort = text(raw.thinkingEffort);
351
+ if (isRecord(raw.assignedModel)) {
352
+ const m = raw.assignedModel;
353
+ return {
354
+ modelId: text(m.modelId) ?? "",
355
+ modelLabel: text(m.modelLabel) ?? "",
356
+ thinkingEffort: text(m.thinkingEffort) ?? effort
357
+ };
358
+ }
359
+ return from && effort !== void 0 ? { ...from, thinkingEffort: effort } : from;
360
+ }
361
+ function plannedTask(spec, catalog, at = {}) {
362
+ const raw = isRecord(spec) ? spec : {};
363
+ const { parent, from, freshIds } = at;
364
+ const base = from?.fields ?? {};
365
+ const errors = [];
366
+ const ignored = [];
367
+ const renamed = /* @__PURE__ */ new Map();
368
+ const title = text(raw.title) ?? "";
369
+ const where = taskWhere(title, parent?.title);
370
+ const plannerId = text(raw.id);
371
+ const id = freshIds || !plannerId ? (0, import_uuid2.v4)() : plannerId;
372
+ if (freshIds && plannerId) renamed.set(plannerId, id);
373
+ const type = raw.type === "user" || raw.type === "ai" ? raw.type : base.type ?? "ai";
374
+ const ai = type === "ai";
375
+ const assignedRunner = text(raw.assignedRunner) ?? base.assignedRunner ?? catalog.runners[0] ?? "claude-code";
376
+ const description = text(raw.description) ?? base.description ?? title;
377
+ if (raw.ops === true && !ai) errors.push({ taskId: id, field: "ops", message: `${where} ${opsPlacementRefusal(type, !!parent)}` });
378
+ if (raw.taskMode !== void 0) ignored.push({ taskId: id, field: "taskMode", message: `${where}: ${MODE_NOT_THE_PLANNERS}` });
379
+ if (!ai && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${MANUAL_NOT_READ_ONLY}` });
380
+ const ops = raw.ops !== void 0 ? raw.ops === true : base.ops;
381
+ if (ai && ops && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${OPS_NOT_READ_ONLY}` });
382
+ const asked = typeof raw.readOnly === "boolean" ? raw.readOnly : void 0;
383
+ const readOnly = ai && !ops && (asked ?? from?.readOnly ?? parent?.readOnly ?? false);
384
+ let taskMode = base.taskMode;
385
+ if (ai && catalog.runners.includes(assignedRunner)) {
386
+ const mode = plannedMode(assignedRunner, catalog.runnerModes, { readOnly, current: base.taskMode }, catalog.autonomousDefault ?? true);
387
+ if (!mode.ok) errors.push({ taskId: id, field: "readOnly", message: `${where}: ${mode.error}` });
388
+ else if (mode.mode !== void 0) taskMode = mode.mode;
389
+ }
390
+ if (taskMode === void 0 && readOnly && !catalog.runnerModes) taskMode = "plan";
391
+ const assignedModel = ai ? modelOf(raw, base.assignedModel) : void 0;
392
+ if (assignedModel && isRecord(raw.assignedModel) && catalog.models && catalog.runners.includes(assignedRunner)) {
393
+ const validity = checkModelAndModeValidity(assignedRunner, assignedModel, void 0, catalog.models);
394
+ if (!validity.ok) errors.push({ taskId: id, field: "assignedModel", message: `${where}: ${validity.error}` });
395
+ }
396
+ const slice = (value) => value === "AFK" || value === "HITL" ? value : void 0;
397
+ const autonomy = ai ? slice(raw.autonomy) ?? base.autonomy ?? parent?.autonomy : void 0;
398
+ const sliceType = slice(raw.sliceType) ?? base.sliceType ?? (ai ? parent?.sliceType : void 0);
399
+ const subtasks = [];
400
+ if (Array.isArray(raw.subtasks)) {
401
+ for (const s of raw.subtasks) {
402
+ const child = plannedTask(s, catalog, { parent: { title, sliceType, autonomy, readOnly }, freshIds });
403
+ subtasks.push(child.task);
404
+ errors.push(...child.errors);
405
+ ignored.push(...child.ignored);
406
+ for (const [was, now] of child.renamed) renamed.set(was, now);
407
+ }
408
+ }
409
+ const task = createTask({
410
+ id,
411
+ order: Number(raw.order ?? 0),
412
+ title,
413
+ description,
414
+ type,
415
+ dependencies: Array.isArray(raw.dependencies) ? raw.dependencies.map(String) : base.dependencies ?? [],
416
+ prompt: text(raw.prompt) || base.prompt || (ai ? description || title || void 0 : void 0),
417
+ userSteps: Array.isArray(raw.userSteps) ? raw.userSteps.map((s) => isRecord(s) ? s : {}).map((s) => ({ order: Number(s.order ?? 0), instruction: String(s.instruction ?? ""), completed: false })) : base.userSteps,
418
+ subtasks,
419
+ assignedModel,
420
+ assignedRunner,
421
+ taskMode,
422
+ autonomy,
423
+ sliceType,
424
+ userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : base.userStoriesCovered,
425
+ ops,
426
+ skills: from ? inheritedSkills(raw.skills, base.skills) : skillNames(raw.skills)
427
+ });
428
+ if (Array.isArray(raw.skills)) skillsAsked.set(task, raw.skills);
429
+ return { task, errors, ignored, renamed };
430
+ }
431
+ function checkPlanInvariants(tasks, runners) {
432
+ const rows = flattenTasksWithParents(tasks);
433
+ const errors = [];
434
+ const seen = /* @__PURE__ */ new Set();
435
+ for (const { task: t, parent } of rows) {
436
+ const where = taskWhere(t.title, parent?.title);
437
+ const fail = (field, message) => errors.push({ taskId: t.id, field, message });
438
+ if (seen.has(t.id)) fail("id", `${where} has id "${t.id}", which another task in the plan has too: every task, subtasks included, needs an id of its own`);
439
+ seen.add(t.id);
440
+ if (t.autonomy === "AFK" && t.userSteps && t.userSteps.length > 0) {
441
+ fail("userSteps", `${where} has autonomy "AFK" but contains userSteps. AFK tasks must not have user touchpoints.`);
442
+ }
443
+ if (t.type === "user" && t.sliceType && t.sliceType !== "HITL") {
444
+ fail("sliceType", `User ${where} has sliceType "${t.sliceType}". User tasks must have sliceType "HITL".`);
445
+ }
446
+ const opsRefused = t.ops ? opsPlacementRefusal(t.type, parent !== null) : null;
447
+ if (opsRefused) fail("ops", `${where} ${opsRefused}`);
448
+ if (t.type !== "user" && !runners.includes(t.assignedRunner)) {
449
+ fail("assignedRunner", `${where} has invalid assignedRunner "${t.assignedRunner}". Expected one of: ${runners.join(", ")}`);
450
+ }
451
+ }
452
+ const ids = new Set(rows.map((r) => r.task.id));
453
+ for (const { task: t, parent } of rows) {
454
+ for (const dep of t.dependencies) {
455
+ if (!ids.has(dep)) errors.push({ taskId: t.id, field: "dependencies", message: `${taskWhere(t.title, parent?.title)} depends on "${dep}", which is not a task in the plan` });
456
+ }
457
+ }
458
+ const cycle = findCycle(rows.map((r) => r.task));
459
+ if (cycle) errors.push({ taskId: cycle[0].id, field: "dependencies", message: `Dependency cycle detected: ${[...cycle, cycle[0]].map((t) => `"${t.title}"`).join(" \u2192 ")}` });
460
+ return errors;
461
+ }
462
+ function findCycle(tasks) {
463
+ const byId = new Map(tasks.map((t) => [t.id, t]));
464
+ const path = [];
465
+ const done = /* @__PURE__ */ new Set();
466
+ const visit = (id) => {
467
+ if (done.has(id)) return null;
468
+ const at = path.indexOf(id);
469
+ if (at >= 0) return path.slice(at);
470
+ path.push(id);
471
+ for (const dep of byId.get(id)?.dependencies ?? []) {
472
+ const hit = visit(dep);
473
+ if (hit) return hit;
474
+ }
475
+ path.pop();
476
+ done.add(id);
477
+ return null;
478
+ };
479
+ for (const t of tasks) {
480
+ const hit = visit(t.id);
481
+ if (hit) return hit.map((id) => byId.get(id));
482
+ }
483
+ return null;
252
484
  }
253
485
 
254
486
  // src/services/PlanValidator.ts
@@ -288,117 +520,53 @@ function parsePlanObject(json, raw, runners, runnerModes, autonomousDefault = tr
288
520
  throw new PlanParseError(first.message, raw, { semantic: first.taskId !== void 0 });
289
521
  }
290
522
  function validatePlanTasks(obj, runners, runnerModes, autonomousDefault = true) {
291
- const rawTasks = isRecord(obj) ? obj.tasks : void 0;
523
+ const rawTasks = isRecord2(obj) ? obj.tasks : void 0;
292
524
  if (!Array.isArray(rawTasks)) {
293
525
  return { ok: false, errors: [{ field: "tasks", message: "Invalid plan: missing tasks array" }] };
294
526
  }
295
527
  if (rawTasks.length === 0) {
296
528
  return { ok: false, errors: [{ field: "tasks", message: "Plan contained no tasks" }] };
297
529
  }
298
- const tasks = rawTasks.map((t) => parseTask(asRecord(t), runners, runnerModes, autonomousDefault));
299
- const errors = verticalSliceErrors(tasks);
300
- for (const t of tasks) {
301
- if (!runners.includes(t.assignedRunner)) {
302
- errors.push({
303
- taskId: t.id,
304
- field: "assignedRunner",
305
- message: `Task "${t.title}" has invalid assignedRunner "${t.assignedRunner}". Expected one of: ${runners.join(", ")}`
306
- });
307
- }
308
- }
309
- return errors.length > 0 ? { ok: false, errors } : { ok: true, tasks };
530
+ const planned = rawTasks.map((t) => plannedTask(t, { runners, runnerModes, autonomousDefault }));
531
+ const tasks = planned.map((p) => p.task);
532
+ const errors = [
533
+ ...sliceRequired(tasks),
534
+ ...planned.flatMap((p) => p.errors),
535
+ ...checkPlanInvariants(tasks, runners)
536
+ ];
537
+ if (errors.length > 0) return { ok: false, errors };
538
+ const ignored = planned.flatMap((p) => p.ignored);
539
+ const warnings = [
540
+ ...ignored.some((i) => i.field === "taskMode") ? [`"taskMode" is not yours to set and was ignored: each task runs in its runner's mode for the user's autonomy level, or its read-only mode when you set "readOnly": true.`] : [],
541
+ ...ignored.filter((i) => i.field === "readOnly").map((i) => `${i.message} \u2014 it was ignored.`)
542
+ ];
543
+ return warnings.length > 0 ? { ok: true, tasks, warnings } : { ok: true, tasks };
310
544
  }
311
- function isRecord(value) {
545
+ function isRecord2(value) {
312
546
  return typeof value === "object" && value !== null && !Array.isArray(value);
313
547
  }
314
- function asRecord(value) {
315
- return isRecord(value) ? value : {};
316
- }
317
- var skillsAsked = /* @__PURE__ */ new WeakMap();
318
- function parseTask(raw, runners, runnerModes, autonomousDefault = true, inherited) {
319
- const assignedRunner = typeof raw.assignedRunner === "string" ? raw.assignedRunner : runners[0] ?? "claude-code";
320
- const taskType = raw.type === "user" ? "user" : "ai";
321
- const title = String(raw.title ?? "");
322
- const description = String(raw.description ?? "");
323
- const autonomy = taskType === "ai" && typeof raw.autonomy === "string" && (raw.autonomy === "AFK" || raw.autonomy === "HITL") ? raw.autonomy : taskType === "ai" ? inherited?.autonomy : void 0;
324
- const sliceType = typeof raw.sliceType === "string" && (raw.sliceType === "HITL" || raw.sliceType === "AFK") ? raw.sliceType : taskType === "ai" ? inherited?.sliceType : void 0;
325
- const task = createTask({
326
- id: String(raw.id ?? ""),
327
- order: Number(raw.order ?? 0),
328
- title,
329
- description,
330
- type: taskType,
331
- dependencies: Array.isArray(raw.dependencies) ? raw.dependencies.map(String) : [],
332
- // The planner sometimes omits `prompt` on an AI task while still filling in
333
- // description/title — same fallback TaskOps.applyTaskOps uses for `add`, so
334
- // a task never silently ends up unschedulable (getReadyTasks requires prompt).
335
- prompt: raw.prompt ? String(raw.prompt) : description || title || void 0,
336
- userSteps: Array.isArray(raw.userSteps) ? raw.userSteps.map(asRecord).map((s) => ({
337
- order: Number(s.order ?? 0),
338
- instruction: String(s.instruction ?? ""),
339
- completed: false
340
- })) : void 0,
341
- subtasks: Array.isArray(raw.subtasks) ? raw.subtasks.map((s) => parseTask(asRecord(s), runners, runnerModes, autonomousDefault, { sliceType, autonomy })) : [],
342
- assignedModel: raw.assignedModel ? {
343
- modelId: String(raw.assignedModel.modelId ?? ""),
344
- modelLabel: String(raw.assignedModel.modelLabel ?? ""),
345
- thinkingEffort: raw.assignedModel.thinkingEffort
346
- } : void 0,
347
- assignedRunner,
348
- thinkingEffort: raw.thinkingEffort ? String(raw.thinkingEffort) : void 0,
349
- taskMode: resolveTaskMode(
350
- raw.taskMode !== void 0 ? String(raw.taskMode) : void 0,
351
- assignedRunner,
352
- runnerModes,
353
- autonomousDefault
354
- ),
355
- autonomy,
356
- sliceType,
357
- userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0,
358
- // A subtask runs with its parent, so only a top-level task carries it (ADR-0020).
359
- ops: inherited ? void 0 : opsFlag(raw.ops),
360
- // Not inherited: a subtask's skills are its own.
361
- skills: skillNames(raw.skills)
548
+ function sliceRequired(tasks) {
549
+ return flattenTasksWithParents(tasks).flatMap(({ task: t, parent }) => {
550
+ const where = taskWhere(t.title, parent?.title);
551
+ const errors = [];
552
+ if (!t.sliceType) errors.push({ taskId: t.id, field: "sliceType", message: `${where} is missing sliceType. Every task must specify sliceType ("AFK" or "HITL").` });
553
+ if (t.type === "ai" && !t.autonomy) errors.push({ taskId: t.id, field: "autonomy", message: `AI ${where} is missing autonomy. Every AI task must specify autonomy ("AFK" or "HITL").` });
554
+ return errors;
362
555
  });
363
- if (Array.isArray(raw.skills)) skillsAsked.set(task, raw.skills);
364
- return task;
365
- }
366
- function verticalSliceErrors(tasks, parentTitle) {
367
- const where = (t) => parentTitle ? `subtask "${t.title}" of "${parentTitle}"` : `Task "${t.title}"`;
368
- const errors = [];
369
- for (const t of tasks) {
370
- const fail = (field, message) => errors.push({ taskId: t.id, field, message });
371
- if (!t.sliceType) {
372
- fail("sliceType", `${where(t)} is missing sliceType. Every task must specify sliceType ("AFK" or "HITL").`);
373
- }
374
- if (t.type === "ai" && !t.autonomy) {
375
- fail("autonomy", `AI ${where(t)} is missing autonomy. Every AI task must specify autonomy ("AFK" or "HITL").`);
376
- }
377
- if (t.autonomy === "AFK" && t.userSteps && t.userSteps.length > 0) {
378
- fail("userSteps", `${where(t)} has autonomy "AFK" but contains userSteps. AFK tasks must not have user touchpoints.`);
379
- }
380
- if (t.type === "user" && t.sliceType && t.sliceType !== "HITL") {
381
- fail("sliceType", `User ${where(t)} has sliceType "${t.sliceType}". User tasks must have sliceType "HITL".`);
382
- }
383
- if (t.subtasks.length > 0) {
384
- errors.push(...verticalSliceErrors(t.subtasks, t.title));
385
- }
386
- }
387
- return errors;
388
556
  }
389
557
 
390
558
  // src/services/PartialPlanParser.ts
391
559
  function parsePartialPlan(partial) {
392
- const text = stripModelNoise(partial);
393
- const arrayStart = findTasksArrayStart(text);
560
+ const text2 = stripModelNoise(partial);
561
+ const arrayStart = findTasksArrayStart(text2);
394
562
  if (arrayStart === -1) return [];
395
563
  const tasks = [];
396
564
  let depth = 0;
397
565
  let objText = "";
398
566
  let inString = false;
399
567
  let escaped = false;
400
- for (let i = arrayStart; i < text.length; i++) {
401
- const ch = text[i];
568
+ for (let i = arrayStart; i < text2.length; i++) {
569
+ const ch = text2[i];
402
570
  if (depth > 0) objText += ch;
403
571
  if (inString) {
404
572
  if (escaped) escaped = false;
@@ -425,20 +593,39 @@ function parsePartialPlan(partial) {
425
593
  if (depth > 0) tasks.push(rowFromObject(objText, "streaming"));
426
594
  return tasks;
427
595
  }
428
- function findTasksArrayStart(text) {
429
- const key = /"tasks"\s*:\s*\[/.exec(text);
596
+ function findTasksArrayStart(text2) {
597
+ const key = /"tasks"\s*:\s*\[/.exec(text2);
430
598
  return key ? key.index + key[0].length : -1;
431
599
  }
432
- function rowFromObject(objText, status) {
600
+ function rowFromObject(fullText, status) {
601
+ const objText = withoutSubtasks(fullText);
433
602
  const model = stringField(objText, "modelLabel");
434
- const mode = stringField(objText, "taskMode");
603
+ const readOnly = /"readOnly"\s*:\s*true/.test(objText);
435
604
  return {
436
605
  title: titleFromObject(objText),
437
606
  status,
438
607
  ...model ? { model } : {},
439
- ...mode ? { mode } : {}
608
+ ...readOnly ? { readOnly: true } : {}
440
609
  };
441
610
  }
611
+ function withoutSubtasks(objText) {
612
+ const key = /"subtasks"\s*:\s*\[/.exec(objText);
613
+ if (!key) return objText;
614
+ let depth = 0;
615
+ let inString = false;
616
+ let escaped = false;
617
+ for (let i = key.index + key[0].length - 1; i < objText.length; i++) {
618
+ const ch = objText[i];
619
+ if (inString) {
620
+ if (escaped) escaped = false;
621
+ else if (ch === "\\") escaped = true;
622
+ else if (ch === '"') inString = false;
623
+ } else if (ch === '"') inString = true;
624
+ else if (ch === "[") depth++;
625
+ else if (ch === "]" && --depth === 0) return objText.slice(0, key.index) + withoutSubtasks(objText.slice(i + 1));
626
+ }
627
+ return objText.slice(0, key.index);
628
+ }
442
629
  function titleFromObject(objText) {
443
630
  const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(objText);
444
631
  if (!m) return "";