llm-orchestrator 1.2.2 → 1.2.4

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.2",
4
+ "version": "1.2.4",
5
5
  "author": {
6
6
  "name": "Bogdan-Gabriel Torcescu",
7
7
  "url": "https://www.linkedin.com/in/bogdantorcescu/"
package/README.md CHANGED
@@ -367,8 +367,10 @@ more instructions:
367
367
  - Trivial is the narrow exception — a one-line change such as a typo or a version bump. A run
368
368
  declared trivial that then edits a second file or touches tests gets one reminder to reopen it as
369
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
370
+ turn (`Stop` hook), so single-prompt sessions still reach the history. Typed runs stay open across
371
+ turns and close when the session ends (`SessionEnd`), so an unclosed run is never lost.
372
+ - Subagents are counted once each: resuming one (SendMessage) is not a new dispatch.
373
+ - A run opened with two or more shards whose main thread keeps doing the work — two work calls per planned shard, no
372
374
  subagent started — gets one more sentence: dispatch the independent shards (searching for the
373
375
  Agent tool if it is deferred), or declare the chain inline with
374
376
  `run start --type <T> --shards <n> --inline "stateful:<what>"`. Work is inline only while it holds
@@ -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', 'Stop'];
14
+ export const FLOW_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'SubagentStart', 'Stop', 'SessionEnd'];
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) {
@@ -166,6 +166,8 @@ export const OrchestrateFlow = async ({ directory }) => {
166
166
  }
167
167
  const idle = event?.properties?.sessionID;
168
168
  if (event?.type === "session.idle" && idle && !parentOf.has(idle)) gate({ hook_event_name: "Stop", session_id: idle });
169
+ const ended = info?.id;
170
+ if (event?.type === "session.deleted" && ended && !parentOf.has(ended)) gate({ hook_event_name: "SessionEnd", session_id: ended });
169
171
  } catch {}
170
172
  },
171
173
  "chat.message": async (input) => {
package/hooks/hooks.json CHANGED
@@ -44,6 +44,17 @@
44
44
  }
45
45
  ]
46
46
  }
47
+ ],
48
+ "SessionEnd": [
49
+ {
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/llm-orchestrator.mjs\" gate 2>/dev/null || true # orchestrate-core:flow",
54
+ "timeout": 3
55
+ }
56
+ ]
57
+ }
47
58
  ]
48
59
  }
49
60
  }
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
  /**
@@ -58,8 +58,9 @@ function pathHash(path) {
58
58
  return createHash('sha256').update(path).digest('hex').slice(0, 12);
59
59
  }
60
60
 
61
- // Main-thread work calls tolerated after run start before the dispatch nudge.
62
- export const DISPATCH_THRESHOLD = 6;
61
+ // Main-thread work calls tolerated per planned shard before the dispatch nudge: a
62
+ // two-shard task that is finished inline in three calls never reached a fixed six.
63
+ export const DISPATCH_CALLS_PER_SHARD = 2;
63
64
 
64
65
  // Evidence-gathering flows: their first phase fans out across independent sources.
65
66
  const EVIDENCE_TYPES = new Set(['INCIDENT', 'INVESTIGATION', 'RESEARCH']);
@@ -188,8 +189,9 @@ export function normalizePayload(raw) {
188
189
  // plugin and a CLI install; the harness's own event id lets the second be ignored.
189
190
  const id = (value) => (typeof value === 'string' && value ? value : null);
190
191
  if (eventName === 'UserPromptSubmit') return { kind: 'prompt', session, isSubagent, key: keyOf(eventName, id(payload.prompt_id) ?? id(payload.turn_id)) };
191
- if (eventName === 'SubagentStart') return { kind: 'subagent', session, isSubagent, key: keyOf(eventName, id(payload.agent_id)) };
192
+ if (eventName === 'SubagentStart') return { kind: 'subagent', session, isSubagent, key: keyOf(eventName, id(payload.agent_id)), agent: id(payload.agent_id) };
192
193
  if (eventName === 'Stop') return { kind: 'stop', session, isSubagent };
194
+ if (eventName === 'SessionEnd') return { kind: 'session_end', session, isSubagent };
193
195
  if (eventName !== 'PreToolUse') return { kind: 'other', session, isSubagent };
194
196
 
195
197
  const toolName = String(payload.tool_name ?? '');
@@ -283,6 +285,15 @@ export function decide(previous, event, now) {
283
285
  return { session, output, history };
284
286
  }
285
287
 
288
+ if (event.kind === 'session_end') {
289
+ // Typed runs outlive turns but not the session: close them so they reach history.
290
+ if (session.run) {
291
+ history.push(closeRun(session, now, 'session_end'));
292
+ session.run = null;
293
+ }
294
+ return { session, output, history };
295
+ }
296
+
286
297
  if (event.kind === 'stop') {
287
298
  // A trivial run lasts one turn; closing it here keeps single-prompt sessions in history.
288
299
  if (session.run?.trivial && !event.isSubagent) {
@@ -293,8 +304,16 @@ export function decide(previous, event, now) {
293
304
  }
294
305
 
295
306
  if (event.kind === 'subagent') {
296
- if (session.run) session.run.subagents_started += 1;
297
- else session.subagents_without_run += 1;
307
+ if (session.run) {
308
+ // Count distinct agents: a resumed subagent fires SubagentStart again, possibly
309
+ // long after its first start has left the seen-window.
310
+ const ids = Array.isArray(session.run.subagent_ids) ? session.run.subagent_ids : [];
311
+ const agentHash = event.agent ? pathHash(event.agent) : null;
312
+ if (!agentHash || !ids.includes(agentHash)) {
313
+ session.run.subagents_started += 1;
314
+ if (agentHash) session.run.subagent_ids = [...ids, agentHash];
315
+ }
316
+ } else session.subagents_without_run += 1;
298
317
  return { session, output, history };
299
318
  }
300
319
 
@@ -317,6 +336,7 @@ export function decide(previous, event, now) {
317
336
  edited_files: [],
318
337
  touched_tests: false,
319
338
  overreach_nudged: false,
339
+ subagent_ids: [],
320
340
  };
321
341
  // The work already done is accounted for on the run itself now.
322
342
  session.worked_without_run = false;
@@ -345,7 +365,7 @@ export function decide(previous, event, now) {
345
365
  return { session, output, history };
346
366
  }
347
367
  if ((current.planned_shards ?? 0) >= 2 && current.subagents_started === 0 && !current.inline_reason
348
- && !current.dispatch_nudged && current.main_work_calls >= DISPATCH_THRESHOLD) {
368
+ && !current.dispatch_nudged && current.main_work_calls >= DISPATCH_CALLS_PER_SHARD * current.planned_shards) {
349
369
  current.dispatch_nudged = true;
350
370
  output = { additionalContext: dispatchNudgeFor(undefined, current.planned_shards), kind: 'dispatch', planned: current.planned_shards };
351
371
  }
@@ -412,18 +432,37 @@ const LOCK_STALE_MS = 5000;
412
432
  * an atomic mkdir lock; a lock older than LOCK_STALE_MS belongs to a crashed
413
433
  * handler and is taken over. Failing to lock in time throws — the gate fails open.
414
434
  */
435
+ /** A lock whose recorded owner process no longer exists was left by a killed handler. */
436
+ async function ownerIsDead(lock) {
437
+ try {
438
+ const pid = Number.parseInt(await readFile(join(lock, 'owner'), 'utf8'), 10);
439
+ if (!Number.isInteger(pid) || pid <= 0) return false;
440
+ process.kill(pid, 0);
441
+ return false;
442
+ } catch (error) {
443
+ return error.code === 'ESRCH';
444
+ }
445
+ }
446
+
447
+ async function releaseLock(lock) {
448
+ await rm(lock, { recursive: true, force: true }).catch(() => {});
449
+ }
450
+
415
451
  async function withSessionLock(path, work) {
416
452
  const lock = `${path}.lock`;
417
453
  const deadline = Date.now() + LOCK_WAIT_MS;
418
454
  while (true) {
419
455
  try {
420
456
  await mkdir(lock);
457
+ await writeFile(join(lock, 'owner'), `${process.pid}\n`).catch(() => {});
421
458
  break;
422
459
  } catch (error) {
423
460
  if (error.code !== 'EEXIST') throw error;
461
+ // Harnesses kill hooks that overrun their timeout; a lock they held must not
462
+ // swallow every event behind it until the stale timeout.
424
463
  const held = await stat(lock).then((info) => Date.now() - info.mtimeMs).catch(() => 0);
425
- if (held > LOCK_STALE_MS) {
426
- await rmdir(lock).catch(() => {});
464
+ if (held > LOCK_STALE_MS || await ownerIsDead(lock)) {
465
+ await releaseLock(lock);
427
466
  continue;
428
467
  }
429
468
  if (Date.now() > deadline) throw new Error('ledger session is locked');
@@ -433,13 +472,16 @@ async function withSessionLock(path, work) {
433
472
  try {
434
473
  return await work();
435
474
  } finally {
436
- await rmdir(lock).catch(() => {});
475
+ await releaseLock(lock);
437
476
  }
438
477
  }
439
478
 
440
479
  export async function handleHook({ payload, project, now = Date.now(), cli = 'llm-orchestrator' }) {
441
480
  const event = normalizePayload(payload);
442
481
  if (event.kind === 'other') return null;
482
+ // Subagent tool calls change nothing (they work under the parent's run), so they
483
+ // take no lock and do no I/O — a busy subagent must not starve the parent's events.
484
+ if (event.kind === 'tool' && event.isSubagent) return null;
443
485
  // An explicit run command opts the project in; anything else needs the project to use the core.
444
486
  if (!event.run && !(await projectUsesOrchestrator(project))) return null;
445
487
  const directory = await ensureLedger(project);
@@ -511,6 +553,7 @@ export function adherenceSummary(lines) {
511
553
  runs_without_plan: lines.filter((line) => !line.skipped_flow && !line.trivial && line.planned_shards === null).length,
512
554
  inline_declared: lines.filter((line) => Boolean(line.inline_reason)).length,
513
555
  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,
556
+ below_fan_out: lines.filter((line) => EVIDENCE_TYPES.has(line.task_type) && !line.inline_reason
557
+ && (line.planned_shards ?? 0) < 2 && (line.subagents_started ?? 0) < 2).length,
515
558
  };
516
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-orchestrator",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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": {
@@ -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", "edited_files", "touched_tests", "overreach_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", "subagent_ids"],
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] },
@@ -23,7 +23,8 @@
23
23
  "dispatch_nudged": { "type": "boolean" },
24
24
  "edited_files": { "type": "array", "items": { "type": "string", "pattern": "^[0-9a-f]{12}$" }, "description": "Short hashes of edited paths — never the paths themselves." },
25
25
  "touched_tests": { "type": "boolean" },
26
- "overreach_nudged": { "type": "boolean" }
26
+ "overreach_nudged": { "type": "boolean" },
27
+ "subagent_ids": { "type": "array", "items": { "type": "string", "pattern": "^[0-9a-f]{12}$" }, "description": "Short hashes of the distinct subagents started under this run." }
27
28
  }
28
29
  },
29
30
  "session": {
@@ -62,7 +63,7 @@
62
63
  "inline_reason": { "type": ["string", "null"] },
63
64
  "dispatch_nudged": { "type": "boolean" },
64
65
  "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"] }
66
+ "closed_by": { "enum": ["run_close", "next_prompt", "succession", "turn_end", "session_end"] }
66
67
  }
67
68
  }
68
69
  }