@ordewell/core 0.10.11 → 0.11.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 (50) hide show
  1. package/AUTHORS.md +37 -0
  2. package/NOTICE +71 -0
  3. package/README.md +1 -1
  4. package/dist/{IFileSystem-1PqRCjhu.d.mts → IRunner-B5l9PCTv.d.ts} +926 -102
  5. package/dist/{IFileSystem-lTRL_G9Z.d.ts → IRunner-DBgDx_Mr.d.mts} +926 -102
  6. package/dist/{ModeResolver-CoJAJD-f.d.ts → ModeResolver-BDAgouuE.d.mts} +9 -2
  7. package/dist/{ModeResolver-6TQXVsde.d.mts → ModeResolver-DyXeZ9ib.d.ts} +9 -2
  8. package/dist/{Task-CfBM0wkV.d.mts → Task-BL4Idm9r.d.mts} +656 -26
  9. package/dist/{Task-CfBM0wkV.d.ts → Task-BL4Idm9r.d.ts} +656 -26
  10. package/dist/{chunk-ESKK5I4F.mjs → chunk-4QXOWEGC.mjs} +1888 -334
  11. package/dist/chunk-4QXOWEGC.mjs.map +1 -0
  12. package/dist/{chunk-KWS3VEER.mjs → chunk-MYXMIVWA.mjs} +6 -6
  13. package/dist/chunk-MYXMIVWA.mjs.map +1 -0
  14. package/dist/{chunk-PFH7WFIB.mjs → chunk-QTZ5XTBY.mjs} +228 -15
  15. package/dist/chunk-QTZ5XTBY.mjs.map +1 -0
  16. package/dist/{chunk-24EL4Z2W.mjs → chunk-YAFNYNFX.mjs} +342 -36
  17. package/dist/chunk-YAFNYNFX.mjs.map +1 -0
  18. package/dist/index.d.mts +1490 -769
  19. package/dist/index.d.ts +1490 -769
  20. package/dist/index.js +32009 -20354
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +27785 -17956
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/parsing.d.mts +2 -2
  25. package/dist/parsing.d.ts +2 -2
  26. package/dist/parsing.js +232 -25
  27. package/dist/parsing.js.map +1 -1
  28. package/dist/parsing.mjs +3 -3
  29. package/dist/plan-utils.d.mts +4 -4
  30. package/dist/plan-utils.d.ts +4 -4
  31. package/dist/plan-utils.js +1156 -96
  32. package/dist/plan-utils.js.map +1 -1
  33. package/dist/plan-utils.mjs +144 -8
  34. package/dist/{runSettingsText-Jb_efnI4.d.mts → runSettingsText-BKwWs052.d.mts} +2882 -621
  35. package/dist/{runSettingsText-C5uv2axI.d.ts → runSettingsText-CAXiTpsj.d.ts} +2882 -621
  36. package/dist/testing.d.mts +10 -4
  37. package/dist/testing.d.ts +10 -4
  38. package/dist/testing.js +30 -7
  39. package/dist/testing.js.map +1 -1
  40. package/dist/testing.mjs +26 -7
  41. package/dist/testing.mjs.map +1 -1
  42. package/package.json +7 -3
  43. package/skills/diagnose/SKILL.md +52 -0
  44. package/skills/explore/SKILL.md +65 -0
  45. package/dist/chunk-24EL4Z2W.mjs.map +0 -1
  46. package/dist/chunk-ESKK5I4F.mjs.map +0 -1
  47. package/dist/chunk-KWS3VEER.mjs.map +0 -1
  48. package/dist/chunk-NJGID6BG.mjs +0 -115
  49. package/dist/chunk-NJGID6BG.mjs.map +0 -1
  50. package/dist/chunk-PFH7WFIB.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
- import { a7 as RunnerId, am as Task } from './Task-CfBM0wkV.mjs';
2
- import { R as RunnerModeInfo } from './ModeResolver-6TQXVsde.mjs';
1
+ import { az as RunnerId, aS as Task } from './Task-BL4Idm9r.mjs';
2
+ import { R as RunnerModeInfo } from './ModeResolver-BDAgouuE.mjs';
3
3
 
4
4
  declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
5
5
 
package/dist/parsing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a7 as RunnerId, am as Task } from './Task-CfBM0wkV.js';
2
- import { R as RunnerModeInfo } from './ModeResolver-CoJAJD-f.js';
1
+ import { az as RunnerId, aS as Task } from './Task-BL4Idm9r.js';
2
+ import { R as RunnerModeInfo } from './ModeResolver-DyXeZ9ib.js';
3
3
 
4
4
  declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
5
5
 
package/dist/parsing.js CHANGED
@@ -101,12 +101,13 @@ function findDependencyCycle(tasks) {
101
101
  // src/models/Task.ts
102
102
  function createTask(overrides = {}) {
103
103
  const skills = skillNames(overrides.skills);
104
+ const type = overrides.type ?? "ai";
104
105
  return {
105
106
  id: overrides.id ?? (0, import_uuid.v4)(),
106
107
  order: overrides.order ?? 0,
107
108
  title: overrides.title ?? "",
108
109
  description: overrides.description ?? "",
109
- type: overrides.type ?? "ai",
110
+ type,
110
111
  status: overrides.status ?? "pending",
111
112
  dependencies: overrides.dependencies ?? [],
112
113
  prompt: overrides.prompt,
@@ -114,16 +115,25 @@ function createTask(overrides = {}) {
114
115
  subtasks: overrides.subtasks ?? [],
115
116
  verdict: overrides.verdict,
116
117
  outputSummary: overrides.outputSummary,
118
+ ...overrides.handoff ? { handoff: structuredClone(overrides.handoff) } : {},
119
+ ...overrides.attempted ? { attempted: true } : {},
117
120
  ...overrides.attemptUsage ? { attemptUsage: structuredClone(overrides.attemptUsage) } : {},
118
121
  assignedModel: overrides.assignedModel,
119
122
  assignedRunner: overrides.assignedRunner ?? DEFAULT_RUNNERS[0],
120
- taskMode: overrides.taskMode ?? "build",
123
+ // A script task runs in no mode (ADR-0035): one given it would read as a mode it runs in.
124
+ taskMode: overrides.taskMode ?? (type === "script" ? void 0 : "build"),
121
125
  autonomy: overrides.autonomy,
122
126
  sliceType: overrides.sliceType,
123
127
  userStoriesCovered: overrides.userStoriesCovered,
124
128
  ...opsFlag(overrides.ops, overrides.type) ? { ops: true } : {},
125
129
  ...skills ? { skills } : {},
126
- ...overrides.addedBy === "supervisor" ? { addedBy: "supervisor" } : {}
130
+ ...overrides.addedBy === "supervisor" ? { addedBy: "supervisor" } : {},
131
+ ...overrides.commands !== void 0 ? { commands: overrides.commands.map((argv) => [...argv]) } : {},
132
+ ...overrides.script !== void 0 ? { script: overrides.script } : {},
133
+ ...overrides.timeoutMs !== void 0 ? { timeoutMs: overrides.timeoutMs } : {},
134
+ ...overrides.loop ? { loop: { body: [...overrides.loop.body], maxRounds: overrides.loop.maxRounds } } : {},
135
+ ...overrides.loopRound !== void 0 ? { loopRound: overrides.loopRound } : {},
136
+ ...overrides.research !== void 0 ? { research: overrides.research } : {}
127
137
  };
128
138
  }
129
139
  var SKILL_NAME = new RegExp(`^${SKILL_NAME_PATTERN}$`);
@@ -293,7 +303,7 @@ function escapeControlCharsInStrings(json) {
293
303
  var import_uuid2 = require("uuid");
294
304
 
295
305
  // src/services/ModeResolver.ts
296
- function resolveDefaultMode(modes, autonomousDefault = true) {
306
+ function resolveDefaultMode(modes, autonomousDefault = false) {
297
307
  if (!modes || modes.length === 0) return void 0;
298
308
  const tag = autonomousDefault ? "autonomous" : "safe";
299
309
  const tagged = modes.find((m) => m[tag]);
@@ -316,7 +326,7 @@ function unavailableModeReason(modes, id) {
316
326
  function unavailableModeError(runner, id, reason) {
317
327
  return `Mode "${id}" of runner "${runner}" is unavailable on this host: ${reason}`;
318
328
  }
319
- function plannedMode(runner, runnerModes, { readOnly, current }, autonomousDefault = true) {
329
+ function plannedMode(runner, runnerModes, { readOnly, current }, autonomousDefault = false) {
320
330
  if (!runnerModes) return { ok: true, mode: void 0 };
321
331
  const modes = runnerModes[runner] ?? [];
322
332
  const readOnlyId = readOnlyMode(modes);
@@ -399,7 +409,7 @@ function modeFor(write, task, moved) {
399
409
  if (unavailable) return { ok: false, error: `Mode "${task.taskMode}" of runner "${runner}" is unavailable on this host: ${unavailable}` };
400
410
  return { ok: true, mode: task.taskMode };
401
411
  }
402
- const becameAi = before?.type === "user";
412
+ const becameAi = before !== void 0 && before.type !== "ai";
403
413
  if (writer === "runner" || !catalog.runnerModes || before && !moved && !becameAi && write.readOnly === void 0) return { ok: true, mode: task.taskMode };
404
414
  const carried = prior !== void 0 && task.taskMode === prior.taskMode && readOnlyOn(prior.taskMode, prior.assignedRunner, catalog);
405
415
  const readOnly = write.readOnly ?? (carried || readOnlyOn(task.taskMode, runner, catalog));
@@ -411,7 +421,7 @@ function modeFor(write, task, moved) {
411
421
  error: `"${task.title}" is read-only, and runner "${runner}" has no read-only mode${reason ? " available on this host" : ""}: give it another mode first${able.length > 0 ? `, or move it to one of: ${able.join(", ")}` : ""}${reason ? `. ${reason}` : ""}`
412
422
  };
413
423
  }
414
- const mode = plannedMode(runner, catalog.runnerModes, { readOnly, current: task.taskMode }, catalog.autonomousDefault ?? true);
424
+ const mode = plannedMode(runner, catalog.runnerModes, { readOnly, current: task.taskMode }, catalog.autonomousDefault ?? false);
415
425
  return mode.ok ? { ok: true, mode: mode.mode ?? task.taskMode } : mode;
416
426
  }
417
427
  function resolveAssignment(write) {
@@ -423,7 +433,7 @@ function resolveAssignment(write) {
423
433
  const live = writer !== "runner" && !!before && !!catalog.liveRunner?.(before.id);
424
434
  if (live && moved) return { ok: false, field: "assignedRunner", error: liveRunnerRefusal({ title }) };
425
435
  if (live && !sameAssignment(before?.assignedModel, written.assignedModel)) return { ok: false, field: "assignedModel", error: liveModelRefusal({ title }) };
426
- if (written.type === "user") return { ok: true, task: written, adjustments: [] };
436
+ if (written.type !== "ai") return { ok: true, task: written, adjustments: [] };
427
437
  const mode = modeFor(write, written, moved);
428
438
  if (!mode.ok) return { ok: false, field: "taskMode", error: mode.error };
429
439
  const model = live ? { ok: true, assignment: written.assignedModel } : modelFor(write, moved);
@@ -492,17 +502,162 @@ function modelFor(write, moved) {
492
502
  return { ok: true, assignment: disowned || !allows(id) ? refill(written.thinkingEffort) : settled(written) };
493
503
  }
494
504
 
505
+ // src/models/ScriptTask.ts
506
+ var DEFAULT_SCRIPT_TIMEOUT_MS = 10 * 6e4;
507
+ var MAX_SCRIPT_TIMEOUT_MS = 24 * 60 * 6e4;
508
+ var SCRIPT_BODY_FIELDS = ["commands", "script", "timeoutMs"];
509
+ var NOT_A_SCRIPTS_FIELDS = ["assignedModel", "taskMode", "prompt", "skills", "userSteps", "ops"];
510
+ function scriptBodyErrors(task) {
511
+ if (task.type !== "script") {
512
+ return SCRIPT_BODY_FIELDS.filter((f) => task[f] !== void 0).map((field) => ({ field, message: `has "${field}", which only a script task has: set "type": "script", or leave it out` }));
513
+ }
514
+ const errors = [];
515
+ const { commands, script, timeoutMs } = task;
516
+ if (commands !== void 0 && script !== void 0) {
517
+ errors.push({ field: "commands", message: 'has both "commands" and "script": a script task runs exactly one of them' });
518
+ } else if (commands === void 0 && script === void 0) {
519
+ errors.push({ field: "commands", message: 'is a script task with neither "commands" nor "script": give it the argument vectors to run, or the script' });
520
+ }
521
+ if (commands !== void 0) errors.push(...commandsErrors(commands));
522
+ if (script !== void 0 && (typeof script !== "string" || !script.trim())) {
523
+ errors.push({ field: "script", message: 'has an empty "script": write the script it runs' });
524
+ }
525
+ if (timeoutMs !== void 0 && !(typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= MAX_SCRIPT_TIMEOUT_MS)) {
526
+ errors.push({ field: "timeoutMs", message: `has "timeoutMs" ${JSON.stringify(timeoutMs)}: the time limit is a whole number of milliseconds from 1 to ${MAX_SCRIPT_TIMEOUT_MS}, or left out for ten minutes` });
527
+ }
528
+ return errors;
529
+ }
530
+ function commandsErrors(commands) {
531
+ const shape = 'a list of argument vectors, each a list of strings, like [["git", "push", "origin", "main"]]';
532
+ if (!Array.isArray(commands) || commands.length === 0) return [{ field: "commands", message: `has "commands" that is not ${shape}` }];
533
+ for (const [i, argv] of commands.entries()) {
534
+ const n = i + 1;
535
+ if (!Array.isArray(argv) || !argv.every((word) => typeof word === "string")) {
536
+ return [{ field: "commands", message: `has command ${n} that is not an argument vector: "commands" is ${shape}` }];
537
+ }
538
+ if (argv.length === 0) return [{ field: "commands", message: `has command ${n} as an empty argument vector: name the program it runs` }];
539
+ if (!argv[0].trim()) return [{ field: "commands", message: `has command ${n} naming no program: its first word is the program it runs` }];
540
+ }
541
+ return [];
542
+ }
543
+ function notAScriptsFields(task) {
544
+ return NOT_A_SCRIPTS_FIELDS.filter((f) => {
545
+ const value = task[f];
546
+ return value !== void 0 && value !== false && !(Array.isArray(value) && value.length === 0);
547
+ });
548
+ }
549
+
550
+ // src/models/Loop.ts
551
+ var DEFAULT_LOOP_ROUNDS = 3;
552
+ var MAX_LOOP_ROUNDS = 10;
553
+ var SHAPE = 'is not {"body": ["<task id>", ...], "maxRounds"?: <rounds>}';
554
+ function roundsMessage(value) {
555
+ return `has "maxRounds" ${JSON.stringify(value)}: a loop runs its check from 1 to ${MAX_LOOP_ROUNDS} times, or ${DEFAULT_LOOP_ROUNDS} when it is left out`;
556
+ }
557
+ function isRoundCount(value) {
558
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_LOOP_ROUNDS;
559
+ }
560
+ function loopSpec(value) {
561
+ if (value === void 0 || value === null) return { loop: void 0 };
562
+ if (typeof value !== "object" || Array.isArray(value)) return { error: `has a "loop" that ${SHAPE}` };
563
+ const { body, maxRounds } = value;
564
+ if (!Array.isArray(body) || !body.every((id) => typeof id === "string" || typeof id === "number")) return { error: `has a "loop" whose "body" ${SHAPE}` };
565
+ if (body.length === 0) return { error: 'has a "loop" with an empty "body": name the tasks that fix what it finds' };
566
+ if (maxRounds !== void 0 && !isRoundCount(maxRounds)) return { error: roundsMessage(maxRounds) };
567
+ return { loop: { body: body.map(String), maxRounds: maxRounds ?? DEFAULT_LOOP_ROUNDS } };
568
+ }
569
+ function loopErrors(tasks) {
570
+ const rows = flattenTasksWithParents(tasks);
571
+ const byId = new Map(rows.map((row) => [row.task.id, row]));
572
+ const errors = [];
573
+ const bodyOwner = /* @__PURE__ */ new Map();
574
+ for (const { task: check, parent } of rows) {
575
+ const loop = check.loop;
576
+ if (!loop) continue;
577
+ const where = parent ? `subtask "${check.title}" of "${parent.title}"` : `Task "${check.title}"`;
578
+ const fail = (message) => errors.push({ taskId: check.id, field: "loop", message: `${where} ${message}` });
579
+ if (parent) fail(`has a "loop", but only a top-level task can be a loop's check`);
580
+ if (check.type === "user") fail("is a manual task, so it cannot be a loop's check: a check is an AI task, which reports through task_complete, or a script task, whose exit status decides");
581
+ if (check.subtasks?.length) fail("is a loop's check, which has no subtasks: each round runs it again whole");
582
+ if (!isRoundCount(loop.maxRounds)) fail(roundsMessage(loop.maxRounds));
583
+ if (!Array.isArray(loop.body) || loop.body.length === 0) fail('has a "loop" with an empty "body": name the tasks that fix what it finds');
584
+ const named = /* @__PURE__ */ new Set();
585
+ for (const id of loop.body ?? []) {
586
+ const row = byId.get(id);
587
+ if (named.has(id)) {
588
+ fail(`names "${row?.task.title ?? id}" twice in its loop's "body"`);
589
+ continue;
590
+ }
591
+ named.add(id);
592
+ if (!row) {
593
+ fail(`names "${id}" in its loop's "body", which is no task in the plan`);
594
+ continue;
595
+ }
596
+ const body = row.task;
597
+ if (body.id === check.id) {
598
+ fail(`names itself in its loop's "body": the body is the tasks that fix what it finds`);
599
+ continue;
600
+ }
601
+ if (row.parent) {
602
+ fail(`names subtask "${body.title}" of "${row.parent.title}" in its loop's "body": only a top-level task can be in a loop's body`);
603
+ continue;
604
+ }
605
+ const other = bodyOwner.get(id);
606
+ if (other) {
607
+ fail(`names "${body.title}" in its loop's "body", which is in the body of "${other.title}"'s loop too: a task belongs to one loop only`);
608
+ continue;
609
+ }
610
+ bodyOwner.set(id, check);
611
+ if (body.loop) fail(`names "${body.title}" in its loop's "body", which has a loop of its own: a loop has no loop inside its body`);
612
+ if (body.type === "user") fail(`names manual task "${body.title}" in its loop's "body": a body is AI or script tasks, which Ordewell runs again each round`);
613
+ if (body.subtasks?.length) fail(`names "${body.title}" in its loop's "body", which has subtasks: each round runs a body task again whole, so make each step a task of the body`);
614
+ }
615
+ }
616
+ for (const [bodyId, check] of bodyOwner) {
617
+ const body = byId.get(bodyId).task;
618
+ const where = `Task "${body.title}" is in the body of "${check.title}"'s loop`;
619
+ if (!body.dependencies.includes(check.id)) {
620
+ errors.push({ taskId: body.id, field: "dependencies", message: `${where}, so it depends on "${check.title}": the check's report is what it fixes. Add "${check.title}" to its dependencies` });
621
+ }
622
+ for (const dep of body.dependencies) {
623
+ if (dep === check.id || check.dependencies.includes(dep) || bodyOwner.get(dep) === check) continue;
624
+ errors.push({ taskId: body.id, field: "dependencies", message: `${where}, so it depends only on "${check.title}", on what "${check.title}" depends on, or on other tasks of that body; "${byId.get(dep)?.task.title ?? dep}" is none of these` });
625
+ }
626
+ }
627
+ for (const { task } of rows) {
628
+ for (const dep of task.dependencies) {
629
+ const check = bodyOwner.get(dep);
630
+ if (!check || bodyOwner.get(task.id) === check) continue;
631
+ const body = byId.get(dep).task;
632
+ const message = task.id === check.id ? `Task "${task.title}" depends on "${body.title}" from its own loop's body: the body runs after the check, so the check cannot wait for it` : `Task "${task.title}" depends on "${body.title}", which is in the body of "${check.title}"'s loop and runs only if "${check.title}" fails: depend on "${check.title}" instead`;
633
+ errors.push({ taskId: task.id, field: "dependencies", message });
634
+ }
635
+ }
636
+ return errors;
637
+ }
638
+
495
639
  // src/services/PlannedTask.ts
496
640
  var MODE_NOT_THE_PLANNERS = `"taskMode" is the user's to choose: set "readOnly": true on a task that must not change files, and leave it out otherwise`;
497
641
  var MANUAL_NOT_READ_ONLY = '"readOnly" is for AI tasks: a manual task runs in no mode';
498
- var OPS_NOT_READ_ONLY = `"readOnly" is not for an ops task: it runs commands, in the session's mode, and a read-only mode refuses them`;
642
+ var OPS_NOT_READ_ONLY = '"readOnly" is not for an ops task: it acts in the workspace, while a read-only task lands nothing';
499
643
  function isRecord(value) {
500
644
  return typeof value === "object" && value !== null && !Array.isArray(value);
501
645
  }
502
646
  function text(value) {
503
647
  return typeof value === "string" || typeof value === "number" ? String(value) : void 0;
504
648
  }
649
+ var NOT_ON_A_SCRIPT = ["assignedRunner", "assignedModel", "thinkingEffort", "taskMode", "readOnly", "prompt", "skills", "userSteps", "ops", "subtasks"];
650
+ function notOnAScript(raw) {
651
+ return NOT_ON_A_SCRIPT.filter((f) => {
652
+ const value = raw[f];
653
+ return value !== void 0 && value !== null && value !== false && !(Array.isArray(value) && value.length === 0);
654
+ });
655
+ }
656
+ function notOnAScriptMessage(fields) {
657
+ return `is a script task, which runs its "commands" or "script" with no model: leave out ${fields.map((f) => `"${f}"`).join(", ")}`;
658
+ }
505
659
  function opsPlacementRefusal(type, isSubtask) {
660
+ if (type === "script") return 'is a script task, which is an ops task by nature: leave "ops" out';
506
661
  if (type !== "ai") return "is a manual task: only an AI task can be an ops task";
507
662
  if (isSubtask) return "is a subtask, which runs with its parent: only a top-level task can be an ops task";
508
663
  return null;
@@ -523,6 +678,25 @@ function modelOf(raw, from) {
523
678
  }
524
679
  return from && effort !== void 0 ? { ...from, thinkingEffort: effort } : from;
525
680
  }
681
+ function bodyFields(raw, base) {
682
+ const pick = (field) => raw[field] !== void 0 && raw[field] !== null ? raw[field] : base[field];
683
+ const judged = { commands: pick("commands"), script: pick("script"), timeoutMs: pick("timeoutMs") };
684
+ const malformed = /* @__PURE__ */ new Set();
685
+ const commands = isCommands(judged.commands) ? judged.commands : void 0;
686
+ if (judged.commands !== void 0 && !commands) malformed.add("commands");
687
+ const script = typeof judged.script === "string" ? judged.script : void 0;
688
+ if (judged.script !== void 0 && script === void 0) malformed.add("script");
689
+ const timeoutMs = typeof judged.timeoutMs === "number" ? judged.timeoutMs : void 0;
690
+ if (judged.timeoutMs !== void 0 && timeoutMs === void 0) malformed.add("timeoutMs");
691
+ return {
692
+ fields: { ...commands ? { commands } : {}, ...script !== void 0 ? { script } : {}, ...timeoutMs !== void 0 ? { timeoutMs } : {} },
693
+ raw: judged,
694
+ malformed
695
+ };
696
+ }
697
+ function isCommands(value) {
698
+ return Array.isArray(value) && value.every((argv) => Array.isArray(argv) && argv.every((word) => typeof word === "string"));
699
+ }
526
700
  function plannedTask(spec, catalog, at = {}) {
527
701
  const raw = isRecord(spec) ? spec : {};
528
702
  const { parent, from, freshIds, newId = import_uuid2.v4 } = at;
@@ -536,17 +710,26 @@ function plannedTask(spec, catalog, at = {}) {
536
710
  const plannerId = text(raw.id);
537
711
  const id = freshIds || !plannerId ? newId() : plannerId;
538
712
  if (freshIds && plannerId) renamed.set(plannerId, id);
539
- const type = raw.type === "user" || raw.type === "ai" ? raw.type : base.type ?? "ai";
713
+ const type = raw.type === "user" || raw.type === "ai" || raw.type === "script" ? raw.type : base.type ?? "ai";
540
714
  const ai = type === "ai";
715
+ const script = type === "script";
541
716
  const assignedRunner = text(raw.assignedRunner) ?? base.assignedRunner ?? catalog.runners[0] ?? DEFAULT_RUNNERS[0];
542
717
  const description = text(raw.description) ?? base.description ?? title;
543
- if (raw.ops === true && !ai) errors.push({ taskId: id, field: "ops", message: `${where} ${opsPlacementRefusal(type, !!parent)}` });
544
- if (raw.taskMode !== void 0) ignored.push({ taskId: id, field: "taskMode", message: `${where}: ${MODE_NOT_THE_PLANNERS}` });
545
- if (!ai && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${MANUAL_NOT_READ_ONLY}` });
718
+ const notScripts = script ? notOnAScript(raw) : [];
719
+ if (notScripts.length > 0) errors.push({ taskId: id, field: notScripts[0], message: `${where} ${notOnAScriptMessage(notScripts)}` });
720
+ const body = bodyFields(raw, base);
721
+ const shapeErrors = scriptBodyErrors({ type, ...body.raw }).filter((e) => body.malformed.has(e.field));
722
+ for (const e of shapeErrors) errors.push({ taskId: id, field: e.field, message: `${where} ${e.message}` });
723
+ const loop = loopSpec(raw.loop);
724
+ if ("error" in loop) errors.push({ taskId: id, field: "loop", message: `${where} ${loop.error}` });
725
+ if (raw.ops === true && !ai && !script) errors.push({ taskId: id, field: "ops", message: `${where} ${opsPlacementRefusal(type, !!parent)}` });
726
+ if (raw.taskMode !== void 0 && !script) ignored.push({ taskId: id, field: "taskMode", message: `${where}: ${MODE_NOT_THE_PLANNERS}` });
727
+ if (type === "user" && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${MANUAL_NOT_READ_ONLY}` });
546
728
  const ops = raw.ops !== void 0 ? raw.ops === true : base.ops;
547
729
  if (ai && ops && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${OPS_NOT_READ_ONLY}` });
548
730
  const asked = typeof raw.readOnly === "boolean" ? raw.readOnly : void 0;
549
731
  const readOnly = ai && !ops && (asked ?? from?.readOnly ?? parent?.readOnly ?? false);
732
+ if (raw.research !== void 0 && typeof raw.research !== "boolean") errors.push({ taskId: id, field: "research", message: `${where}: "research" must be a boolean` });
550
733
  const assignedModel = ai ? modelOf(raw, base.assignedModel) : void 0;
551
734
  const slice = (value) => value === "AFK" || value === "HITL" ? value : void 0;
552
735
  const autonomy = ai ? slice(raw.autonomy) ?? base.autonomy ?? parent?.autonomy : void 0;
@@ -569,7 +752,8 @@ function plannedTask(spec, catalog, at = {}) {
569
752
  description,
570
753
  type,
571
754
  dependencies: Array.isArray(raw.dependencies) ? raw.dependencies.map(String) : base.dependencies ?? [],
572
- prompt: text(raw.prompt) || base.prompt || (ai ? description || title || void 0 : void 0),
755
+ // What a merge or split carries over for a runner is nothing a script runs.
756
+ prompt: script ? void 0 : text(raw.prompt) || base.prompt || (ai ? description || title || void 0 : void 0),
573
757
  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,
574
758
  subtasks,
575
759
  assignedModel,
@@ -577,10 +761,13 @@ function plannedTask(spec, catalog, at = {}) {
577
761
  autonomy,
578
762
  sliceType,
579
763
  userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : base.userStoriesCovered,
580
- ops,
581
- skills: from ? inheritedSkills(raw.skills, base.skills) : skillNames(raw.skills)
764
+ ops: script ? void 0 : ops,
765
+ research: typeof raw.research === "boolean" ? raw.research : base.research,
766
+ skills: script ? void 0 : from ? inheritedSkills(raw.skills, base.skills) : skillNames(raw.skills),
767
+ ...body.fields,
768
+ ..."loop" in loop && loop.loop ? { loop: loop.loop } : {}
582
769
  };
583
- let taskMode = base.taskMode;
770
+ let taskMode = script ? void 0 : base.taskMode;
584
771
  let landedModel = assignedModel;
585
772
  if (ai && catalog.runners.includes(assignedRunner)) {
586
773
  const resolved = resolveAssignment({
@@ -603,30 +790,50 @@ function plannedTask(spec, catalog, at = {}) {
603
790
  if (Array.isArray(raw.skills)) skillsAsked.set(task, raw.skills);
604
791
  return { task, errors, ignored, renamed, adjustments };
605
792
  }
793
+ function checkPlanRunners(tasks, runners) {
794
+ if (!runners) return [];
795
+ return flattenTasksWithParents(tasks).filter(({ task: t }) => t.type === "ai" && !runners.includes(t.assignedRunner)).map(({ task: t, parent }) => ({
796
+ taskId: t.id,
797
+ field: "assignedRunner",
798
+ message: `${taskWhere(t.title, parent?.title)} has invalid assignedRunner "${t.assignedRunner}". Expected one of: ${runners.join(", ")}`
799
+ }));
800
+ }
606
801
  function checkPlanInvariants(tasks, runners, runnerModes) {
607
802
  const rows = flattenTasksWithParents(tasks);
608
803
  const errors = [];
609
804
  const seen = /* @__PURE__ */ new Set();
805
+ const body = new Set(tasks.flatMap((t) => t.loop?.body ?? []));
610
806
  for (const { task: t, parent } of rows) {
611
807
  const where = taskWhere(t.title, parent?.title);
612
808
  const fail = (field, message) => errors.push({ taskId: t.id, field, message });
613
809
  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`);
614
810
  seen.add(t.id);
811
+ if (t.research !== void 0 && typeof t.research !== "boolean") fail("research", `${where}: "research" must be a boolean`);
812
+ const readOnly = runnerModes ? taggedReadOnlyMode(runnerModes[t.assignedRunner]) : "plan";
813
+ if (t.research && (t.type !== "ai" || parent !== null || t.ops || body.has(t.id) || readOnly === void 0 || t.taskMode !== readOnly)) {
814
+ fail("research", `${where}: research is only for top-level read-only AI tasks, never a loop's body`);
815
+ }
615
816
  if (t.autonomy === "AFK" && t.userSteps && t.userSteps.length > 0) {
616
817
  fail("userSteps", `${where} has autonomy "AFK" but contains userSteps. AFK tasks must not have user touchpoints.`);
617
818
  }
618
819
  if (t.type === "user" && t.sliceType && t.sliceType !== "HITL") {
619
820
  fail("sliceType", `User ${where} has sliceType "${t.sliceType}". User tasks must have sliceType "HITL".`);
620
821
  }
621
- const opsRefused = t.ops ? opsPlacementRefusal(t.type, parent !== null) : null;
822
+ const opsRefused = t.ops && t.type !== "script" ? opsPlacementRefusal(t.type, parent !== null) : null;
622
823
  if (opsRefused) fail("ops", `${where} ${opsRefused}`);
623
- if (t.ops && t.taskMode !== void 0 && t.taskMode === taggedReadOnlyMode(runnerModes?.[t.assignedRunner])) {
624
- fail("taskMode", `${where} is an ops task, which runs commands, so it cannot run in its runner's read-only mode`);
824
+ if (t.type === "script") {
825
+ if (parent !== null) fail("type", `${where} is a script task: only a top-level task can be one, as only a top-level task can be an ops task`);
826
+ if (t.subtasks?.length) fail("subtasks", `${where} is a script task, which has no subtasks: make each step a command of its own, or a task of its own`);
827
+ const extra = notAScriptsFields(t);
828
+ if (extra.length > 0) fail(extra[0], `${where} ${notOnAScriptMessage(extra)}`);
625
829
  }
626
- if (runners && t.type !== "user" && !runners.includes(t.assignedRunner)) {
627
- fail("assignedRunner", `${where} has invalid assignedRunner "${t.assignedRunner}". Expected one of: ${runners.join(", ")}`);
830
+ for (const e of scriptBodyErrors(t)) fail(e.field, `${where} ${e.message}`);
831
+ if (t.ops && t.taskMode !== void 0 && t.taskMode === taggedReadOnlyMode(runnerModes?.[t.assignedRunner])) {
832
+ fail("taskMode", `${where} is an ops task, which acts in the workspace, so it cannot use the read-only profile`);
628
833
  }
629
834
  }
835
+ errors.push(...checkPlanRunners(tasks, runners));
836
+ errors.push(...loopErrors(tasks));
630
837
  for (const { task: t, parent } of rows) {
631
838
  for (const error of dependencyTargetErrors(tasks, t.id, t.dependencies.filter((id) => id !== t.id))) {
632
839
  errors.push({ taskId: t.id, field: "dependencies", message: `${taskWhere(t.title, parent?.title)}: ${error}` });
@@ -641,7 +848,7 @@ function findCycle(tasks) {
641
848
  }
642
849
 
643
850
  // src/services/PlanValidator.ts
644
- function parsePlanJson(raw, runners, runnerModes, autonomousDefault = true) {
851
+ function parsePlanJson(raw, runners, runnerModes, autonomousDefault = false) {
645
852
  const { matches, fallback, sawUnbalanced } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
646
853
  const candidates = matches.length > 0 ? [...matches].reverse() : [fallback.json];
647
854
  let lastError = null;
@@ -662,7 +869,7 @@ function parsePlanJson(raw, runners, runnerModes, autonomousDefault = true) {
662
869
  }
663
870
  throw lastError ?? new PlanParseError("Invalid plan: missing tasks array", raw);
664
871
  }
665
- function parsePlanObject(json, raw, runners, runnerModes, autonomousDefault = true) {
872
+ function parsePlanObject(json, raw, runners, runnerModes, autonomousDefault = false) {
666
873
  const cleaned = stripTrailingCommas(escapeControlCharsInStrings(json));
667
874
  let parsed;
668
875
  try {
@@ -676,7 +883,7 @@ function parsePlanObject(json, raw, runners, runnerModes, autonomousDefault = tr
676
883
  const [first] = result.errors;
677
884
  throw new PlanParseError(first.message, raw, { semantic: first.taskId !== void 0 });
678
885
  }
679
- function validatePlanTasks(obj, runners, runnerModes, autonomousDefault = true) {
886
+ function validatePlanTasks(obj, runners, runnerModes, autonomousDefault = false) {
680
887
  const rawTasks = isRecord2(obj) ? obj.tasks : void 0;
681
888
  if (!Array.isArray(rawTasks)) {
682
889
  return { ok: false, errors: [{ field: "tasks", message: "Invalid plan: missing tasks array" }] };