llm-orchestrator 1.2.1 → 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/plugin.json +1 -1
- package/README.md +20 -0
- package/adapters/hooks.mjs +3 -1
- package/hooks/hooks.json +11 -0
- package/lib/flow-gate.mjs +51 -2
- package/package.json +1 -1
- package/protocol.md +4 -1
- package/schemas/flow-ledger.schema.json +8 -4
|
@@ -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,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
|
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/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
|
@@ -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>\`)
|
|
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;
|
|
@@ -417,7 +463,9 @@ export async function handleHook({ payload, project, now = Date.now(), cli = 'll
|
|
|
417
463
|
});
|
|
418
464
|
if (!result.output) return null;
|
|
419
465
|
// 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)
|
|
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);
|
|
421
469
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text } };
|
|
422
470
|
}
|
|
423
471
|
|
|
@@ -462,6 +510,7 @@ export function adherenceSummary(lines) {
|
|
|
462
510
|
planned_but_not_dispatched: lines.filter((line) => (line.planned_shards ?? 0) > 1 && line.subagents_started === 0).length,
|
|
463
511
|
runs_without_plan: lines.filter((line) => !line.skipped_flow && !line.trivial && line.planned_shards === null).length,
|
|
464
512
|
inline_declared: lines.filter((line) => Boolean(line.inline_reason)).length,
|
|
513
|
+
trivial_overreach: lines.filter((line) => line.trivial && line.overreach).length,
|
|
465
514
|
below_fan_out: lines.filter((line) => EVIDENCE_TYPES.has(line.task_type) && !line.inline_reason && (line.planned_shards ?? 0) < 2).length,
|
|
466
515
|
};
|
|
467
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/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
|
-
"
|
|
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
|
}
|