llm-orchestrator 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "name": "tbogdan",
3
+ "description": "llm-orchestrator: write /task once and the agent plans, shards across parallel subagents, gates every phase and verifies before claiming done.",
3
4
  "owner": {
4
5
  "name": "Bogdan-Gabriel Torcescu",
5
6
  "url": "https://www.linkedin.com/in/bogdantorcescu/"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "llm-orchestrator",
3
3
  "description": "Write /task once — it plans the work, shards it across parallel subagents, gates every phase and verifies before claiming done. Claude Code, Codex, OpenCode, Kilo.",
4
- "version": "1.2.0",
4
+ "version": "1.2.2",
5
5
  "author": {
6
6
  "name": "Bogdan-Gabriel Torcescu",
7
7
  "url": "https://www.linkedin.com/in/bogdantorcescu/"
package/README.md CHANGED
@@ -1,6 +1,22 @@
1
1
  <!-- llm-orchestrator · created by Bogdan-Gabriel Torcescu · https://www.linkedin.com/in/bogdantorcescu/ · keep this credit when copying or deriving -->
2
2
  # llm-orchestrator
3
3
 
4
+ I got tired of babysitting long AI coding tasks — splitting work manually, keeping agents in sync, and checking whether “done” actually meant done.
5
+ So I built LLM Orchestrator.
6
+
7
+ Write /task once.
8
+ It:
9
+
10
+ → plans the work
11
+
12
+ → shards it across parallel subagents
13
+
14
+ → gates each phase
15
+
16
+ → verifies the result before claiming it’s done
17
+
18
+ Built for the kind of tasks where one agent and one context window simply aren’t enough.
19
+
4
20
  ## License
5
21
 
6
22
  [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). Use, copy, adapt and redistribute freely, including commercially, as long as you credit **Bogdan-Gabriel Torcescu** (https://www.linkedin.com/in/bogdantorcescu/), link the license, note your changes and keep the embedded attribution markers. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
@@ -348,6 +364,17 @@ more instructions:
348
364
  `flow.adherence`: runs, trivial declarations, tasks that skipped the flow, runs started outside
349
365
  it, runs opened without a PlanShard count, runs that planned several shards but started no
350
366
  subagents, and runs still open.
367
+ - Trivial is the narrow exception — a one-line change such as a typo or a version bump. A run
368
+ declared trivial that then edits a second file or touches tests gets one reminder to reopen it as
369
+ a typed run, and counts as `trivial_overreach` in the audit. Trivial runs close at the end of the
370
+ turn (`Stop` hook), so single-prompt sessions still reach the history.
371
+ - A run opened with two or more shards whose main thread keeps doing the work — six work calls, no
372
+ subagent started — gets one more sentence: dispatch the independent shards (searching for the
373
+ Agent tool if it is deferred), or declare the chain inline with
374
+ `run start --type <T> --shards <n> --inline "stateful:<what>"`. Work is inline only while it holds
375
+ live state a subagent cannot inherit (a browser mid-flow, an interactive shell); independent reads
376
+ are never inline. The audit adds `inline_declared` and `below_fan_out` (incident, investigation or
377
+ research runs opened with fewer than two shards).
351
378
  - Once the entrypoint is loaded, read-only discovery (reading files, `grep`, `git status`, tool
352
379
  version checks) before `run start` is SKILL.md steps 2–3, not a deviation. An edit, a write, a
353
380
  dispatch or any other shell command before the run is.
@@ -11,7 +11,7 @@ import { relative, isAbsolute } from 'node:path';
11
11
  /** Marks the hook entries this package owns inside a user's hooks JSON. */
12
12
  export const FLOW_MARKER = 'orchestrate-core:flow';
13
13
 
14
- export const FLOW_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'SubagentStart'];
14
+ export const FLOW_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'SubagentStart', 'Stop'];
15
15
 
16
16
  /** Spell the runtime path through $HOME when it lives there, so committed settings stay portable. */
17
17
  export function runtimeCliPath(runtimeRoot) {
@@ -164,6 +164,8 @@ export const OrchestrateFlow = async ({ directory }) => {
164
164
  parentOf.set(info.id, info.parentID);
165
165
  gate({ hook_event_name: "SubagentStart", session_id: info.parentID, agent_id: info.id });
166
166
  }
167
+ const idle = event?.properties?.sessionID;
168
+ if (event?.type === "session.idle" && idle && !parentOf.has(idle)) gate({ hook_event_name: "Stop", session_id: idle });
167
169
  } catch {}
168
170
  },
169
171
  "chat.message": async (input) => {
package/bin/run.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { parseRunArgs, TASK_TYPES } from '../lib/flow-gate.mjs';
11
11
 
12
- const USAGE = `Usage: llm-orchestrator run start --type <${TASK_TYPES.join('|')}> [--shards N]
12
+ const USAGE = `Usage: llm-orchestrator run start --type <${TASK_TYPES.join('|')}> [--shards N] [--inline "stateful:<what>"]
13
13
  llm-orchestrator run start --trivial "<reason>"
14
14
  llm-orchestrator run close`;
15
15
 
@@ -22,6 +22,6 @@ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
22
22
  process.stderr.write(`${USAGE}\n`);
23
23
  process.exitCode = 1;
24
24
  } else {
25
- process.stdout.write(`${JSON.stringify({ run: parsed.action, ...(parsed.action === 'start' ? { type: parsed.type, trivial: parsed.trivial, shards: parsed.shards, reason: parsed.reason } : {}) })}\n`);
25
+ process.stdout.write(`${JSON.stringify({ run: parsed.action, ...(parsed.action === 'start' ? { type: parsed.type, trivial: parsed.trivial, shards: parsed.shards, reason: parsed.reason, inline: parsed.inline } : {}) })}\n`);
26
26
  }
27
27
  }
package/hooks/hooks.json CHANGED
@@ -33,6 +33,17 @@
33
33
  }
34
34
  ]
35
35
  }
36
+ ],
37
+ "Stop": [
38
+ {
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/llm-orchestrator.mjs\" gate 2>/dev/null || true # orchestrate-core:flow",
43
+ "timeout": 3
44
+ }
45
+ ]
46
+ }
36
47
  ]
37
48
  }
38
49
  }
package/lib/flow-gate.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * tool inputs or file contents. `doctor` reads it back as the adherence audit.
15
15
  */
16
16
  import { createHash, randomUUID } from 'node:crypto';
17
- import { appendFile, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
17
+ import { appendFile, mkdir, readdir, readFile, rename, rmdir, stat, writeFile } from 'node:fs/promises';
18
18
  import { join } from 'node:path';
19
19
 
20
20
  /**
@@ -23,11 +23,47 @@ import { join } from 'node:path';
23
23
  * `llm-orchestrator` is not on PATH unless installed from npm.
24
24
  */
25
25
  export function nudgeFor(cli = 'llm-orchestrator') {
26
- return `No orchestrate-core run is open for this task. Classify it and plan before continuing (\`${cli} run start --type <TYPE>\`), or declare it trivial (\`${cli} run start --trivial "<reason>"\`).`;
26
+ return `No orchestrate-core run is open for this task. Classify it and plan before continuing (\`${cli} run start --type <TYPE> --shards <n>\`). Declare it trivial (\`${cli} run start --trivial "<reason>"\`) only for a one-line change such as a typo or a version bump — a bug fix with a regression test, or work across two files, is a typed run.`;
27
27
  }
28
28
 
29
29
  export const NUDGE = nudgeFor();
30
30
 
31
+ /**
32
+ * The second, and last, sentence the model can get per run: the plan has shards,
33
+ * none went to a subagent, and the main thread keeps doing the work itself.
34
+ */
35
+ export function dispatchNudgeFor(cli = 'llm-orchestrator', planned = 2) {
36
+ return `This run planned ${planned} shards and none has been dispatched to a subagent; the main thread is doing the work itself. Dispatch the independent shards (Claude Code: the Agent tool — search for it if it is deferred; Codex: spawn_agent; OpenCode/Kilo: task), or declare why this must stay inline (\`${cli} run start --type <TYPE> --inline "stateful:<what state>"\`).`;
37
+ }
38
+
39
+ /** A run declared trivial has outgrown the declaration. */
40
+ export function overreachNudgeFor(cli = 'llm-orchestrator', files = 2) {
41
+ return `This task was declared trivial, but it now touches ${files} files or its tests. Reopen it as a typed run (\`${cli} run start --type <TYPE> --shards <n>\`) so it is classified, planned and verified like one.`;
42
+ }
43
+
44
+ // Edit-shaped tools, per harness. Only a short hash of each path is kept.
45
+ const EDIT_TOOLS = new Set(['edit', 'write', 'multiedit', 'notebookedit', 'str_replace_based_edit_tool', 'apply_patch', 'patch']);
46
+ const TEST_PATH = /(^|\/)(test|tests|__tests__|spec|specs)\/|[._-](test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.py$|_spec\.rb$/i;
47
+ const PATCH_FILE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
48
+
49
+ function editedPaths(toolName, input) {
50
+ if (!EDIT_TOOLS.has(toolName.toLowerCase())) return [];
51
+ const command = commandOf(input);
52
+ if (command && command.includes('*** Begin Patch')) return [...command.matchAll(PATCH_FILE)].map((match) => match[1].trim());
53
+ const path = filePathOf(input);
54
+ return path ? [path] : [];
55
+ }
56
+
57
+ function pathHash(path) {
58
+ return createHash('sha256').update(path).digest('hex').slice(0, 12);
59
+ }
60
+
61
+ // Main-thread work calls tolerated after run start before the dispatch nudge.
62
+ export const DISPATCH_THRESHOLD = 6;
63
+
64
+ // Evidence-gathering flows: their first phase fans out across independent sources.
65
+ const EVIDENCE_TYPES = new Set(['INCIDENT', 'INVESTIGATION', 'RESEARCH']);
66
+
31
67
  export const TASK_TYPES = ['INCIDENT', 'FEATURE', 'BUG_FIX', 'REFACTOR', 'INVESTIGATION', 'DEPLOY', 'CONFIG', 'REVIEW', 'RESEARCH'];
32
68
 
33
69
  export const LEDGER_DIRECTORY = '.orchestrator-run';
@@ -46,7 +82,7 @@ const RUN_COMMAND = /(^|[\s/"'])llm-orchestrator(?:\.mjs)?["']?\s+run\s+(start|c
46
82
  // come before the run is opened at step 4. Once the entrypoint is loaded, these do not
47
83
  // count as starting work; edits, writes, dispatches and other shell commands still do.
48
84
  const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob', 'ls', 'view', 'list', 'notebookread', 'webfetch', 'websearch']);
49
- const READ_ONLY_COMMAND = /^\s*(rtk\s+)?(cat|head|tail|less|bat|ls|tree|find|grep|rg|ag|wc|file|stat|pwd|which|type|command\s+-v|git\s+(status|diff|log|show|ls-files|branch|rev-parse|remote))(\s|$)|--version\b/;
85
+ const READ_ONLY_COMMAND = /^\s*(rtk\s+)?(cat|head|tail|less|bat|ls|tree|find|grep|rg|ag|wc|file|stat|pwd|which|type|echo|printf|sort|uniq|command\s+-v|git\s+(status|diff|log|show|ls-files|branch|rev-parse|remote))(\s|$)|--version\b/;
50
86
  const LOAD_SKILL = /(^|\/)orchestrate(-core)?(\/SKILL\.md)?$/;
51
87
 
52
88
  /** Every segment of a compound command reads; nothing is redirected into a file. */
@@ -122,16 +158,18 @@ export function parseRunArgs(args) {
122
158
  let type = null;
123
159
  let shards = null;
124
160
  let trivial = null;
161
+ let inline = null;
125
162
  for (let index = 0; index < rest.length; index += 1) {
126
163
  const flag = rest[index];
127
164
  const value = rest[index + 1];
128
165
  if (flag === '--type' && value) { type = value.toUpperCase(); index += 1; }
129
166
  else if (flag === '--shards' && value) { shards = Number.parseInt(value, 10); index += 1; }
130
167
  else if (flag === '--trivial' && value !== undefined) { trivial = value.slice(0, MAX_REASON); index += 1; }
168
+ else if (flag === '--inline' && value) { inline = value.slice(0, MAX_REASON); index += 1; }
131
169
  }
132
- if (trivial !== null) return { action: 'start', trivial: true, reason: trivial || null, type: null, shards: null };
170
+ if (trivial !== null) return { action: 'start', trivial: true, reason: trivial || null, type: null, shards: null, inline: null };
133
171
  if (!TASK_TYPES.includes(type)) return null;
134
- return { action: 'start', trivial: false, reason: null, type, shards: Number.isInteger(shards) && shards > 0 ? shards : null };
172
+ return { action: 'start', trivial: false, reason: null, type, shards: Number.isInteger(shards) && shards > 0 ? shards : null, inline };
135
173
  }
136
174
 
137
175
  function keyOf(eventName, value) {
@@ -151,6 +189,7 @@ export function normalizePayload(raw) {
151
189
  const id = (value) => (typeof value === 'string' && value ? value : null);
152
190
  if (eventName === 'UserPromptSubmit') return { kind: 'prompt', session, isSubagent, key: keyOf(eventName, id(payload.prompt_id) ?? id(payload.turn_id)) };
153
191
  if (eventName === 'SubagentStart') return { kind: 'subagent', session, isSubagent, key: keyOf(eventName, id(payload.agent_id)) };
192
+ if (eventName === 'Stop') return { kind: 'stop', session, isSubagent };
154
193
  if (eventName !== 'PreToolUse') return { kind: 'other', session, isSubagent };
155
194
 
156
195
  const toolName = String(payload.tool_name ?? '');
@@ -174,6 +213,7 @@ export function normalizePayload(raw) {
174
213
  nonWork: NON_WORK_TOOLS.has(toolName.toLowerCase()),
175
214
  loadsEntrypoint,
176
215
  readOnly,
216
+ edits: editedPaths(toolName, input).map((editedPath) => ({ hash: pathHash(editedPath), test: TEST_PATH.test(editedPath) })),
177
217
  };
178
218
  }
179
219
 
@@ -196,6 +236,9 @@ function historyLine(session, fields, now) {
196
236
  subagents_started: fields.subagents_started ?? 0,
197
237
  started_outside_flow: Boolean(fields.started_outside_flow),
198
238
  skipped_flow: Boolean(fields.skipped_flow),
239
+ inline_reason: fields.inline_reason ?? null,
240
+ dispatch_nudged: Boolean(fields.dispatch_nudged),
241
+ overreach: Boolean(fields.overreach_nudged),
199
242
  closed_by: fields.closed_by,
200
243
  };
201
244
  }
@@ -206,7 +249,7 @@ function closeRun(session, now, closedBy) {
206
249
 
207
250
  /**
208
251
  * Pure state transition: (session, event, now) → { session, output, history }.
209
- * `output` is either null or `{ additionalContext }`; it has no other shape.
252
+ * `output` is null or `{ additionalContext, kind }` a steering sentence, never a decision.
210
253
  */
211
254
  export function decide(previous, event, now) {
212
255
  const session = structuredClone(previous);
@@ -240,6 +283,15 @@ export function decide(previous, event, now) {
240
283
  return { session, output, history };
241
284
  }
242
285
 
286
+ if (event.kind === 'stop') {
287
+ // A trivial run lasts one turn; closing it here keeps single-prompt sessions in history.
288
+ if (session.run?.trivial && !event.isSubagent) {
289
+ history.push(closeRun(session, now, 'turn_end'));
290
+ session.run = null;
291
+ }
292
+ return { session, output, history };
293
+ }
294
+
243
295
  if (event.kind === 'subagent') {
244
296
  if (session.run) session.run.subagents_started += 1;
245
297
  else session.subagents_without_run += 1;
@@ -259,6 +311,12 @@ export function decide(previous, event, now) {
259
311
  planned_shards: event.run.shards,
260
312
  subagents_started: 0,
261
313
  started_outside_flow: session.worked_without_run,
314
+ inline_reason: event.run.inline,
315
+ main_work_calls: 0,
316
+ dispatch_nudged: false,
317
+ edited_files: [],
318
+ touched_tests: false,
319
+ overreach_nudged: false,
262
320
  };
263
321
  // The work already done is accounted for on the run itself now.
264
322
  session.worked_without_run = false;
@@ -271,7 +329,26 @@ export function decide(previous, event, now) {
271
329
  }
272
330
 
273
331
  if (event.loadsEntrypoint) session.entrypoint_loaded = true;
274
- if (event.isSubagent || event.instruction || event.orchestratorCall || event.nonWork || event.loadsEntrypoint || session.run) {
332
+ if (event.isSubagent || event.instruction || event.orchestratorCall || event.nonWork || event.loadsEntrypoint) {
333
+ return { session, output, history };
334
+ }
335
+ if (session.run) {
336
+ const current = session.run;
337
+ current.main_work_calls = (current.main_work_calls ?? 0) + 1;
338
+ if (current.trivial) {
339
+ current.edited_files = [...new Set([...(current.edited_files ?? []), ...event.edits.map((entry) => entry.hash)])];
340
+ current.touched_tests = Boolean(current.touched_tests) || event.edits.some((entry) => entry.test);
341
+ if (!current.overreach_nudged && (current.edited_files.length >= 2 || current.touched_tests)) {
342
+ current.overreach_nudged = true;
343
+ output = { additionalContext: overreachNudgeFor(undefined, current.edited_files.length), kind: 'overreach', files: current.edited_files.length };
344
+ }
345
+ return { session, output, history };
346
+ }
347
+ if ((current.planned_shards ?? 0) >= 2 && current.subagents_started === 0 && !current.inline_reason
348
+ && !current.dispatch_nudged && current.main_work_calls >= DISPATCH_THRESHOLD) {
349
+ current.dispatch_nudged = true;
350
+ output = { additionalContext: dispatchNudgeFor(undefined, current.planned_shards), kind: 'dispatch', planned: current.planned_shards };
351
+ }
275
352
  return { session, output, history };
276
353
  }
277
354
  // Following the entrypoint: discovery reads before the run opens are step 2–3, not a skip.
@@ -280,7 +357,7 @@ export function decide(previous, event, now) {
280
357
  session.worked_without_run = true;
281
358
  if (!session.nudged) {
282
359
  session.nudged = true;
283
- output = { additionalContext: NUDGE };
360
+ output = { additionalContext: NUDGE, kind: 'start' };
284
361
  }
285
362
  return { session, output, history };
286
363
  }
@@ -326,6 +403,40 @@ export async function projectUsesOrchestrator(project) {
326
403
  return false;
327
404
  }
328
405
 
406
+ const LOCK_WAIT_MS = 2000;
407
+ const LOCK_STALE_MS = 5000;
408
+
409
+ /**
410
+ * Claude Code runs matching hooks in parallel, so a plugin install and a CLI
411
+ * install both handle every event at the same moment. Serialise per session with
412
+ * an atomic mkdir lock; a lock older than LOCK_STALE_MS belongs to a crashed
413
+ * handler and is taken over. Failing to lock in time throws — the gate fails open.
414
+ */
415
+ async function withSessionLock(path, work) {
416
+ const lock = `${path}.lock`;
417
+ const deadline = Date.now() + LOCK_WAIT_MS;
418
+ while (true) {
419
+ try {
420
+ await mkdir(lock);
421
+ break;
422
+ } catch (error) {
423
+ if (error.code !== 'EEXIST') throw error;
424
+ const held = await stat(lock).then((info) => Date.now() - info.mtimeMs).catch(() => 0);
425
+ if (held > LOCK_STALE_MS) {
426
+ await rmdir(lock).catch(() => {});
427
+ continue;
428
+ }
429
+ if (Date.now() > deadline) throw new Error('ledger session is locked');
430
+ await new Promise((resolve) => setTimeout(resolve, 5 + Math.random() * 20));
431
+ }
432
+ }
433
+ try {
434
+ return await work();
435
+ } finally {
436
+ await rmdir(lock).catch(() => {});
437
+ }
438
+ }
439
+
329
440
  export async function handleHook({ payload, project, now = Date.now(), cli = 'llm-orchestrator' }) {
330
441
  const event = normalizePayload(payload);
331
442
  if (event.kind === 'other') return null;
@@ -333,22 +444,29 @@ export async function handleHook({ payload, project, now = Date.now(), cli = 'll
333
444
  if (!event.run && !(await projectUsesOrchestrator(project))) return null;
334
445
  const directory = await ensureLedger(project);
335
446
  const path = join(directory, 'sessions', sessionFileName(event.session));
336
- let session;
337
- try {
338
- session = JSON.parse(await readFile(path, 'utf8'));
339
- } catch (error) {
340
- if (error.code !== 'ENOENT') throw error;
341
- session = emptySession(event.session);
342
- }
343
- const result = decide(session, event, now);
344
- const temporary = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
345
- await writeFile(temporary, `${JSON.stringify(result.session)}\n`);
346
- await rename(temporary, path);
347
- if (result.history.length > 0) {
348
- await appendFile(join(directory, 'history.jsonl'), result.history.map((line) => `${JSON.stringify(line)}\n`).join(''));
349
- }
447
+ const result = await withSessionLock(path, async () => {
448
+ let session;
449
+ try {
450
+ session = JSON.parse(await readFile(path, 'utf8'));
451
+ } catch (error) {
452
+ if (error.code !== 'ENOENT') throw error;
453
+ session = emptySession(event.session);
454
+ }
455
+ const next = decide(session, event, now);
456
+ const temporary = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
457
+ await writeFile(temporary, `${JSON.stringify(next.session)}\n`);
458
+ await rename(temporary, path);
459
+ if (next.history.length > 0) {
460
+ await appendFile(join(directory, 'history.jsonl'), next.history.map((line) => `${JSON.stringify(line)}\n`).join(''));
461
+ }
462
+ return next;
463
+ });
350
464
  if (!result.output) return null;
351
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: result.output.additionalContext === NUDGE ? nudgeFor(cli) : result.output.additionalContext } };
465
+ // decide() speaks in the default CLI spelling; the hook swaps in the runnable path.
466
+ const text = result.output.kind === 'dispatch' ? dispatchNudgeFor(cli, result.output.planned)
467
+ : result.output.kind === 'overreach' ? overreachNudgeFor(cli, result.output.files)
468
+ : nudgeFor(cli);
469
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text } };
352
470
  }
353
471
 
354
472
  export async function readHistory(project) {
@@ -391,5 +509,8 @@ export function adherenceSummary(lines) {
391
509
  started_outside_flow: lines.filter((line) => line.started_outside_flow).length,
392
510
  planned_but_not_dispatched: lines.filter((line) => (line.planned_shards ?? 0) > 1 && line.subagents_started === 0).length,
393
511
  runs_without_plan: lines.filter((line) => !line.skipped_flow && !line.trivial && line.planned_shards === null).length,
512
+ inline_declared: lines.filter((line) => Boolean(line.inline_reason)).length,
513
+ trivial_overreach: lines.filter((line) => line.trivial && line.overreach).length,
514
+ below_fan_out: lines.filter((line) => EVIDENCE_TYPES.has(line.task_type) && !line.inline_reason && (line.planned_shards ?? 0) < 2).length,
394
515
  };
395
516
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-orchestrator",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Write /task once — it plans the work, shards it across parallel subagents, gates every phase and verifies before claiming done. Claude Code, Codex, OpenCode, Kilo.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -282,3 +282,24 @@ Parallel work requires disjoint ownership and a real critical-path reduction; do
282
282
  beyond the minimums to satisfy an appearance of breadth, and do not fall below them when independent
283
283
  scopes exist. A serial fallback is valid only where no required independence is lost — a reviewer's
284
284
  independence is never negotiable.
285
+
286
+ ### A shard ends where state ends
287
+
288
+ "No independent work exists" is the claim that most often excuses skipping dispatch, so it has a
289
+ definition. Work is **inline** only while it holds live state a fresh subagent cannot inherit — a
290
+ browser session mid-flow (cookies, a half-filled form, an open modal), an interactive SSH shell with
291
+ context, a REPL, a booted simulator — **and** each step depends on the result of the one before.
292
+ Everything around that chain is still sharded:
293
+
294
+ - Independent reads are never inline: log, metric and database queries, code search, config
295
+ lookups. An SSH query that only reads is a stateless command, not a session — five of them are
296
+ five W-tier evidence shards, not one inline chain.
297
+ - "Cheaper inline" is not a reason. The main thread runs at the flow's highest tier; the same reads
298
+ on a W-tier subagent cost less per token and keep the orchestrator's context for synthesis.
299
+ - A tool that is not visible is not absent. On Claude Code the Agent tool can be deferred — search
300
+ for it (`tool.discovery`) before concluding dispatch is unavailable.
301
+ - Declare the inline chain when opening the run: `run start --type <T> --shards <n> --inline
302
+ "stateful:<what state>"`. Undeclared, a planned multi-shard run whose main thread keeps working
303
+ with no subagent started gets one reminder from the flow hooks, and the audit counts it.
304
+ - Name the real seam you will split at, e.g. iOS simulator vs Android emulator: independent devices
305
+ with independent state are parallel shards even when each one is inline inside.
package/protocol.md CHANGED
@@ -36,9 +36,17 @@ can target it.
36
36
  No dispatch, edit or shell command may precede this object. Opening the run
37
37
  (`llm-orchestrator run start --type <TASK_TYPE>`) follows it immediately.
38
38
 
39
+ **Inline shards.** A run whose shards must stay in the main thread because they hold live state
40
+ (a browser session, an interactive shell, a simulator) says so when it opens:
41
+ `run start --type <T> --shards <n> --inline "stateful:<what>"`. Independent reads around that state
42
+ are still dispatched — see "A shard ends where state ends" in [dispatch](policies/dispatch.md).
43
+
39
44
  **Trivial tasks.** A one-line, obviously scoped change (a typo, a version bump) may skip the full
40
45
  flow, but only by declaring it: `llm-orchestrator run start --trivial "<reason>"`. The
41
- declaration and its reason are recorded; an undeclared skip is recorded as a skipped flow.
46
+ declaration and its reason are recorded; an undeclared skip is recorded as a skipped flow. A bug
47
+ fix that needs a regression test, or any change across two or more files, is **not** trivial — it
48
+ is a typed run. A trivial run that grows past that line gets one reminder to reopen it as a typed
49
+ run, and the audit counts it as `trivial_overreach`. A trivial run lasts one turn.
42
50
 
43
51
  ```json
44
52
  {
@@ -8,7 +8,7 @@
8
8
  "run": {
9
9
  "type": "object",
10
10
  "additionalProperties": false,
11
- "required": ["task_id", "task_type", "trivial", "reason", "opened_at", "planned_shards", "subagents_started", "started_outside_flow"],
11
+ "required": ["task_id", "task_type", "trivial", "reason", "opened_at", "planned_shards", "subagents_started", "started_outside_flow", "inline_reason", "main_work_calls", "dispatch_nudged", "edited_files", "touched_tests", "overreach_nudged"],
12
12
  "properties": {
13
13
  "task_id": { "type": "string", "minLength": 1 },
14
14
  "task_type": { "type": ["string", "null"], "enum": ["INCIDENT", "FEATURE", "BUG_FIX", "REFACTOR", "INVESTIGATION", "DEPLOY", "CONFIG", "REVIEW", "RESEARCH", null] },
@@ -17,7 +17,13 @@
17
17
  "opened_at": { "type": "number" },
18
18
  "planned_shards": { "type": ["integer", "null"], "minimum": 1 },
19
19
  "subagents_started": { "type": "integer", "minimum": 0 },
20
- "started_outside_flow": { "type": "boolean" }
20
+ "started_outside_flow": { "type": "boolean" },
21
+ "inline_reason": { "type": ["string", "null"], "description": "Why the shards stay inline, e.g. stateful:browser — a live state no subagent can inherit." },
22
+ "main_work_calls": { "type": "integer", "minimum": 0 },
23
+ "dispatch_nudged": { "type": "boolean" },
24
+ "edited_files": { "type": "array", "items": { "type": "string", "pattern": "^[0-9a-f]{12}$" }, "description": "Short hashes of edited paths — never the paths themselves." },
25
+ "touched_tests": { "type": "boolean" },
26
+ "overreach_nudged": { "type": "boolean" }
21
27
  }
22
28
  },
23
29
  "session": {
@@ -39,7 +45,7 @@
39
45
  "historyLine": {
40
46
  "type": "object",
41
47
  "additionalProperties": false,
42
- "required": ["session", "task_id", "task_type", "trivial", "reason", "opened_at", "closed_at", "duration_s", "planned_shards", "subagents_started", "started_outside_flow", "skipped_flow", "closed_by"],
48
+ "required": ["session", "task_id", "task_type", "trivial", "reason", "opened_at", "closed_at", "duration_s", "planned_shards", "subagents_started", "started_outside_flow", "skipped_flow", "inline_reason", "dispatch_nudged", "overreach", "closed_by"],
43
49
  "properties": {
44
50
  "session": { "type": "string", "minLength": 1 },
45
51
  "task_id": { "type": ["string", "null"] },
@@ -53,7 +59,10 @@
53
59
  "subagents_started": { "type": "integer", "minimum": 0 },
54
60
  "started_outside_flow": { "type": "boolean" },
55
61
  "skipped_flow": { "type": "boolean" },
56
- "closed_by": { "enum": ["run_close", "next_prompt", "succession"] }
62
+ "inline_reason": { "type": ["string", "null"] },
63
+ "dispatch_nudged": { "type": "boolean" },
64
+ "overreach": { "type": "boolean", "description": "A trivial run that edited two or more files or its tests." },
65
+ "closed_by": { "enum": ["run_close", "next_prompt", "succession", "turn_end"] }
57
66
  }
58
67
  }
59
68
  }