agentopology 0.2.3 → 0.2.5
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/README.md +23 -7
- package/dist/bindings/__tests__/bindings.test.js +148 -2
- package/dist/bindings/__tests__/bindings.test.js.map +1 -1
- package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts +2 -0
- package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/claude-code-hybrid.test.js +152 -0
- package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -0
- package/dist/bindings/__tests__/claude-workflow.test.d.ts +2 -0
- package/dist/bindings/__tests__/claude-workflow.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/claude-workflow.test.js +204 -0
- package/dist/bindings/__tests__/claude-workflow.test.js.map +1 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.d.ts +15 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.js +94 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +389 -8
- package/dist/bindings/claude-code.js.map +1 -1
- package/dist/bindings/claude-workflow.d.ts +28 -0
- package/dist/bindings/claude-workflow.d.ts.map +1 -0
- package/dist/bindings/claude-workflow.js +684 -0
- package/dist/bindings/claude-workflow.js.map +1 -0
- package/dist/bindings/index.d.ts +1 -0
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +3 -0
- package/dist/bindings/index.js.map +1 -1
- package/dist/bindings/lib/seam.d.ts +35 -0
- package/dist/bindings/lib/seam.d.ts.map +1 -0
- package/dist/bindings/lib/seam.js +42 -0
- package/dist/bindings/lib/seam.js.map +1 -0
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +10 -0
- package/dist/docs/content.js.map +1 -1
- package/dist/parser/__tests__/parser.test.js +57 -0
- package/dist/parser/__tests__/parser.test.js.map +1 -1
- package/dist/parser/ast.d.ts +15 -0
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +12 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +37 -1
- package/dist/parser/validator.js.map +1 -1
- package/examples/matchmat-ship.at +249 -0
- package/package.json +1 -1
- package/spec/grammar.md +21 -1
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import { deduplicateFiles } from "./types.js";
|
|
10
|
-
import { shellStub } from "./lib/stub.js";
|
|
10
|
+
import { shellStub, STUB_MARKER } from "./lib/stub.js";
|
|
11
|
+
import { isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Helpers
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -90,10 +91,106 @@ function isRegisteredSubagentType(ast, name) {
|
|
|
90
91
|
return false;
|
|
91
92
|
return ast.nodes.some((n) => (n.type === "agent" || n.type === "group") && n.id === name);
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the topology's orchestration delegation mode.
|
|
96
|
+
*
|
|
97
|
+
* Returns `"inline"` if the orchestrator block declares `delegation: inline`.
|
|
98
|
+
* Otherwise returns `"subagent"` (the default). Inline-orchestrator topologies
|
|
99
|
+
* drive every agent in the main session's context; no Task-tool subagent is
|
|
100
|
+
* ever spawned, so any `SubagentStop` hook would silently never fire.
|
|
101
|
+
*/
|
|
102
|
+
function getDelegationMode(ast) {
|
|
103
|
+
const orch = ast.nodes.find((n) => n.type === "orchestrator");
|
|
104
|
+
return orch?.delegation === "inline" ? "inline" : "subagent";
|
|
105
|
+
}
|
|
93
106
|
/** Generate an empty directory marker (a .gitkeep file). */
|
|
94
107
|
function gitkeep(dirPath) {
|
|
95
108
|
return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
|
|
96
109
|
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// HYBRID AWARENESS (host + embedded claude-workflow rung)
|
|
112
|
+
//
|
|
113
|
+
// A topology can mark an agent/phase `extensions { claude-workflow { execution:
|
|
114
|
+
// workflow } }`. The claude-workflow binding compiles those phases into a
|
|
115
|
+
// `<topology>.workflow.js` rung. THIS (claude-code) binding is the HOST: it must
|
|
116
|
+
// NOT emit a subagent AGENT.md for a workflow-marked agent — in the hybrid, the
|
|
117
|
+
// host does not run that agent as a subagent; it LAUNCHES the workflow rung that
|
|
118
|
+
// contains it and OBSERVES the Blackboard writes.
|
|
119
|
+
//
|
|
120
|
+
// The seam vocabulary here MUST stay in sync with src/bindings/claude-workflow.ts:
|
|
121
|
+
// - workflow.js filename: `<topology>.workflow.js`
|
|
122
|
+
// - seam contract doc: `<topology>-SEAM.md`
|
|
123
|
+
// - human-split README: `<topology>-README.md`
|
|
124
|
+
// - Blackboard root: `memory.workspace.path` (e.g. "workspace/")
|
|
125
|
+
// - run order: host runs host phases in-session, LAUNCHES the workflow.js for
|
|
126
|
+
// the workflow-marked phase(s), host hooks observe the writes LIVE.
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Seam constants/helpers (SEAM_NS, isWorkflowSeamAgent, seamFiles, workspaceRoot)
|
|
129
|
+
// are the single source of truth shared with the claude-workflow binding —
|
|
130
|
+
// imported from ./lib/seam.js. Do NOT re-hardcode the namespace or filenames here.
|
|
131
|
+
/** Resolve an agent's phase (defaults to a large value so unphased trails). */
|
|
132
|
+
function agentPhase(agent) {
|
|
133
|
+
return agent.phase ?? Number.MAX_SAFE_INTEGER;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The set of phases that compile into the embedded workflow rung — every phase
|
|
137
|
+
* that contains ≥1 agent marked `execution: workflow`. (Matches the
|
|
138
|
+
* claude-workflow binding's `workflowPhases`: an agent sharing a workflow phase
|
|
139
|
+
* is folded into the rung too.)
|
|
140
|
+
*/
|
|
141
|
+
function workflowPhaseSet(ast) {
|
|
142
|
+
const phases = new Set();
|
|
143
|
+
for (const n of ast.nodes) {
|
|
144
|
+
if (n.type === "agent" && isWorkflowSeamAgent(n)) {
|
|
145
|
+
phases.add(agentPhase(n));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return phases;
|
|
149
|
+
}
|
|
150
|
+
/** Does this topology have ≥1 workflow-marked phase (i.e. is it a hybrid)? */
|
|
151
|
+
function isHybridTopology(ast) {
|
|
152
|
+
return workflowPhaseSet(ast).size > 0;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The Blackboard root path from `memory.workspace.path`, or null. Both bindings
|
|
156
|
+
* key the seam off this path.
|
|
157
|
+
*/
|
|
158
|
+
function workspaceRoot(ast) {
|
|
159
|
+
const ws = ast.memory?.workspace;
|
|
160
|
+
return ws?.path ?? null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Group the workflow-marked phases into contiguous runs (by sorted phase
|
|
164
|
+
* number). Each contiguous run is ONE launch directive (the host launches the
|
|
165
|
+
* workflow.js once for that span). Returns a list of phase-number arrays.
|
|
166
|
+
*/
|
|
167
|
+
function contiguousWorkflowPhaseRuns(ast) {
|
|
168
|
+
const sorted = [...workflowPhaseSet(ast)].sort((a, b) => a - b);
|
|
169
|
+
const runs = [];
|
|
170
|
+
let current = [];
|
|
171
|
+
for (const p of sorted) {
|
|
172
|
+
if (current.length === 0 || p === current[current.length - 1] + 1) {
|
|
173
|
+
current.push(p);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
runs.push(current);
|
|
177
|
+
current = [p];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (current.length > 0)
|
|
181
|
+
runs.push(current);
|
|
182
|
+
return runs;
|
|
183
|
+
}
|
|
184
|
+
/** A short, stable label for a phase run (used in stub filenames). */
|
|
185
|
+
function workflowRunLabel(ast, run) {
|
|
186
|
+
// Prefer the first workflow-marked agent id in the run's lowest phase — that
|
|
187
|
+
// reads naturally (e.g. "build" from `builder`). Fall back to phase numbers.
|
|
188
|
+
const lowest = run[0];
|
|
189
|
+
const agentInPhase = ast.nodes.find((n) => n.type === "agent" && agentPhase(n) === lowest && isWorkflowSeamAgent(n));
|
|
190
|
+
if (agentInPhase)
|
|
191
|
+
return agentInPhase.id;
|
|
192
|
+
return run.length === 1 ? `phase-${run[0]}` : `phases-${run[0]}-${run[run.length - 1]}`;
|
|
193
|
+
}
|
|
97
194
|
/**
|
|
98
195
|
* Map topology permission strings to Claude Code permissionMode values.
|
|
99
196
|
* Returns undefined when the permission mode should be omitted (default/autonomous).
|
|
@@ -230,6 +327,13 @@ function generateAgents(ast) {
|
|
|
230
327
|
if (node.type !== "agent")
|
|
231
328
|
continue;
|
|
232
329
|
const agent = node;
|
|
330
|
+
// HYBRID SEAM: an agent marked `execution: workflow` is owned by the
|
|
331
|
+
// embedded workflow rung (claude-workflow binding → <topology>.workflow.js),
|
|
332
|
+
// NOT by the host. The host does not run it as a subagent — it launches the
|
|
333
|
+
// workflow rung and observes its Blackboard writes. So do NOT emit a
|
|
334
|
+
// .claude/agents/<name>/AGENT.md for it. See generateWorkflowLaunchArtifacts.
|
|
335
|
+
if (isWorkflowSeamAgent(agent))
|
|
336
|
+
continue;
|
|
233
337
|
// Build frontmatter fields
|
|
234
338
|
const fm = {};
|
|
235
339
|
fm.name = agent.id;
|
|
@@ -1215,6 +1319,12 @@ function generateTopologySkill(ast) {
|
|
|
1215
1319
|
sections.push("");
|
|
1216
1320
|
}
|
|
1217
1321
|
}
|
|
1322
|
+
// HYBRID run order — mirrors the claude-workflow SEAM.md so the host playbook
|
|
1323
|
+
// knows where it launches the embedded workflow rung and observes its writes.
|
|
1324
|
+
const hybridRunOrder = buildHybridRunOrderSection(ast);
|
|
1325
|
+
if (hybridRunOrder) {
|
|
1326
|
+
sections.push(hybridRunOrder);
|
|
1327
|
+
}
|
|
1218
1328
|
files.push({
|
|
1219
1329
|
path: `.claude/skills/${name}/SKILL.md`,
|
|
1220
1330
|
content: sections.join("\n") + "\n",
|
|
@@ -1459,13 +1569,22 @@ function generateGateWrapperScripts(ast) {
|
|
|
1459
1569
|
const onFailExit = gate.onFail === "halt"
|
|
1460
1570
|
? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
|
|
1461
1571
|
: `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
|
|
1462
|
-
const
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1572
|
+
const inlineMode = getDelegationMode(ast) === "inline";
|
|
1573
|
+
const wiredAsHook = !inlineMode && isRegisteredSubagentType(ast, gate.after);
|
|
1574
|
+
let enforcementNote;
|
|
1575
|
+
let enforcementNote2;
|
|
1576
|
+
if (inlineMode) {
|
|
1577
|
+
enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
|
|
1578
|
+
enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
|
|
1579
|
+
}
|
|
1580
|
+
else if (wiredAsHook) {
|
|
1581
|
+
enforcementNote = `# Enforcement: SubagentStop hook on agent "${gate.after}". Exit 2 blocks the subagent`;
|
|
1582
|
+
enforcementNote2 = `# from stopping (Claude Code prevents stop and surfaces stderr to Claude).`;
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (after: ${gate.after || "<unset>"} is not an agent).`;
|
|
1586
|
+
enforcementNote2 = `# Invoke this script from your orchestrator playbook or a /command at the right step.`;
|
|
1587
|
+
}
|
|
1469
1588
|
const contentLines = [
|
|
1470
1589
|
"#!/usr/bin/env bash",
|
|
1471
1590
|
`# Gate wrapper: ${gate.id}`,
|
|
@@ -1631,10 +1750,21 @@ function generateAgentHookScripts(ast) {
|
|
|
1631
1750
|
function generateSettings(ast) {
|
|
1632
1751
|
const settings = {};
|
|
1633
1752
|
const topologyName = ast.topology.name;
|
|
1753
|
+
const isInline = getDelegationMode(ast) === "inline";
|
|
1634
1754
|
// Hooks section — grouped by event name
|
|
1635
1755
|
const hooksByEvent = {};
|
|
1636
1756
|
for (const hook of ast.hooks) {
|
|
1637
1757
|
const eventName = hook.on;
|
|
1758
|
+
// Inline orchestrator mode never spawns subagents, so SubagentStop /
|
|
1759
|
+
// SubagentStart hooks would never fire. Warn the user and skip emission
|
|
1760
|
+
// — keeping them in settings.json would be a maintenance trap.
|
|
1761
|
+
if (isInline && (eventName === "SubagentStop" || eventName === "SubagentStart")) {
|
|
1762
|
+
console.warn(`[claude-code] Hook "${hook.name}" targets ${eventName} but ` +
|
|
1763
|
+
`orchestrator.delegation is "inline" — no subagent ever runs, so the hook would ` +
|
|
1764
|
+
`never fire. Skipped. Move the work into your orchestrator playbook or change ` +
|
|
1765
|
+
`delegation to "subagent".`);
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1638
1768
|
if (!hooksByEvent[eventName]) {
|
|
1639
1769
|
hooksByEvent[eventName] = [];
|
|
1640
1770
|
}
|
|
@@ -1684,12 +1814,27 @@ function generateSettings(ast) {
|
|
|
1684
1814
|
// action, or "any"), no SubagentStop hook fires reliably. We skip emitting
|
|
1685
1815
|
// dead config and warn the user — the topology's orchestrator (or a slash
|
|
1686
1816
|
// command playbook) must invoke the gate script at the right step.
|
|
1817
|
+
//
|
|
1818
|
+
// INLINE-ORCHESTRATOR MODE: when the topology declares
|
|
1819
|
+
// `orchestrator { delegation: inline }`, no subagent is ever spawned even
|
|
1820
|
+
// when the matcher names a real agent — the orchestrator drives every step
|
|
1821
|
+
// in its own session context. SubagentStop hooks would be dead config in
|
|
1822
|
+
// this mode, so we skip emitting them entirely (still generate the wrapper
|
|
1823
|
+
// script). The gate must be invoked from the playbook at the right step.
|
|
1824
|
+
const delegationMode = getDelegationMode(ast);
|
|
1687
1825
|
const gates = ast.nodes.filter((n) => n.type === "gate");
|
|
1688
1826
|
for (const gate of gates) {
|
|
1689
1827
|
if (!gate.run)
|
|
1690
1828
|
continue;
|
|
1691
1829
|
if (gate.behavior === "advisory")
|
|
1692
1830
|
continue; // advisory gates stay in markdown only
|
|
1831
|
+
if (delegationMode === "inline") {
|
|
1832
|
+
console.warn(`[claude-code] Gate "${gate.id}" — orchestrator.delegation is "inline", ` +
|
|
1833
|
+
`so no subagent ever runs. SubagentStop hook not emitted; invoke ` +
|
|
1834
|
+
`.claude/skills/${topologyName}/scripts/gate-${gate.id}.sh from your ` +
|
|
1835
|
+
`orchestrator playbook at the right step.`);
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1693
1838
|
// SubagentStop matcher must be a registered subagent_type. If the gate
|
|
1694
1839
|
// doesn't target an agent node, we cannot wire it as a hook — warn and
|
|
1695
1840
|
// skip so we don't ship dead config that never fires.
|
|
@@ -1716,6 +1861,21 @@ function generateSettings(ast) {
|
|
|
1716
1861
|
};
|
|
1717
1862
|
hooksByEvent[eventName].push(hookEntry);
|
|
1718
1863
|
}
|
|
1864
|
+
// HYBRID: host-layer concurrent Blackboard observer.
|
|
1865
|
+
//
|
|
1866
|
+
// The topology's bare-event `hooks { PostToolUse { matcher: "Write" ... } }`
|
|
1867
|
+
// block is not surfaced into ast.hooks (only the named `hook <name> {}` form is
|
|
1868
|
+
// parsed). We don't change the parser (out of scope); instead, when this is a
|
|
1869
|
+
// hybrid topology WITH a Blackboard, we synthesize the observe-hook: a
|
|
1870
|
+
// Blackboard implies a host-side observer that watches the workflow rung's
|
|
1871
|
+
// Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
|
|
1872
|
+
// observability (the deterministic workflow rung cannot express it).
|
|
1873
|
+
const observeHook = buildHybridObserveHook(ast, topologyName);
|
|
1874
|
+
if (observeHook) {
|
|
1875
|
+
if (!hooksByEvent[observeHook.event])
|
|
1876
|
+
hooksByEvent[observeHook.event] = [];
|
|
1877
|
+
hooksByEvent[observeHook.event].push(observeHook.entry);
|
|
1878
|
+
}
|
|
1719
1879
|
// Write hooksByEvent to settings if any hooks or gates were registered
|
|
1720
1880
|
if (Object.keys(hooksByEvent).length > 0) {
|
|
1721
1881
|
settings.hooks = hooksByEvent;
|
|
@@ -2062,6 +2222,34 @@ function generateCommandFiles(ast) {
|
|
|
2062
2222
|
}
|
|
2063
2223
|
sections.push("");
|
|
2064
2224
|
}
|
|
2225
|
+
// Inline-orchestrator topologies cannot wire gates via SubagentStop hooks
|
|
2226
|
+
// (no subagent is ever spawned). The orchestrator playbook must invoke
|
|
2227
|
+
// each non-advisory gate at the right step. We emit a copy-pasteable
|
|
2228
|
+
// cheat sheet so the user knows exactly which command to drop in and
|
|
2229
|
+
// where in the flow it belongs.
|
|
2230
|
+
if (getDelegationMode(ast) === "inline") {
|
|
2231
|
+
const blockingGates = ast.nodes.filter((n) => n.type === "gate")
|
|
2232
|
+
.filter((g) => g.run && g.behavior !== "advisory");
|
|
2233
|
+
if (blockingGates.length > 0) {
|
|
2234
|
+
sections.push("## Gates to invoke (inline-orchestrator)");
|
|
2235
|
+
sections.push("");
|
|
2236
|
+
sections.push("Because `orchestrator.delegation: inline` is declared, no `SubagentStop` " +
|
|
2237
|
+
"hooks fire. Invoke each gate from this playbook at the step indicated " +
|
|
2238
|
+
"by its `after:` field. Exit code 0 = pass, non-zero = fail.");
|
|
2239
|
+
sections.push("");
|
|
2240
|
+
for (const gate of blockingGates) {
|
|
2241
|
+
const after = gate.after ?? "(any)";
|
|
2242
|
+
const before = gate.before ?? "(any)";
|
|
2243
|
+
const onFail = gate.onFail ?? "halt";
|
|
2244
|
+
sections.push(`### gate \`${gate.id}\` — after \`${after}\`, before \`${before}\`, on-fail \`${onFail}\``);
|
|
2245
|
+
sections.push("");
|
|
2246
|
+
sections.push("```bash");
|
|
2247
|
+
sections.push(`bash .claude/skills/${ast.topology.name}/scripts/gate-${gate.id}.sh`);
|
|
2248
|
+
sections.push("```");
|
|
2249
|
+
sections.push("");
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2065
2253
|
files.push({
|
|
2066
2254
|
path: `.claude/commands/${trigger.name}.md`,
|
|
2067
2255
|
content: sections.join("\n") + "\n",
|
|
@@ -2244,12 +2432,198 @@ function generateContextFile(ast) {
|
|
|
2244
2432
|
}
|
|
2245
2433
|
sections.push("");
|
|
2246
2434
|
}
|
|
2435
|
+
// HYBRID run order — when this topology composes with an embedded
|
|
2436
|
+
// claude-workflow rung, document who runs/launches/observes what.
|
|
2437
|
+
const hybridRunOrder = buildHybridRunOrderSection(ast);
|
|
2438
|
+
if (hybridRunOrder) {
|
|
2439
|
+
sections.push(hybridRunOrder);
|
|
2440
|
+
}
|
|
2247
2441
|
return {
|
|
2248
2442
|
path: fileName,
|
|
2249
2443
|
content: sections.join("\n") + "\n",
|
|
2250
2444
|
category: "machine",
|
|
2251
2445
|
};
|
|
2252
2446
|
}
|
|
2447
|
+
/**
|
|
2448
|
+
* HYBRID: emit a host-side "launch the workflow rung" skill stub for each
|
|
2449
|
+
* contiguous workflow-marked phase run.
|
|
2450
|
+
*
|
|
2451
|
+
* In the hybrid model the host does NOT run the workflow-marked agents as
|
|
2452
|
+
* subagents. At the corresponding point in the flow, the host LAUNCHES the
|
|
2453
|
+
* embedded deterministic workflow rung (`<topology>.workflow.js`, emitted by the
|
|
2454
|
+
* sibling claude-workflow binding). These stubs are the documented launch
|
|
2455
|
+
* directives, scoped into the topology's skill scripts directory next to the
|
|
2456
|
+
* gate/hook stubs (same AGENTOPOLOGY_STUB mechanism so CI can detect them).
|
|
2457
|
+
*/
|
|
2458
|
+
function generateWorkflowLaunchArtifacts(ast) {
|
|
2459
|
+
const files = [];
|
|
2460
|
+
if (!isHybridTopology(ast))
|
|
2461
|
+
return files;
|
|
2462
|
+
const name = ast.topology.name;
|
|
2463
|
+
const workflowFile = seamFiles.workflowScript(name);
|
|
2464
|
+
const seamDoc = seamFiles.seamDoc(name);
|
|
2465
|
+
const root = workspaceRoot(ast);
|
|
2466
|
+
const runs = contiguousWorkflowPhaseRuns(ast);
|
|
2467
|
+
for (const run of runs) {
|
|
2468
|
+
const label = workflowRunLabel(ast, run);
|
|
2469
|
+
// Agents that this rung owns (for documentation in the stub body).
|
|
2470
|
+
const ownedAgents = ast.nodes
|
|
2471
|
+
.filter((n) => n.type === "agent" && run.includes(agentPhase(n)) && isWorkflowSeamAgent(n))
|
|
2472
|
+
.map((a) => a.id);
|
|
2473
|
+
const phaseLabel = run.length === 1 ? `phase ${run[0]}` : `phases ${run[0]}–${run[run.length - 1]}`;
|
|
2474
|
+
const body = [
|
|
2475
|
+
"#!/usr/bin/env bash",
|
|
2476
|
+
`# Launch the embedded workflow rung for ${phaseLabel} (${ownedAgents.join(", ")}).`,
|
|
2477
|
+
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2478
|
+
STUB_MARKER,
|
|
2479
|
+
"set -euo pipefail",
|
|
2480
|
+
"",
|
|
2481
|
+
`# HYBRID SEAM (host -> embedded workflow). This step does NOT run`,
|
|
2482
|
+
`# ${ownedAgents.join(", ")} as host subagent(s). Instead the host LAUNCHES the`,
|
|
2483
|
+
`# deterministic workflow rung that owns ${phaseLabel}, emitted by the`,
|
|
2484
|
+
`# claude-workflow binding as: ${workflowFile}`,
|
|
2485
|
+
"#",
|
|
2486
|
+
"# The rung reads its inputs from the Blackboard (seeded by the host's prior",
|
|
2487
|
+
`# phase) and writes its results back so host hooks observe them LIVE.`,
|
|
2488
|
+
root ? `# Blackboard root (shared by host + rung): ${root}` : "# Blackboard root: (memory.workspace.path not declared)",
|
|
2489
|
+
`# Seam contract both bindings honor: ${seamDoc}`,
|
|
2490
|
+
"#",
|
|
2491
|
+
"# Claude Workflows are launched IN-SESSION via the /workflow keyword, not as a",
|
|
2492
|
+
"# detached process. The orchestrator invokes the workflow at this step:",
|
|
2493
|
+
"#",
|
|
2494
|
+
`# /workflow ${workflowFile}`,
|
|
2495
|
+
"#",
|
|
2496
|
+
"# If your harness launches workflows out-of-process instead, replace the line",
|
|
2497
|
+
`# below with the real launcher (e.g. \`claude workflow ${workflowFile}\`).`,
|
|
2498
|
+
"",
|
|
2499
|
+
`echo "TODO: launch the workflow rung — invoke '/workflow ${workflowFile}' in-session (${phaseLabel}: ${ownedAgents.join(", ")})."`,
|
|
2500
|
+
"",
|
|
2501
|
+
].join("\n");
|
|
2502
|
+
files.push({
|
|
2503
|
+
path: `.claude/skills/${name}/scripts/launch-${label}-workflow.sh`,
|
|
2504
|
+
content: body,
|
|
2505
|
+
category: "script",
|
|
2506
|
+
executable: true,
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
return files;
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* HYBRID: build the "Hybrid run order" markdown block appended to the generated
|
|
2513
|
+
* context file (.claude/CLAUDE.md) and the topology SKILL.md. It states the
|
|
2514
|
+
* host's interleaving with the embedded workflow rung, in the SAME run-order
|
|
2515
|
+
* vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
|
|
2516
|
+
* stay in sync. Returns null when the topology is not a hybrid.
|
|
2517
|
+
*/
|
|
2518
|
+
function buildHybridRunOrderSection(ast) {
|
|
2519
|
+
if (!isHybridTopology(ast))
|
|
2520
|
+
return null;
|
|
2521
|
+
const name = ast.topology.name;
|
|
2522
|
+
const workflowFile = seamFiles.workflowScript(name);
|
|
2523
|
+
const seamDoc = seamFiles.seamDoc(name);
|
|
2524
|
+
const root = workspaceRoot(ast);
|
|
2525
|
+
const wfPhases = workflowPhaseSet(ast);
|
|
2526
|
+
const agents = ast.nodes.filter((n) => n.type === "agent");
|
|
2527
|
+
const human = ast.nodes.find((n) => n.type === "human") ?? null;
|
|
2528
|
+
const allPhases = [...new Set(agents.map(agentPhase))].sort((a, b) => a - b);
|
|
2529
|
+
const idsInPhase = (p) => agents.filter((a) => agentPhase(a) === p).map((a) => a.id).join(", ");
|
|
2530
|
+
const lines = [];
|
|
2531
|
+
lines.push("## Hybrid run order");
|
|
2532
|
+
lines.push("");
|
|
2533
|
+
lines.push(`This topology is a **hybrid**: it composes the host (this \`.claude/\` tree) with an embedded deterministic workflow rung emitted by the \`claude-workflow\` binding as \`${workflowFile}\`. The host runs the host phases in-session; for the workflow-marked phase(s) it **launches the rung** and **observes** its Blackboard writes. Both sides honor the seam contract in \`${seamDoc}\`.`);
|
|
2534
|
+
lines.push("");
|
|
2535
|
+
if (root) {
|
|
2536
|
+
lines.push(`**Blackboard root** (shared by host + rung): \`${root}\``);
|
|
2537
|
+
lines.push("");
|
|
2538
|
+
}
|
|
2539
|
+
lines.push("Run order:");
|
|
2540
|
+
lines.push("");
|
|
2541
|
+
let step = 1;
|
|
2542
|
+
for (const p of allPhases) {
|
|
2543
|
+
if (wfPhases.has(p)) {
|
|
2544
|
+
lines.push(`${step}. **Host launches \`${workflowFile}\`** for phase ${p} (${idsInPhase(p)}) — the deterministic rung. It reads the Blackboard inputs the host seeded, does the work, and writes its results back. Host hooks observe those writes LIVE (the concurrent observability the workflow rung itself cannot express). Launch via \`.claude/skills/${name}/scripts/launch-${workflowRunLabel(ast, [p])}-workflow.sh\` (i.e. \`/workflow ${workflowFile}\`).`);
|
|
2545
|
+
}
|
|
2546
|
+
else {
|
|
2547
|
+
lines.push(`${step}. **Host (in-session)** runs phase ${p} (${idsInPhase(p)}) — writes its outputs to the Blackboard so the next rung can read them.`);
|
|
2548
|
+
}
|
|
2549
|
+
step++;
|
|
2550
|
+
}
|
|
2551
|
+
if (human) {
|
|
2552
|
+
lines.push(`${step}. **Host** presents the Blackboard artifacts to the human \`${human.id}\`, who acts (e.g. promotes). Promotion triggers the SEPARATE downstream workflow.`);
|
|
2553
|
+
}
|
|
2554
|
+
lines.push("");
|
|
2555
|
+
lines.push(`> This run order MUST match \`${seamDoc}\`'s "Run order" section (emitted by the claude-workflow binding). If you change one, change the other.`);
|
|
2556
|
+
lines.push("");
|
|
2557
|
+
return lines.join("\n");
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* HYBRID: emit the host-layer concurrent Blackboard observer hook into
|
|
2561
|
+
* settings.json.
|
|
2562
|
+
*
|
|
2563
|
+
* Rationale: the topology's `hooks { PostToolUse { matcher: "Write" ... } }`
|
|
2564
|
+
* block is the bare-event form, which the parser does NOT currently surface into
|
|
2565
|
+
* `ast.hooks` (only the named `hook <name> {}` form is parsed; changing the
|
|
2566
|
+
* parser is out of scope). So we synthesize the observer hook from the presence
|
|
2567
|
+
* of a Blackboard (`memory.workspace`): a Blackboard in a hybrid topology IMPLIES
|
|
2568
|
+
* a host-side observer that watches the workflow rung's Write tool-calls live.
|
|
2569
|
+
*
|
|
2570
|
+
* Returns the hook entry to merge into settings.hooks.PostToolUse, or null.
|
|
2571
|
+
*/
|
|
2572
|
+
function buildHybridObserveHook(ast, topologyName) {
|
|
2573
|
+
if (!isHybridTopology(ast))
|
|
2574
|
+
return null;
|
|
2575
|
+
if (!workspaceRoot(ast))
|
|
2576
|
+
return null; // no Blackboard → no observer
|
|
2577
|
+
// If a real ast.hook already targets PostToolUse/Write, the generic hook path
|
|
2578
|
+
// emits it — don't double up.
|
|
2579
|
+
const alreadyEmitted = ast.hooks.some((h) => h.on === "PostToolUse" && (h.matcher === "Write" || !h.matcher));
|
|
2580
|
+
if (alreadyEmitted)
|
|
2581
|
+
return null;
|
|
2582
|
+
const scriptPath = `.claude/skills/${topologyName}/scripts/observe-blackboard.sh`;
|
|
2583
|
+
return {
|
|
2584
|
+
event: "PostToolUse",
|
|
2585
|
+
scriptPath,
|
|
2586
|
+
entry: {
|
|
2587
|
+
matcher: "Write",
|
|
2588
|
+
hooks: [
|
|
2589
|
+
{
|
|
2590
|
+
type: "command",
|
|
2591
|
+
command: `bash ${scriptPath}`,
|
|
2592
|
+
},
|
|
2593
|
+
],
|
|
2594
|
+
},
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
/** HYBRID: the observe-blackboard.sh stub the observe-hook points at. */
|
|
2598
|
+
function generateObserveBlackboardScript(ast) {
|
|
2599
|
+
const hook = buildHybridObserveHook(ast, ast.topology.name);
|
|
2600
|
+
if (!hook)
|
|
2601
|
+
return null;
|
|
2602
|
+
const root = workspaceRoot(ast) ?? "workspace/";
|
|
2603
|
+
const body = [
|
|
2604
|
+
"#!/usr/bin/env bash",
|
|
2605
|
+
"# Host-layer concurrent Blackboard observer.",
|
|
2606
|
+
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2607
|
+
STUB_MARKER,
|
|
2608
|
+
"set -euo pipefail",
|
|
2609
|
+
"",
|
|
2610
|
+
"# HYBRID SEAM (host observability for the embedded workflow rung).",
|
|
2611
|
+
"# Wired as a PostToolUse(Write) hook in .claude/settings.json. It fires on every",
|
|
2612
|
+
`# Write tool-call so the host can watch the Blackboard (${root}) the workflow`,
|
|
2613
|
+
"# rung's agents write — that IS the concurrent observability the deterministic",
|
|
2614
|
+
"# workflow rung itself cannot express. Stdin JSON payload is on FD 0.",
|
|
2615
|
+
"",
|
|
2616
|
+
"PAYLOAD=$(cat)",
|
|
2617
|
+
`echo "TODO: observe Blackboard write under ${root} — inspect the payload and log/notify."`,
|
|
2618
|
+
"",
|
|
2619
|
+
].join("\n");
|
|
2620
|
+
return {
|
|
2621
|
+
path: hook.scriptPath,
|
|
2622
|
+
content: body,
|
|
2623
|
+
category: "script",
|
|
2624
|
+
executable: true,
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2253
2627
|
/**
|
|
2254
2628
|
* Merge `ast.env` into the settings.json output under an `env` key.
|
|
2255
2629
|
*
|
|
@@ -2363,6 +2737,13 @@ export const claudeCodeBinding = {
|
|
|
2363
2737
|
files.push(...generateHookScripts(ast));
|
|
2364
2738
|
// Hook scripts (per-agent)
|
|
2365
2739
|
files.push(...generateAgentHookScripts(ast));
|
|
2740
|
+
// HYBRID: host-side launch directives for the embedded workflow rung(s).
|
|
2741
|
+
files.push(...generateWorkflowLaunchArtifacts(ast));
|
|
2742
|
+
// HYBRID: host-layer concurrent Blackboard observer script (paired with the
|
|
2743
|
+
// synthesized PostToolUse(Write) hook in settings.json).
|
|
2744
|
+
const observeScript = generateObserveBlackboardScript(ast);
|
|
2745
|
+
if (observeScript)
|
|
2746
|
+
files.push(observeScript);
|
|
2366
2747
|
// Tool scripts
|
|
2367
2748
|
files.push(...generateToolScripts(ast));
|
|
2368
2749
|
// Schedule scripts
|