agentopology 0.2.4 → 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 (32) hide show
  1. package/README.md +23 -7
  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 +307 -1
  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/examples/matchmat-ship.at +249 -0
  31. package/package.json +1 -1
  32. package/spec/grammar.md +12 -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
  // ---------------------------------------------------------------------------
@@ -106,6 +107,90 @@ 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 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
+ }
109
194
  /**
110
195
  * Map topology permission strings to Claude Code permissionMode values.
111
196
  * Returns undefined when the permission mode should be omitted (default/autonomous).
@@ -242,6 +327,13 @@ function generateAgents(ast) {
242
327
  if (node.type !== "agent")
243
328
  continue;
244
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;
245
337
  // Build frontmatter fields
246
338
  const fm = {};
247
339
  fm.name = agent.id;
@@ -1227,6 +1319,12 @@ function generateTopologySkill(ast) {
1227
1319
  sections.push("");
1228
1320
  }
1229
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
+ }
1230
1328
  files.push({
1231
1329
  path: `.claude/skills/${name}/SKILL.md`,
1232
1330
  content: sections.join("\n") + "\n",
@@ -1763,6 +1861,21 @@ function generateSettings(ast) {
1763
1861
  };
1764
1862
  hooksByEvent[eventName].push(hookEntry);
1765
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
+ }
1766
1879
  // Write hooksByEvent to settings if any hooks or gates were registered
1767
1880
  if (Object.keys(hooksByEvent).length > 0) {
1768
1881
  settings.hooks = hooksByEvent;
@@ -2319,12 +2432,198 @@ function generateContextFile(ast) {
2319
2432
  }
2320
2433
  sections.push("");
2321
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
+ }
2322
2441
  return {
2323
2442
  path: fileName,
2324
2443
  content: sections.join("\n") + "\n",
2325
2444
  category: "machine",
2326
2445
  };
2327
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
+ }
2328
2627
  /**
2329
2628
  * Merge `ast.env` into the settings.json output under an `env` key.
2330
2629
  *
@@ -2438,6 +2737,13 @@ export const claudeCodeBinding = {
2438
2737
  files.push(...generateHookScripts(ast));
2439
2738
  // Hook scripts (per-agent)
2440
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);
2441
2747
  // Tool scripts
2442
2748
  files.push(...generateToolScripts(ast));
2443
2749
  // Schedule scripts