llm-orchestrator 1.2.0 → 1.2.1

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.1",
5
5
  "author": {
6
6
  "name": "Bogdan-Gabriel Torcescu",
7
7
  "url": "https://www.linkedin.com/in/bogdantorcescu/"
package/README.md CHANGED
@@ -348,6 +348,13 @@ more instructions:
348
348
  `flow.adherence`: runs, trivial declarations, tasks that skipped the flow, runs started outside
349
349
  it, runs opened without a PlanShard count, runs that planned several shards but started no
350
350
  subagents, and runs still open.
351
+ - A run opened with two or more shards whose main thread keeps doing the work — six work calls, no
352
+ subagent started — gets one more sentence: dispatch the independent shards (searching for the
353
+ Agent tool if it is deferred), or declare the chain inline with
354
+ `run start --type <T> --shards <n> --inline "stateful:<what>"`. Work is inline only while it holds
355
+ live state a subagent cannot inherit (a browser mid-flow, an interactive shell); independent reads
356
+ are never inline. The audit adds `inline_declared` and `below_fan_out` (incident, investigation or
357
+ research runs opened with fewer than two shards).
351
358
  - Once the entrypoint is loaded, read-only discovery (reading files, `grep`, `git status`, tool
352
359
  version checks) before `run start` is SKILL.md steps 2–3, not a deviation. An edit, a write, a
353
360
  dispatch or any other shell command before the run is.
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/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
  /**
@@ -28,6 +28,20 @@ export function nudgeFor(cli = 'llm-orchestrator') {
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
+ // Main-thread work calls tolerated after run start before the dispatch nudge.
40
+ export const DISPATCH_THRESHOLD = 6;
41
+
42
+ // Evidence-gathering flows: their first phase fans out across independent sources.
43
+ const EVIDENCE_TYPES = new Set(['INCIDENT', 'INVESTIGATION', 'RESEARCH']);
44
+
31
45
  export const TASK_TYPES = ['INCIDENT', 'FEATURE', 'BUG_FIX', 'REFACTOR', 'INVESTIGATION', 'DEPLOY', 'CONFIG', 'REVIEW', 'RESEARCH'];
32
46
 
33
47
  export const LEDGER_DIRECTORY = '.orchestrator-run';
@@ -46,7 +60,7 @@ const RUN_COMMAND = /(^|[\s/"'])llm-orchestrator(?:\.mjs)?["']?\s+run\s+(start|c
46
60
  // come before the run is opened at step 4. Once the entrypoint is loaded, these do not
47
61
  // count as starting work; edits, writes, dispatches and other shell commands still do.
48
62
  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/;
63
+ 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
64
  const LOAD_SKILL = /(^|\/)orchestrate(-core)?(\/SKILL\.md)?$/;
51
65
 
52
66
  /** Every segment of a compound command reads; nothing is redirected into a file. */
@@ -122,16 +136,18 @@ export function parseRunArgs(args) {
122
136
  let type = null;
123
137
  let shards = null;
124
138
  let trivial = null;
139
+ let inline = null;
125
140
  for (let index = 0; index < rest.length; index += 1) {
126
141
  const flag = rest[index];
127
142
  const value = rest[index + 1];
128
143
  if (flag === '--type' && value) { type = value.toUpperCase(); index += 1; }
129
144
  else if (flag === '--shards' && value) { shards = Number.parseInt(value, 10); index += 1; }
130
145
  else if (flag === '--trivial' && value !== undefined) { trivial = value.slice(0, MAX_REASON); index += 1; }
146
+ else if (flag === '--inline' && value) { inline = value.slice(0, MAX_REASON); index += 1; }
131
147
  }
132
- if (trivial !== null) return { action: 'start', trivial: true, reason: trivial || null, type: null, shards: null };
148
+ if (trivial !== null) return { action: 'start', trivial: true, reason: trivial || null, type: null, shards: null, inline: null };
133
149
  if (!TASK_TYPES.includes(type)) return null;
134
- return { action: 'start', trivial: false, reason: null, type, shards: Number.isInteger(shards) && shards > 0 ? shards : null };
150
+ return { action: 'start', trivial: false, reason: null, type, shards: Number.isInteger(shards) && shards > 0 ? shards : null, inline };
135
151
  }
136
152
 
137
153
  function keyOf(eventName, value) {
@@ -196,6 +212,8 @@ function historyLine(session, fields, now) {
196
212
  subagents_started: fields.subagents_started ?? 0,
197
213
  started_outside_flow: Boolean(fields.started_outside_flow),
198
214
  skipped_flow: Boolean(fields.skipped_flow),
215
+ inline_reason: fields.inline_reason ?? null,
216
+ dispatch_nudged: Boolean(fields.dispatch_nudged),
199
217
  closed_by: fields.closed_by,
200
218
  };
201
219
  }
@@ -206,7 +224,7 @@ function closeRun(session, now, closedBy) {
206
224
 
207
225
  /**
208
226
  * Pure state transition: (session, event, now) → { session, output, history }.
209
- * `output` is either null or `{ additionalContext }`; it has no other shape.
227
+ * `output` is null or `{ additionalContext, kind }` a steering sentence, never a decision.
210
228
  */
211
229
  export function decide(previous, event, now) {
212
230
  const session = structuredClone(previous);
@@ -259,6 +277,9 @@ export function decide(previous, event, now) {
259
277
  planned_shards: event.run.shards,
260
278
  subagents_started: 0,
261
279
  started_outside_flow: session.worked_without_run,
280
+ inline_reason: event.run.inline,
281
+ main_work_calls: 0,
282
+ dispatch_nudged: false,
262
283
  };
263
284
  // The work already done is accounted for on the run itself now.
264
285
  session.worked_without_run = false;
@@ -271,7 +292,17 @@ export function decide(previous, event, now) {
271
292
  }
272
293
 
273
294
  if (event.loadsEntrypoint) session.entrypoint_loaded = true;
274
- if (event.isSubagent || event.instruction || event.orchestratorCall || event.nonWork || event.loadsEntrypoint || session.run) {
295
+ if (event.isSubagent || event.instruction || event.orchestratorCall || event.nonWork || event.loadsEntrypoint) {
296
+ return { session, output, history };
297
+ }
298
+ if (session.run) {
299
+ const current = session.run;
300
+ current.main_work_calls = (current.main_work_calls ?? 0) + 1;
301
+ if ((current.planned_shards ?? 0) >= 2 && current.subagents_started === 0 && !current.inline_reason
302
+ && !current.dispatch_nudged && current.main_work_calls >= DISPATCH_THRESHOLD) {
303
+ current.dispatch_nudged = true;
304
+ output = { additionalContext: dispatchNudgeFor(undefined, current.planned_shards), kind: 'dispatch', planned: current.planned_shards };
305
+ }
275
306
  return { session, output, history };
276
307
  }
277
308
  // Following the entrypoint: discovery reads before the run opens are step 2–3, not a skip.
@@ -280,7 +311,7 @@ export function decide(previous, event, now) {
280
311
  session.worked_without_run = true;
281
312
  if (!session.nudged) {
282
313
  session.nudged = true;
283
- output = { additionalContext: NUDGE };
314
+ output = { additionalContext: NUDGE, kind: 'start' };
284
315
  }
285
316
  return { session, output, history };
286
317
  }
@@ -326,6 +357,40 @@ export async function projectUsesOrchestrator(project) {
326
357
  return false;
327
358
  }
328
359
 
360
+ const LOCK_WAIT_MS = 2000;
361
+ const LOCK_STALE_MS = 5000;
362
+
363
+ /**
364
+ * Claude Code runs matching hooks in parallel, so a plugin install and a CLI
365
+ * install both handle every event at the same moment. Serialise per session with
366
+ * an atomic mkdir lock; a lock older than LOCK_STALE_MS belongs to a crashed
367
+ * handler and is taken over. Failing to lock in time throws — the gate fails open.
368
+ */
369
+ async function withSessionLock(path, work) {
370
+ const lock = `${path}.lock`;
371
+ const deadline = Date.now() + LOCK_WAIT_MS;
372
+ while (true) {
373
+ try {
374
+ await mkdir(lock);
375
+ break;
376
+ } catch (error) {
377
+ if (error.code !== 'EEXIST') throw error;
378
+ const held = await stat(lock).then((info) => Date.now() - info.mtimeMs).catch(() => 0);
379
+ if (held > LOCK_STALE_MS) {
380
+ await rmdir(lock).catch(() => {});
381
+ continue;
382
+ }
383
+ if (Date.now() > deadline) throw new Error('ledger session is locked');
384
+ await new Promise((resolve) => setTimeout(resolve, 5 + Math.random() * 20));
385
+ }
386
+ }
387
+ try {
388
+ return await work();
389
+ } finally {
390
+ await rmdir(lock).catch(() => {});
391
+ }
392
+ }
393
+
329
394
  export async function handleHook({ payload, project, now = Date.now(), cli = 'llm-orchestrator' }) {
330
395
  const event = normalizePayload(payload);
331
396
  if (event.kind === 'other') return null;
@@ -333,22 +398,27 @@ export async function handleHook({ payload, project, now = Date.now(), cli = 'll
333
398
  if (!event.run && !(await projectUsesOrchestrator(project))) return null;
334
399
  const directory = await ensureLedger(project);
335
400
  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
- }
401
+ const result = await withSessionLock(path, async () => {
402
+ let session;
403
+ try {
404
+ session = JSON.parse(await readFile(path, 'utf8'));
405
+ } catch (error) {
406
+ if (error.code !== 'ENOENT') throw error;
407
+ session = emptySession(event.session);
408
+ }
409
+ const next = decide(session, event, now);
410
+ const temporary = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
411
+ await writeFile(temporary, `${JSON.stringify(next.session)}\n`);
412
+ await rename(temporary, path);
413
+ if (next.history.length > 0) {
414
+ await appendFile(join(directory, 'history.jsonl'), next.history.map((line) => `${JSON.stringify(line)}\n`).join(''));
415
+ }
416
+ return next;
417
+ });
350
418
  if (!result.output) return null;
351
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: result.output.additionalContext === NUDGE ? nudgeFor(cli) : result.output.additionalContext } };
419
+ // decide() speaks in the default CLI spelling; the hook swaps in the runnable path.
420
+ const text = result.output.kind === 'dispatch' ? dispatchNudgeFor(cli, result.output.planned) : nudgeFor(cli);
421
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text } };
352
422
  }
353
423
 
354
424
  export async function readHistory(project) {
@@ -391,5 +461,7 @@ export function adherenceSummary(lines) {
391
461
  started_outside_flow: lines.filter((line) => line.started_outside_flow).length,
392
462
  planned_but_not_dispatched: lines.filter((line) => (line.planned_shards ?? 0) > 1 && line.subagents_started === 0).length,
393
463
  runs_without_plan: lines.filter((line) => !line.skipped_flow && !line.trivial && line.planned_shards === null).length,
464
+ inline_declared: lines.filter((line) => Boolean(line.inline_reason)).length,
465
+ below_fan_out: lines.filter((line) => EVIDENCE_TYPES.has(line.task_type) && !line.inline_reason && (line.planned_shards ?? 0) < 2).length,
394
466
  };
395
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-orchestrator",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
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,6 +36,11 @@ 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
46
  declaration and its reason are recorded; an undeclared skip is recorded as a skipped flow.
@@ -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"],
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,10 @@
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" }
21
24
  }
22
25
  },
23
26
  "session": {
@@ -39,7 +42,7 @@
39
42
  "historyLine": {
40
43
  "type": "object",
41
44
  "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"],
45
+ "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", "closed_by"],
43
46
  "properties": {
44
47
  "session": { "type": "string", "minLength": 1 },
45
48
  "task_id": { "type": ["string", "null"] },
@@ -53,6 +56,8 @@
53
56
  "subagents_started": { "type": "integer", "minimum": 0 },
54
57
  "started_outside_flow": { "type": "boolean" },
55
58
  "skipped_flow": { "type": "boolean" },
59
+ "inline_reason": { "type": ["string", "null"] },
60
+ "dispatch_nudged": { "type": "boolean" },
56
61
  "closed_by": { "enum": ["run_close", "next_prompt", "succession"] }
57
62
  }
58
63
  }