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.
- package/.claude-plugin/marketplace.json +1 -0
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -0
- package/adapters/hooks.mjs +3 -1
- package/bin/run.mjs +2 -2
- package/hooks/hooks.json +11 -0
- package/lib/flow-gate.mjs +144 -23
- package/package.json +1 -1
- package/policies/dispatch.md +21 -0
- package/protocol.md +9 -1
- package/schemas/flow-ledger.schema.json +13 -4
|
@@ -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.
|
|
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.
|
package/adapters/hooks.mjs
CHANGED
|
@@ -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>\`)
|
|
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
|
|
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
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/policies/dispatch.md
CHANGED
|
@@ -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
|
-
"
|
|
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
|
}
|