llm-orchestrator 1.2.1 → 1.2.3

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,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.1",
4
+ "version": "1.2.3",
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,10 @@ 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.
351
371
  - A run opened with two or more shards whose main thread keeps doing the work — six work calls, no
352
372
  subagent started — gets one more sentence: dispatch the independent shards (searching for the
353
373
  Agent tool if it is deferred), or declare the chain inline with
@@ -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/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, rmdir, stat, writeFile } from 'node:fs/promises';
17
+ import { appendFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
18
18
  import { join } from 'node:path';
19
19
 
20
20
  /**
@@ -23,7 +23,7 @@ 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();
@@ -36,6 +36,28 @@ export function dispatchNudgeFor(cli = 'llm-orchestrator', planned = 2) {
36
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
37
  }
38
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
+
39
61
  // Main-thread work calls tolerated after run start before the dispatch nudge.
40
62
  export const DISPATCH_THRESHOLD = 6;
41
63
 
@@ -167,6 +189,7 @@ export function normalizePayload(raw) {
167
189
  const id = (value) => (typeof value === 'string' && value ? value : null);
168
190
  if (eventName === 'UserPromptSubmit') return { kind: 'prompt', session, isSubagent, key: keyOf(eventName, id(payload.prompt_id) ?? id(payload.turn_id)) };
169
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 };
170
193
  if (eventName !== 'PreToolUse') return { kind: 'other', session, isSubagent };
171
194
 
172
195
  const toolName = String(payload.tool_name ?? '');
@@ -190,6 +213,7 @@ export function normalizePayload(raw) {
190
213
  nonWork: NON_WORK_TOOLS.has(toolName.toLowerCase()),
191
214
  loadsEntrypoint,
192
215
  readOnly,
216
+ edits: editedPaths(toolName, input).map((editedPath) => ({ hash: pathHash(editedPath), test: TEST_PATH.test(editedPath) })),
193
217
  };
194
218
  }
195
219
 
@@ -214,6 +238,7 @@ function historyLine(session, fields, now) {
214
238
  skipped_flow: Boolean(fields.skipped_flow),
215
239
  inline_reason: fields.inline_reason ?? null,
216
240
  dispatch_nudged: Boolean(fields.dispatch_nudged),
241
+ overreach: Boolean(fields.overreach_nudged),
217
242
  closed_by: fields.closed_by,
218
243
  };
219
244
  }
@@ -258,6 +283,15 @@ export function decide(previous, event, now) {
258
283
  return { session, output, history };
259
284
  }
260
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
+
261
295
  if (event.kind === 'subagent') {
262
296
  if (session.run) session.run.subagents_started += 1;
263
297
  else session.subagents_without_run += 1;
@@ -280,6 +314,9 @@ export function decide(previous, event, now) {
280
314
  inline_reason: event.run.inline,
281
315
  main_work_calls: 0,
282
316
  dispatch_nudged: false,
317
+ edited_files: [],
318
+ touched_tests: false,
319
+ overreach_nudged: false,
283
320
  };
284
321
  // The work already done is accounted for on the run itself now.
285
322
  session.worked_without_run = false;
@@ -298,6 +335,15 @@ export function decide(previous, event, now) {
298
335
  if (session.run) {
299
336
  const current = session.run;
300
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
+ }
301
347
  if ((current.planned_shards ?? 0) >= 2 && current.subagents_started === 0 && !current.inline_reason
302
348
  && !current.dispatch_nudged && current.main_work_calls >= DISPATCH_THRESHOLD) {
303
349
  current.dispatch_nudged = true;
@@ -366,18 +412,37 @@ const LOCK_STALE_MS = 5000;
366
412
  * an atomic mkdir lock; a lock older than LOCK_STALE_MS belongs to a crashed
367
413
  * handler and is taken over. Failing to lock in time throws — the gate fails open.
368
414
  */
415
+ /** A lock whose recorded owner process no longer exists was left by a killed handler. */
416
+ async function ownerIsDead(lock) {
417
+ try {
418
+ const pid = Number.parseInt(await readFile(join(lock, 'owner'), 'utf8'), 10);
419
+ if (!Number.isInteger(pid) || pid <= 0) return false;
420
+ process.kill(pid, 0);
421
+ return false;
422
+ } catch (error) {
423
+ return error.code === 'ESRCH';
424
+ }
425
+ }
426
+
427
+ async function releaseLock(lock) {
428
+ await rm(lock, { recursive: true, force: true }).catch(() => {});
429
+ }
430
+
369
431
  async function withSessionLock(path, work) {
370
432
  const lock = `${path}.lock`;
371
433
  const deadline = Date.now() + LOCK_WAIT_MS;
372
434
  while (true) {
373
435
  try {
374
436
  await mkdir(lock);
437
+ await writeFile(join(lock, 'owner'), `${process.pid}\n`).catch(() => {});
375
438
  break;
376
439
  } catch (error) {
377
440
  if (error.code !== 'EEXIST') throw error;
441
+ // Harnesses kill hooks that overrun their timeout; a lock they held must not
442
+ // swallow every event behind it until the stale timeout.
378
443
  const held = await stat(lock).then((info) => Date.now() - info.mtimeMs).catch(() => 0);
379
- if (held > LOCK_STALE_MS) {
380
- await rmdir(lock).catch(() => {});
444
+ if (held > LOCK_STALE_MS || await ownerIsDead(lock)) {
445
+ await releaseLock(lock);
381
446
  continue;
382
447
  }
383
448
  if (Date.now() > deadline) throw new Error('ledger session is locked');
@@ -387,13 +452,16 @@ async function withSessionLock(path, work) {
387
452
  try {
388
453
  return await work();
389
454
  } finally {
390
- await rmdir(lock).catch(() => {});
455
+ await releaseLock(lock);
391
456
  }
392
457
  }
393
458
 
394
459
  export async function handleHook({ payload, project, now = Date.now(), cli = 'llm-orchestrator' }) {
395
460
  const event = normalizePayload(payload);
396
461
  if (event.kind === 'other') return null;
462
+ // Subagent tool calls change nothing (they work under the parent's run), so they
463
+ // take no lock and do no I/O — a busy subagent must not starve the parent's events.
464
+ if (event.kind === 'tool' && event.isSubagent) return null;
397
465
  // An explicit run command opts the project in; anything else needs the project to use the core.
398
466
  if (!event.run && !(await projectUsesOrchestrator(project))) return null;
399
467
  const directory = await ensureLedger(project);
@@ -417,7 +485,9 @@ export async function handleHook({ payload, project, now = Date.now(), cli = 'll
417
485
  });
418
486
  if (!result.output) return null;
419
487
  // 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);
488
+ const text = result.output.kind === 'dispatch' ? dispatchNudgeFor(cli, result.output.planned)
489
+ : result.output.kind === 'overreach' ? overreachNudgeFor(cli, result.output.files)
490
+ : nudgeFor(cli);
421
491
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text } };
422
492
  }
423
493
 
@@ -462,6 +532,7 @@ export function adherenceSummary(lines) {
462
532
  planned_but_not_dispatched: lines.filter((line) => (line.planned_shards ?? 0) > 1 && line.subagents_started === 0).length,
463
533
  runs_without_plan: lines.filter((line) => !line.skipped_flow && !line.trivial && line.planned_shards === null).length,
464
534
  inline_declared: lines.filter((line) => Boolean(line.inline_reason)).length,
535
+ trivial_overreach: lines.filter((line) => line.trivial && line.overreach).length,
465
536
  below_fan_out: lines.filter((line) => EVIDENCE_TYPES.has(line.task_type) && !line.inline_reason && (line.planned_shards ?? 0) < 2).length,
466
537
  };
467
538
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-orchestrator",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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": {
package/protocol.md CHANGED
@@ -43,7 +43,10 @@ are still dispatched — see "A shard ends where state ends" in [dispatch](polic
43
43
 
44
44
  **Trivial tasks.** A one-line, obviously scoped change (a typo, a version bump) may skip the full
45
45
  flow, but only by declaring it: `llm-orchestrator run start --trivial "<reason>"`. The
46
- 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.
47
50
 
48
51
  ```json
49
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", "inline_reason", "main_work_calls", "dispatch_nudged"],
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] },
@@ -20,7 +20,10 @@
20
20
  "started_outside_flow": { "type": "boolean" },
21
21
  "inline_reason": { "type": ["string", "null"], "description": "Why the shards stay inline, e.g. stateful:browser — a live state no subagent can inherit." },
22
22
  "main_work_calls": { "type": "integer", "minimum": 0 },
23
- "dispatch_nudged": { "type": "boolean" }
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" }
24
27
  }
25
28
  },
26
29
  "session": {
@@ -42,7 +45,7 @@
42
45
  "historyLine": {
43
46
  "type": "object",
44
47
  "additionalProperties": false,
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"],
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"],
46
49
  "properties": {
47
50
  "session": { "type": "string", "minLength": 1 },
48
51
  "task_id": { "type": ["string", "null"] },
@@ -58,7 +61,8 @@
58
61
  "skipped_flow": { "type": "boolean" },
59
62
  "inline_reason": { "type": ["string", "null"] },
60
63
  "dispatch_nudged": { "type": "boolean" },
61
- "closed_by": { "enum": ["run_close", "next_prompt", "succession"] }
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"] }
62
66
  }
63
67
  }
64
68
  }