agentopology 0.2.4 → 0.3.0

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 (64) hide show
  1. package/README.md +92 -10
  2. package/dist/bindings/__tests__/bindings.test.js +2 -2
  3. package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts +2 -0
  4. package/dist/bindings/__tests__/claude-code-hybrid.test.d.ts.map +1 -0
  5. package/dist/bindings/__tests__/claude-code-hybrid.test.js +152 -0
  6. package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -0
  7. package/dist/bindings/__tests__/claude-workflow.test.d.ts +2 -0
  8. package/dist/bindings/__tests__/claude-workflow.test.d.ts.map +1 -0
  9. package/dist/bindings/__tests__/claude-workflow.test.js +204 -0
  10. package/dist/bindings/__tests__/claude-workflow.test.js.map +1 -0
  11. package/dist/bindings/__tests__/hybrid-e2e.test.d.ts +15 -0
  12. package/dist/bindings/__tests__/hybrid-e2e.test.d.ts.map +1 -0
  13. package/dist/bindings/__tests__/hybrid-e2e.test.js +94 -0
  14. package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -0
  15. package/dist/bindings/claude-code.d.ts.map +1 -1
  16. package/dist/bindings/claude-code.js +407 -3
  17. package/dist/bindings/claude-code.js.map +1 -1
  18. package/dist/bindings/claude-workflow.d.ts +28 -0
  19. package/dist/bindings/claude-workflow.d.ts.map +1 -0
  20. package/dist/bindings/claude-workflow.js +684 -0
  21. package/dist/bindings/claude-workflow.js.map +1 -0
  22. package/dist/bindings/index.d.ts +1 -0
  23. package/dist/bindings/index.d.ts.map +1 -1
  24. package/dist/bindings/index.js +3 -0
  25. package/dist/bindings/index.js.map +1 -1
  26. package/dist/bindings/lib/seam.d.ts +35 -0
  27. package/dist/bindings/lib/seam.d.ts.map +1 -0
  28. package/dist/bindings/lib/seam.js +42 -0
  29. package/dist/bindings/lib/seam.js.map +1 -0
  30. package/dist/cli/index.js +195 -2
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/parser/__tests__/parser.test.js +170 -0
  33. package/dist/parser/__tests__/parser.test.js.map +1 -1
  34. package/dist/parser/ast.d.ts +39 -0
  35. package/dist/parser/ast.d.ts.map +1 -1
  36. package/dist/parser/index.d.ts.map +1 -1
  37. package/dist/parser/index.js +58 -2
  38. package/dist/parser/index.js.map +1 -1
  39. package/dist/parser/validator.d.ts.map +1 -1
  40. package/dist/parser/validator.js +66 -3
  41. package/dist/parser/validator.js.map +1 -1
  42. package/dist/visualizer/__tests__/brain.test.d.ts +2 -0
  43. package/dist/visualizer/__tests__/brain.test.d.ts.map +1 -0
  44. package/dist/visualizer/__tests__/brain.test.js +138 -0
  45. package/dist/visualizer/__tests__/brain.test.js.map +1 -0
  46. package/dist/visualizer/__tests__/topology-brain-link.test.d.ts +2 -0
  47. package/dist/visualizer/__tests__/topology-brain-link.test.d.ts.map +1 -0
  48. package/dist/visualizer/__tests__/topology-brain-link.test.js +41 -0
  49. package/dist/visualizer/__tests__/topology-brain-link.test.js.map +1 -0
  50. package/dist/visualizer/brain.d.ts +97 -0
  51. package/dist/visualizer/brain.d.ts.map +1 -0
  52. package/dist/visualizer/brain.js +473 -0
  53. package/dist/visualizer/brain.js.map +1 -0
  54. package/dist/visualizer/index.d.ts +19 -1
  55. package/dist/visualizer/index.d.ts.map +1 -1
  56. package/dist/visualizer/index.js +26 -11
  57. package/dist/visualizer/index.js.map +1 -1
  58. package/examples/company-brain-team.at +241 -0
  59. package/examples/company-brain.at +104 -0
  60. package/examples/logos/gmail.svg +1 -0
  61. package/examples/logos/slack.svg +1 -0
  62. package/examples/matchmat-ship.at +249 -0
  63. package/package.json +1 -1
  64. package/spec/grammar.md +42 -3
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Hybrid end-to-end test.
3
+ *
4
+ * Unit tests check each binding in isolation. THIS test compiles BOTH bindings
5
+ * from the SAME .at file and proves they COMPOSE into one coherent hybrid:
6
+ * - the host (claude-code) and the rung (claude-workflow) partition the agents
7
+ * with no overlap and no gap,
8
+ * - they agree on the seam (workflow filename, Blackboard root, SEAM doc),
9
+ * - the generated workflow.js is syntactically valid JavaScript,
10
+ * - nothing is silently dropped (the LOSSY report exists).
11
+ *
12
+ * This is the real "does the hybrid work end to end" gate.
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ import { readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import path from "node:path";
18
+ import os from "node:os";
19
+ import { spawnSync } from "node:child_process";
20
+ import { parse } from "../../parser/index.js";
21
+ import { claudeCodeBinding, claudeWorkflowBinding } from "../index.js";
22
+ import { seamFiles, isWorkflowSeamAgent, isHybridTopology } from "../lib/seam.js";
23
+ const here = path.dirname(fileURLToPath(import.meta.url));
24
+ const fixturePath = path.resolve(here, "../../../examples/matchmat-ship.at");
25
+ const ast = parse(readFileSync(fixturePath, "utf-8"));
26
+ const name = ast.topology.name;
27
+ const hostFiles = claudeCodeBinding.scaffold(ast);
28
+ const wfFiles = claudeWorkflowBinding.scaffold(ast);
29
+ const hostPaths = hostFiles.map((f) => f.path);
30
+ const wfPaths = wfFiles.map((f) => f.path);
31
+ const agents = ast.nodes.filter((n) => n.type === "agent");
32
+ const wfAgents = agents.filter(isWorkflowSeamAgent).map((a) => a.id);
33
+ const hostAgents = agents.filter((a) => !isWorkflowSeamAgent(a)).map((a) => a.id);
34
+ describe("hybrid e2e: matchmat-ship.at → claude-code + claude-workflow", () => {
35
+ it("the fixture is a hybrid topology", () => {
36
+ expect(isHybridTopology(ast)).toBe(true);
37
+ expect(wfAgents.length).toBeGreaterThan(0);
38
+ expect(hostAgents.length).toBeGreaterThan(0);
39
+ });
40
+ it("agents partition cleanly: workflow agents NOT in host, host agents NOT in workflow.js", () => {
41
+ const wfScript = wfFiles.find((f) => f.path === seamFiles.workflowScript(name)).content;
42
+ // workflow-marked agents must NOT have a host AGENT.md
43
+ for (const id of wfAgents) {
44
+ expect(hostPaths).not.toContain(`.claude/agents/${id}/AGENT.md`);
45
+ expect(wfScript).toContain(id); // and they DO appear in the rung
46
+ }
47
+ // host agents must have a host AGENT.md and must NOT be agent() calls in the rung
48
+ for (const id of hostAgents) {
49
+ expect(hostPaths).toContain(`.claude/agents/${id}/AGENT.md`);
50
+ }
51
+ });
52
+ it("both halves agree on the seam (filename, SEAM doc, Blackboard root)", () => {
53
+ const wfFile = seamFiles.workflowScript(name);
54
+ const seamDoc = seamFiles.seamDoc(name);
55
+ // workflow binding emits the rung + the SEAM contract + the LOSSY report
56
+ expect(wfPaths).toContain(wfFile);
57
+ expect(wfPaths).toContain(seamDoc);
58
+ expect(wfPaths).toContain(seamFiles.lossyDoc(name));
59
+ // host references the same workflow filename + seam doc in its CLAUDE.md
60
+ const claudeMd = hostFiles.find((f) => f.path === ".claude/CLAUDE.md").content;
61
+ expect(claudeMd).toContain(wfFile);
62
+ expect(claudeMd).toContain(seamDoc);
63
+ });
64
+ it("the generated workflow.js is syntactically valid JavaScript (ESM)", () => {
65
+ const wfScript = wfFiles.find((f) => f.path === seamFiles.workflowScript(name)).content;
66
+ // The rung is an ES module (export const meta / export default). vm.Script is
67
+ // CommonJS-mode and rejects `export`, so validate via `node --check` on a temp
68
+ // .mjs file (the real syntax gate; we never execute it).
69
+ const tmp = path.join(os.tmpdir(), `at-e2e-${process.pid}.mjs`);
70
+ writeFileSync(tmp, wfScript);
71
+ try {
72
+ const res = spawnSync("node", ["--check", tmp], { encoding: "utf-8" });
73
+ expect(res.status, res.stderr).toBe(0);
74
+ }
75
+ finally {
76
+ rmSync(tmp, { force: true });
77
+ }
78
+ // sanity: pure-literal meta + at least one parallel fan-out
79
+ expect(wfScript).toContain("export const meta");
80
+ expect(wfScript).toMatch(/parallel\(/);
81
+ });
82
+ it("the host wires concurrent observability over the Blackboard (the hybrid's point)", () => {
83
+ const settings = hostFiles.find((f) => f.path === ".claude/settings.json");
84
+ expect(settings).toBeDefined();
85
+ // a PostToolUse observer must exist when the topology has a Blackboard
86
+ expect(settings.content).toMatch(/PostToolUse|observe/i);
87
+ });
88
+ it("nothing is silently dropped: the LOSSY report names the human split", () => {
89
+ const lossy = wfFiles.find((f) => f.path === seamFiles.lossyDoc(name)).content;
90
+ expect(lossy).toMatch(/human/i);
91
+ expect(lossy).toMatch(/UNREPRESENTABLE/);
92
+ });
93
+ });
94
+ //# sourceMappingURL=hybrid-e2e.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hybrid-e2e.test.js","sourceRoot":"","sources":["../../../src/bindings/__tests__/hybrid-e2e.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../src/bindings/claude-code.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAuBH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAC;AAs8E/D,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,EAAE,aA0F/B,CAAC"}
1
+ {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../src/bindings/claude-code.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAuBH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAC;AAu4F/D,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,EAAE,aAkG/B,CAAC"}
@@ -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
  // ---------------------------------------------------------------------------
@@ -106,6 +107,142 @@ function getDelegationMode(ast) {
106
107
  function gitkeep(dirPath) {
107
108
  return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
108
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 standard meaning of each custody maintenance verb. The verb NAMES are
137
+ * language vocabulary (public); these one-line charters tell the agent what each
138
+ * verb means operationally. The deeper "how to do it brilliantly" stays in the
139
+ * skill repo (MOAT) — these are intentionally the safe, obvious definitions.
140
+ */
141
+ const CUSTODY_VERBS = {
142
+ link: "find mentions of existing notes and turn them into `[[wikilinks]]` (add-only — never delete a human's links)",
143
+ tag: "assign `#nested/tags` consistent with the tag hierarchy already present in the vault",
144
+ index: "update the relevant hub note (a Map-of-Content that `[[links]]` the cluster) so the new note is reachable",
145
+ backlink: "rely on computed backlinks from the `[[links]]` you write — never author backlinks by hand",
146
+ dedupe: "flag near-duplicate notes for human review — never auto-merge or delete",
147
+ summarize: "keep a short synthesized summary at the top of hub notes, refreshed when the cluster changes",
148
+ heal: "repair broken links after renames and surface ghost nodes (`[[links]]` to notes that don't exist yet)",
149
+ };
150
+ /**
151
+ * Generate the custody charter section for an agent that is `custodian-of` one
152
+ * or more stores. This is what makes custody a real binding behavior: the agent
153
+ * gets a standing instruction set to MAINTAIN the store(s), not just read them.
154
+ */
155
+ function generateCustodyCharter(agent, storeMap) {
156
+ const out = [];
157
+ out.push("## Custody — you maintain these stores");
158
+ out.push("");
159
+ out.push("You are the **custodian** of the store(s) below: you own their upkeep, not just read access. " +
160
+ "Humans drop raw notes; your job is to wire them into the graph and keep it healthy.");
161
+ out.push("");
162
+ for (const storeId of agent.custodianOf ?? []) {
163
+ const store = storeMap.get(storeId);
164
+ out.push(`### ${storeId}`);
165
+ if (store?.path) {
166
+ out.push(`The vault lives at \`${store.path}\`. Maintain it with \`Read\`, \`Write\`, \`Edit\`, \`Grep\`, \`Glob\` — no database, just files.`);
167
+ }
168
+ if (store?.format === "obsidian") {
169
+ out.push("It is **Obsidian-format** markdown: only ever write `[[wikilinks]]`, `#tags`, `![[embeds]]`, and YAML frontmatter — nothing custom — so the vault round-trips in Obsidian.");
170
+ }
171
+ out.push("");
172
+ // Maintenance verbs — declared ones, or the safe default set.
173
+ const verbs = (agent.custodianDoes && agent.custodianDoes.length > 0)
174
+ ? agent.custodianDoes
175
+ : ["link", "tag", "index", "heal", "dedupe"];
176
+ out.push("When a note is added or changed, perform these maintenance steps:");
177
+ for (const verb of verbs) {
178
+ const meaning = CUSTODY_VERBS[verb] ?? `apply the \`${verb}\` maintenance step`;
179
+ out.push(`- **${verb}** — ${meaning}`);
180
+ }
181
+ out.push("");
182
+ out.push("Wire add-only and conservatively: prefer leaving a human's content untouched over a risky rewrite. When unsure, flag for review rather than edit.");
183
+ out.push("");
184
+ }
185
+ return out;
186
+ }
187
+ /**
188
+ * The set of phases that compile into the embedded workflow rung — every phase
189
+ * that contains ≥1 agent marked `execution: workflow`. (Matches the
190
+ * claude-workflow binding's `workflowPhases`: an agent sharing a workflow phase
191
+ * is folded into the rung too.)
192
+ */
193
+ function workflowPhaseSet(ast) {
194
+ const phases = new Set();
195
+ for (const n of ast.nodes) {
196
+ if (n.type === "agent" && isWorkflowSeamAgent(n)) {
197
+ phases.add(agentPhase(n));
198
+ }
199
+ }
200
+ return phases;
201
+ }
202
+ /** Does this topology have ≥1 workflow-marked phase (i.e. is it a hybrid)? */
203
+ function isHybridTopology(ast) {
204
+ return workflowPhaseSet(ast).size > 0;
205
+ }
206
+ /**
207
+ * The Blackboard root path from `memory.workspace.path`, or null. Both bindings
208
+ * key the seam off this path.
209
+ */
210
+ function workspaceRoot(ast) {
211
+ const ws = ast.memory?.workspace;
212
+ return ws?.path ?? null;
213
+ }
214
+ /**
215
+ * Group the workflow-marked phases into contiguous runs (by sorted phase
216
+ * number). Each contiguous run is ONE launch directive (the host launches the
217
+ * workflow.js once for that span). Returns a list of phase-number arrays.
218
+ */
219
+ function contiguousWorkflowPhaseRuns(ast) {
220
+ const sorted = [...workflowPhaseSet(ast)].sort((a, b) => a - b);
221
+ const runs = [];
222
+ let current = [];
223
+ for (const p of sorted) {
224
+ if (current.length === 0 || p === current[current.length - 1] + 1) {
225
+ current.push(p);
226
+ }
227
+ else {
228
+ runs.push(current);
229
+ current = [p];
230
+ }
231
+ }
232
+ if (current.length > 0)
233
+ runs.push(current);
234
+ return runs;
235
+ }
236
+ /** A short, stable label for a phase run (used in stub filenames). */
237
+ function workflowRunLabel(ast, run) {
238
+ // Prefer the first workflow-marked agent id in the run's lowest phase — that
239
+ // reads naturally (e.g. "build" from `builder`). Fall back to phase numbers.
240
+ const lowest = run[0];
241
+ const agentInPhase = ast.nodes.find((n) => n.type === "agent" && agentPhase(n) === lowest && isWorkflowSeamAgent(n));
242
+ if (agentInPhase)
243
+ return agentInPhase.id;
244
+ return run.length === 1 ? `phase-${run[0]}` : `phases-${run[0]}-${run[run.length - 1]}`;
245
+ }
109
246
  /**
110
247
  * Map topology permission strings to Claude Code permissionMode values.
111
248
  * Returns undefined when the permission mode should be omitted (default/autonomous).
@@ -242,6 +379,13 @@ function generateAgents(ast) {
242
379
  if (node.type !== "agent")
243
380
  continue;
244
381
  const agent = node;
382
+ // HYBRID SEAM: an agent marked `execution: workflow` is owned by the
383
+ // embedded workflow rung (claude-workflow binding → <topology>.workflow.js),
384
+ // NOT by the host. The host does not run it as a subagent — it launches the
385
+ // workflow rung and observes its Blackboard writes. So do NOT emit a
386
+ // .claude/agents/<name>/AGENT.md for it. See generateWorkflowLaunchArtifacts.
387
+ if (isWorkflowSeamAgent(agent))
388
+ continue;
245
389
  // Build frontmatter fields
246
390
  const fm = {};
247
391
  fm.name = agent.id;
@@ -430,6 +574,13 @@ function generateAgents(ast) {
430
574
  sections.push("");
431
575
  }
432
576
  }
577
+ // Custody — the agent OWNS the upkeep of these stores (the `brain` pattern).
578
+ // Generated even when the agent has no hand-written prompt, so custody is a
579
+ // real binding behavior, not just prose the author happened to write.
580
+ if (agent.custodianOf && agent.custodianOf.length > 0) {
581
+ const storeMap = new Map((ast.stores ?? []).map(s => [s.id, s]));
582
+ sections.push(...generateCustodyCharter(agent, storeMap));
583
+ }
433
584
  // Retrieval strategy — rich details
434
585
  if (agent.retrieval) {
435
586
  const retrieval = (ast.retrievals ?? []).find(r => r.id === agent.retrieval);
@@ -1227,6 +1378,12 @@ function generateTopologySkill(ast) {
1227
1378
  sections.push("");
1228
1379
  }
1229
1380
  }
1381
+ // HYBRID run order — mirrors the claude-workflow SEAM.md so the host playbook
1382
+ // knows where it launches the embedded workflow rung and observes its writes.
1383
+ const hybridRunOrder = buildHybridRunOrderSection(ast);
1384
+ if (hybridRunOrder) {
1385
+ sections.push(hybridRunOrder);
1386
+ }
1230
1387
  files.push({
1231
1388
  path: `.claude/skills/${name}/SKILL.md`,
1232
1389
  content: sections.join("\n") + "\n",
@@ -1330,13 +1487,52 @@ function generateMemory(ast) {
1330
1487
  if (memory.workspace) {
1331
1488
  files.push(gitkeep(`.claude/skills/${name}/runs`));
1332
1489
  }
1333
- // Store directory markers
1490
+ // Store directory markers. A `brain` store is a real, user-facing Obsidian
1491
+ // vault — it lives at its declared `path:` (e.g. `brain/`), not buried under
1492
+ // `.claude/stores/`, so a human can open the folder directly in Obsidian.
1493
+ // We seed it with a root Map-of-Content note so the vault opens to something.
1334
1494
  const stores = ast.stores ?? [];
1335
1495
  for (const store of stores) {
1336
- files.push(gitkeep(`.claude/stores/${store.id}`));
1496
+ if (store.type === "brain") {
1497
+ const vault = (store.path ?? `${store.id}/`).replace(/\/+$/, "");
1498
+ files.push(brainVaultSeed(store, vault));
1499
+ }
1500
+ else {
1501
+ files.push(gitkeep(`.claude/stores/${store.id}`));
1502
+ }
1337
1503
  }
1338
1504
  return files;
1339
1505
  }
1506
+ /**
1507
+ * Seed a brain vault with a root index note (`_brain.md`) — an Obsidian Map-of-
1508
+ * Content the librarian grows over time. Gives the vault a home page and proves
1509
+ * the format contract (wikilinks, tags, frontmatter) from the first commit.
1510
+ */
1511
+ function brainVaultSeed(store, vault) {
1512
+ const lines = [
1513
+ "---",
1514
+ "tags:",
1515
+ " - brain/index",
1516
+ "---",
1517
+ "",
1518
+ `# ${toTitle(store.id)}`,
1519
+ "",
1520
+ store.description ? `> ${store.description}` : "> The company brain.",
1521
+ "",
1522
+ "This is the root index of the brain — a Map of Content the librarian maintains.",
1523
+ "Drop new notes into this folder and run `/wire-in <file>` to fold them in.",
1524
+ "",
1525
+ "## Clusters",
1526
+ "",
1527
+ "_New hub notes appear here as the brain grows._",
1528
+ "",
1529
+ ];
1530
+ return {
1531
+ path: `${vault}/_brain.md`,
1532
+ content: lines.join("\n"),
1533
+ category: "machine",
1534
+ };
1535
+ }
1340
1536
  /**
1341
1537
  * Generate memory command files for each store.
1342
1538
  *
@@ -1763,6 +1959,21 @@ function generateSettings(ast) {
1763
1959
  };
1764
1960
  hooksByEvent[eventName].push(hookEntry);
1765
1961
  }
1962
+ // HYBRID: host-layer concurrent Blackboard observer.
1963
+ //
1964
+ // The topology's bare-event `hooks { PostToolUse { matcher: "Write" ... } }`
1965
+ // block is not surfaced into ast.hooks (only the named `hook <name> {}` form is
1966
+ // parsed). We don't change the parser (out of scope); instead, when this is a
1967
+ // hybrid topology WITH a Blackboard, we synthesize the observe-hook: a
1968
+ // Blackboard implies a host-side observer that watches the workflow rung's
1969
+ // Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
1970
+ // observability (the deterministic workflow rung cannot express it).
1971
+ const observeHook = buildHybridObserveHook(ast, topologyName);
1972
+ if (observeHook) {
1973
+ if (!hooksByEvent[observeHook.event])
1974
+ hooksByEvent[observeHook.event] = [];
1975
+ hooksByEvent[observeHook.event].push(observeHook.entry);
1976
+ }
1766
1977
  // Write hooksByEvent to settings if any hooks or gates were registered
1767
1978
  if (Object.keys(hooksByEvent).length > 0) {
1768
1979
  settings.hooks = hooksByEvent;
@@ -2319,12 +2530,198 @@ function generateContextFile(ast) {
2319
2530
  }
2320
2531
  sections.push("");
2321
2532
  }
2533
+ // HYBRID run order — when this topology composes with an embedded
2534
+ // claude-workflow rung, document who runs/launches/observes what.
2535
+ const hybridRunOrder = buildHybridRunOrderSection(ast);
2536
+ if (hybridRunOrder) {
2537
+ sections.push(hybridRunOrder);
2538
+ }
2322
2539
  return {
2323
2540
  path: fileName,
2324
2541
  content: sections.join("\n") + "\n",
2325
2542
  category: "machine",
2326
2543
  };
2327
2544
  }
2545
+ /**
2546
+ * HYBRID: emit a host-side "launch the workflow rung" skill stub for each
2547
+ * contiguous workflow-marked phase run.
2548
+ *
2549
+ * In the hybrid model the host does NOT run the workflow-marked agents as
2550
+ * subagents. At the corresponding point in the flow, the host LAUNCHES the
2551
+ * embedded deterministic workflow rung (`<topology>.workflow.js`, emitted by the
2552
+ * sibling claude-workflow binding). These stubs are the documented launch
2553
+ * directives, scoped into the topology's skill scripts directory next to the
2554
+ * gate/hook stubs (same AGENTOPOLOGY_STUB mechanism so CI can detect them).
2555
+ */
2556
+ function generateWorkflowLaunchArtifacts(ast) {
2557
+ const files = [];
2558
+ if (!isHybridTopology(ast))
2559
+ return files;
2560
+ const name = ast.topology.name;
2561
+ const workflowFile = seamFiles.workflowScript(name);
2562
+ const seamDoc = seamFiles.seamDoc(name);
2563
+ const root = workspaceRoot(ast);
2564
+ const runs = contiguousWorkflowPhaseRuns(ast);
2565
+ for (const run of runs) {
2566
+ const label = workflowRunLabel(ast, run);
2567
+ // Agents that this rung owns (for documentation in the stub body).
2568
+ const ownedAgents = ast.nodes
2569
+ .filter((n) => n.type === "agent" && run.includes(agentPhase(n)) && isWorkflowSeamAgent(n))
2570
+ .map((a) => a.id);
2571
+ const phaseLabel = run.length === 1 ? `phase ${run[0]}` : `phases ${run[0]}–${run[run.length - 1]}`;
2572
+ const body = [
2573
+ "#!/usr/bin/env bash",
2574
+ `# Launch the embedded workflow rung for ${phaseLabel} (${ownedAgents.join(", ")}).`,
2575
+ "# Auto-generated by agentopology scaffold — edit as needed.",
2576
+ STUB_MARKER,
2577
+ "set -euo pipefail",
2578
+ "",
2579
+ `# HYBRID SEAM (host -> embedded workflow). This step does NOT run`,
2580
+ `# ${ownedAgents.join(", ")} as host subagent(s). Instead the host LAUNCHES the`,
2581
+ `# deterministic workflow rung that owns ${phaseLabel}, emitted by the`,
2582
+ `# claude-workflow binding as: ${workflowFile}`,
2583
+ "#",
2584
+ "# The rung reads its inputs from the Blackboard (seeded by the host's prior",
2585
+ `# phase) and writes its results back so host hooks observe them LIVE.`,
2586
+ root ? `# Blackboard root (shared by host + rung): ${root}` : "# Blackboard root: (memory.workspace.path not declared)",
2587
+ `# Seam contract both bindings honor: ${seamDoc}`,
2588
+ "#",
2589
+ "# Claude Workflows are launched IN-SESSION via the /workflow keyword, not as a",
2590
+ "# detached process. The orchestrator invokes the workflow at this step:",
2591
+ "#",
2592
+ `# /workflow ${workflowFile}`,
2593
+ "#",
2594
+ "# If your harness launches workflows out-of-process instead, replace the line",
2595
+ `# below with the real launcher (e.g. \`claude workflow ${workflowFile}\`).`,
2596
+ "",
2597
+ `echo "TODO: launch the workflow rung — invoke '/workflow ${workflowFile}' in-session (${phaseLabel}: ${ownedAgents.join(", ")})."`,
2598
+ "",
2599
+ ].join("\n");
2600
+ files.push({
2601
+ path: `.claude/skills/${name}/scripts/launch-${label}-workflow.sh`,
2602
+ content: body,
2603
+ category: "script",
2604
+ executable: true,
2605
+ });
2606
+ }
2607
+ return files;
2608
+ }
2609
+ /**
2610
+ * HYBRID: build the "Hybrid run order" markdown block appended to the generated
2611
+ * context file (.claude/CLAUDE.md) and the topology SKILL.md. It states the
2612
+ * host's interleaving with the embedded workflow rung, in the SAME run-order
2613
+ * vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
2614
+ * stay in sync. Returns null when the topology is not a hybrid.
2615
+ */
2616
+ function buildHybridRunOrderSection(ast) {
2617
+ if (!isHybridTopology(ast))
2618
+ return null;
2619
+ const name = ast.topology.name;
2620
+ const workflowFile = seamFiles.workflowScript(name);
2621
+ const seamDoc = seamFiles.seamDoc(name);
2622
+ const root = workspaceRoot(ast);
2623
+ const wfPhases = workflowPhaseSet(ast);
2624
+ const agents = ast.nodes.filter((n) => n.type === "agent");
2625
+ const human = ast.nodes.find((n) => n.type === "human") ?? null;
2626
+ const allPhases = [...new Set(agents.map(agentPhase))].sort((a, b) => a - b);
2627
+ const idsInPhase = (p) => agents.filter((a) => agentPhase(a) === p).map((a) => a.id).join(", ");
2628
+ const lines = [];
2629
+ lines.push("## Hybrid run order");
2630
+ lines.push("");
2631
+ 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}\`.`);
2632
+ lines.push("");
2633
+ if (root) {
2634
+ lines.push(`**Blackboard root** (shared by host + rung): \`${root}\``);
2635
+ lines.push("");
2636
+ }
2637
+ lines.push("Run order:");
2638
+ lines.push("");
2639
+ let step = 1;
2640
+ for (const p of allPhases) {
2641
+ if (wfPhases.has(p)) {
2642
+ 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}\`).`);
2643
+ }
2644
+ else {
2645
+ lines.push(`${step}. **Host (in-session)** runs phase ${p} (${idsInPhase(p)}) — writes its outputs to the Blackboard so the next rung can read them.`);
2646
+ }
2647
+ step++;
2648
+ }
2649
+ if (human) {
2650
+ lines.push(`${step}. **Host** presents the Blackboard artifacts to the human \`${human.id}\`, who acts (e.g. promotes). Promotion triggers the SEPARATE downstream workflow.`);
2651
+ }
2652
+ lines.push("");
2653
+ 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.`);
2654
+ lines.push("");
2655
+ return lines.join("\n");
2656
+ }
2657
+ /**
2658
+ * HYBRID: emit the host-layer concurrent Blackboard observer hook into
2659
+ * settings.json.
2660
+ *
2661
+ * Rationale: the topology's `hooks { PostToolUse { matcher: "Write" ... } }`
2662
+ * block is the bare-event form, which the parser does NOT currently surface into
2663
+ * `ast.hooks` (only the named `hook <name> {}` form is parsed; changing the
2664
+ * parser is out of scope). So we synthesize the observer hook from the presence
2665
+ * of a Blackboard (`memory.workspace`): a Blackboard in a hybrid topology IMPLIES
2666
+ * a host-side observer that watches the workflow rung's Write tool-calls live.
2667
+ *
2668
+ * Returns the hook entry to merge into settings.hooks.PostToolUse, or null.
2669
+ */
2670
+ function buildHybridObserveHook(ast, topologyName) {
2671
+ if (!isHybridTopology(ast))
2672
+ return null;
2673
+ if (!workspaceRoot(ast))
2674
+ return null; // no Blackboard → no observer
2675
+ // If a real ast.hook already targets PostToolUse/Write, the generic hook path
2676
+ // emits it — don't double up.
2677
+ const alreadyEmitted = ast.hooks.some((h) => h.on === "PostToolUse" && (h.matcher === "Write" || !h.matcher));
2678
+ if (alreadyEmitted)
2679
+ return null;
2680
+ const scriptPath = `.claude/skills/${topologyName}/scripts/observe-blackboard.sh`;
2681
+ return {
2682
+ event: "PostToolUse",
2683
+ scriptPath,
2684
+ entry: {
2685
+ matcher: "Write",
2686
+ hooks: [
2687
+ {
2688
+ type: "command",
2689
+ command: `bash ${scriptPath}`,
2690
+ },
2691
+ ],
2692
+ },
2693
+ };
2694
+ }
2695
+ /** HYBRID: the observe-blackboard.sh stub the observe-hook points at. */
2696
+ function generateObserveBlackboardScript(ast) {
2697
+ const hook = buildHybridObserveHook(ast, ast.topology.name);
2698
+ if (!hook)
2699
+ return null;
2700
+ const root = workspaceRoot(ast) ?? "workspace/";
2701
+ const body = [
2702
+ "#!/usr/bin/env bash",
2703
+ "# Host-layer concurrent Blackboard observer.",
2704
+ "# Auto-generated by agentopology scaffold — edit as needed.",
2705
+ STUB_MARKER,
2706
+ "set -euo pipefail",
2707
+ "",
2708
+ "# HYBRID SEAM (host observability for the embedded workflow rung).",
2709
+ "# Wired as a PostToolUse(Write) hook in .claude/settings.json. It fires on every",
2710
+ `# Write tool-call so the host can watch the Blackboard (${root}) the workflow`,
2711
+ "# rung's agents write — that IS the concurrent observability the deterministic",
2712
+ "# workflow rung itself cannot express. Stdin JSON payload is on FD 0.",
2713
+ "",
2714
+ "PAYLOAD=$(cat)",
2715
+ `echo "TODO: observe Blackboard write under ${root} — inspect the payload and log/notify."`,
2716
+ "",
2717
+ ].join("\n");
2718
+ return {
2719
+ path: hook.scriptPath,
2720
+ content: body,
2721
+ category: "script",
2722
+ executable: true,
2723
+ };
2724
+ }
2328
2725
  /**
2329
2726
  * Merge `ast.env` into the settings.json output under an `env` key.
2330
2727
  *
@@ -2438,6 +2835,13 @@ export const claudeCodeBinding = {
2438
2835
  files.push(...generateHookScripts(ast));
2439
2836
  // Hook scripts (per-agent)
2440
2837
  files.push(...generateAgentHookScripts(ast));
2838
+ // HYBRID: host-side launch directives for the embedded workflow rung(s).
2839
+ files.push(...generateWorkflowLaunchArtifacts(ast));
2840
+ // HYBRID: host-layer concurrent Blackboard observer script (paired with the
2841
+ // synthesized PostToolUse(Write) hook in settings.json).
2842
+ const observeScript = generateObserveBlackboardScript(ast);
2843
+ if (observeScript)
2844
+ files.push(observeScript);
2441
2845
  // Tool scripts
2442
2846
  files.push(...generateToolScripts(ast));
2443
2847
  // Schedule scripts