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.
- package/README.md +92 -10
- package/dist/bindings/__tests__/bindings.test.js +2 -2
- 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 +407 -3
- 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/cli/index.js +195 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/parser/__tests__/parser.test.js +170 -0
- package/dist/parser/__tests__/parser.test.js.map +1 -1
- package/dist/parser/ast.d.ts +39 -0
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +58 -2
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +66 -3
- package/dist/parser/validator.js.map +1 -1
- package/dist/visualizer/__tests__/brain.test.d.ts +2 -0
- package/dist/visualizer/__tests__/brain.test.d.ts.map +1 -0
- package/dist/visualizer/__tests__/brain.test.js +138 -0
- package/dist/visualizer/__tests__/brain.test.js.map +1 -0
- package/dist/visualizer/__tests__/topology-brain-link.test.d.ts +2 -0
- package/dist/visualizer/__tests__/topology-brain-link.test.d.ts.map +1 -0
- package/dist/visualizer/__tests__/topology-brain-link.test.js +41 -0
- package/dist/visualizer/__tests__/topology-brain-link.test.js.map +1 -0
- package/dist/visualizer/brain.d.ts +97 -0
- package/dist/visualizer/brain.d.ts.map +1 -0
- package/dist/visualizer/brain.js +473 -0
- package/dist/visualizer/brain.js.map +1 -0
- package/dist/visualizer/index.d.ts +19 -1
- package/dist/visualizer/index.d.ts.map +1 -1
- package/dist/visualizer/index.js +26 -11
- package/dist/visualizer/index.js.map +1 -1
- package/examples/company-brain-team.at +241 -0
- package/examples/company-brain.at +104 -0
- package/examples/logos/gmail.svg +1 -0
- package/examples/logos/slack.svg +1 -0
- package/examples/matchmat-ship.at +249 -0
- package/package.json +1 -1
- 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;
|
|
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
|
-
|
|
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
|