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.
Files changed (46) hide show
  1. package/README.md +23 -7
  2. package/dist/bindings/__tests__/bindings.test.js +148 -2
  3. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  4. package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts +2 -0
  5. package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts.map +1 -0
  6. package/dist/bindings/__tests__/claude-code-hybrid.test.js +152 -0
  7. package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -0
  8. package/dist/bindings/__tests__/claude-workflow.test.d.ts +2 -0
  9. package/dist/bindings/__tests__/claude-workflow.test.d.ts.map +1 -0
  10. package/dist/bindings/__tests__/claude-workflow.test.js +204 -0
  11. package/dist/bindings/__tests__/claude-workflow.test.js.map +1 -0
  12. package/dist/bindings/__tests__/hybrid-e2e.test.d.ts +15 -0
  13. package/dist/bindings/__tests__/hybrid-e2e.test.d.ts.map +1 -0
  14. package/dist/bindings/__tests__/hybrid-e2e.test.js +94 -0
  15. package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -0
  16. package/dist/bindings/claude-code.d.ts.map +1 -1
  17. package/dist/bindings/claude-code.js +389 -8
  18. package/dist/bindings/claude-code.js.map +1 -1
  19. package/dist/bindings/claude-workflow.d.ts +28 -0
  20. package/dist/bindings/claude-workflow.d.ts.map +1 -0
  21. package/dist/bindings/claude-workflow.js +684 -0
  22. package/dist/bindings/claude-workflow.js.map +1 -0
  23. package/dist/bindings/index.d.ts +1 -0
  24. package/dist/bindings/index.d.ts.map +1 -1
  25. package/dist/bindings/index.js +3 -0
  26. package/dist/bindings/index.js.map +1 -1
  27. package/dist/bindings/lib/seam.d.ts +35 -0
  28. package/dist/bindings/lib/seam.d.ts.map +1 -0
  29. package/dist/bindings/lib/seam.js +42 -0
  30. package/dist/bindings/lib/seam.js.map +1 -0
  31. package/dist/docs/content.d.ts.map +1 -1
  32. package/dist/docs/content.js +10 -0
  33. package/dist/docs/content.js.map +1 -1
  34. package/dist/parser/__tests__/parser.test.js +57 -0
  35. package/dist/parser/__tests__/parser.test.js.map +1 -1
  36. package/dist/parser/ast.d.ts +15 -0
  37. package/dist/parser/ast.d.ts.map +1 -1
  38. package/dist/parser/index.d.ts.map +1 -1
  39. package/dist/parser/index.js +12 -0
  40. package/dist/parser/index.js.map +1 -1
  41. package/dist/parser/validator.d.ts.map +1 -1
  42. package/dist/parser/validator.js +37 -1
  43. package/dist/parser/validator.js.map +1 -1
  44. package/examples/matchmat-ship.at +249 -0
  45. package/package.json +1 -1
  46. 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 wiredAsHook = isRegisteredSubagentType(ast, gate.after);
1463
- const enforcementNote = wiredAsHook
1464
- ? `# Enforcement: SubagentStop hook on agent "${gate.after}". Exit 2 blocks the subagent`
1465
- : `# Enforcement: NOT wired as a SubagentStop hook (after: ${gate.after || "<unset>"} is not an agent).`;
1466
- const enforcementNote2 = wiredAsHook
1467
- ? `# from stopping (Claude Code prevents stop and surfaces stderr to Claude).`
1468
- : `# Invoke this script from your orchestrator playbook or a /command at the right step.`;
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