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.
- package/README.md +23 -7
- 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 +307 -1
- 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/examples/matchmat-ship.at +249 -0
- package/package.json +1 -1
- package/spec/grammar.md +12 -1
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-workflow binding.
|
|
3
|
+
*
|
|
4
|
+
* Compiles the DETERMINISTIC fan-out phases of an `.at` topology into a Claude
|
|
5
|
+
* Workflow script (`<topology>.workflow.js`), plus the host-side seam glue
|
|
6
|
+
* (a Blackboard seam contract doc) and a loud LOSSY report.
|
|
7
|
+
*
|
|
8
|
+
* This is the embedded-workflow half of the HYBRID model documented in
|
|
9
|
+
* `docs/AT_VS_WORKFLOW_STRATEGY.md`. The claude-code binding emits the HOST
|
|
10
|
+
* (roles, flow, hooks, Blackboard init, concurrent observability); THIS binding
|
|
11
|
+
* emits the deterministic parallel phase(s) marked `execution: workflow`.
|
|
12
|
+
*
|
|
13
|
+
* The spec it implements: `docs/bindings/claude-workflow-mapping.md`.
|
|
14
|
+
*
|
|
15
|
+
* Hard rules from the spec:
|
|
16
|
+
* - The Workflow runtime forbids mid-run human input → a `human` node SPLITS
|
|
17
|
+
* the topology: the workflow.js ends BEFORE the human node and a README
|
|
18
|
+
* documents the handoff (regression is a separate downstream workflow).
|
|
19
|
+
* - The Workflow runtime is deterministic → no Date.now / Math.random; loops
|
|
20
|
+
* are bounded by literal counts and labeled by index.
|
|
21
|
+
* - UNREPRESENTABLE primitives are NEVER silently dropped: they surface as
|
|
22
|
+
* `// UNREPRESENTABLE:` comments in the script AND in a LOSSY-REPORT.md.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { deduplicateFiles } from "./types.js";
|
|
27
|
+
import { SEAM_NS, isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
|
|
28
|
+
/** Mutable collector threaded through the generators. */
|
|
29
|
+
class LossLedger {
|
|
30
|
+
notes = [];
|
|
31
|
+
seen = new Set();
|
|
32
|
+
add(note) {
|
|
33
|
+
const key = `${note.severity}|${note.primitive}|${note.scope}|${note.reason}`;
|
|
34
|
+
if (this.seen.has(key))
|
|
35
|
+
return;
|
|
36
|
+
this.seen.add(key);
|
|
37
|
+
this.notes.push(note);
|
|
38
|
+
}
|
|
39
|
+
has(severity) {
|
|
40
|
+
return this.notes.some((n) => n.severity === severity);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Small helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Escape a string for embedding inside a single-quoted JS string literal. */
|
|
47
|
+
function sq(s) {
|
|
48
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
49
|
+
}
|
|
50
|
+
/** Escape for a JS template literal (backtick). */
|
|
51
|
+
function tl(s) {
|
|
52
|
+
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
53
|
+
}
|
|
54
|
+
/** Collapse a multi-line prompt into a single normalized string. */
|
|
55
|
+
function flattenPrompt(prompt, fallback) {
|
|
56
|
+
if (!prompt)
|
|
57
|
+
return fallback;
|
|
58
|
+
return prompt
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((l) => l.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" ");
|
|
63
|
+
}
|
|
64
|
+
/** Indent every line of a block by `n` spaces. */
|
|
65
|
+
function indent(text, n) {
|
|
66
|
+
const pad = " ".repeat(n);
|
|
67
|
+
return text
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((l) => (l.length ? pad + l : l))
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build the prompt string for a workflow agent, embedding its Blackboard
|
|
74
|
+
* reads/writes (the script has no filesystem, so the paths live in the prompt).
|
|
75
|
+
*/
|
|
76
|
+
function buildAgentPrompt(agent, roles) {
|
|
77
|
+
const base = flattenPrompt(agent.prompt, agent.description ?? roles[agent.role ?? ""] ?? roles[agent.id] ?? `Run the ${agent.id} step.`);
|
|
78
|
+
const parts = [base];
|
|
79
|
+
if (agent.reads && agent.reads.length > 0) {
|
|
80
|
+
parts.push(`Read: ${agent.reads.join(", ")}.`);
|
|
81
|
+
}
|
|
82
|
+
if (agent.writes && agent.writes.length > 0) {
|
|
83
|
+
parts.push(`Write your result to: ${agent.writes.join(", ")}.`);
|
|
84
|
+
}
|
|
85
|
+
return parts.join(" ");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the `schema` option object literal from an agent's `outputs` enum map.
|
|
89
|
+
* `{ verdict: pass | fail }` → `{type:'object',properties:{verdict:{enum:['pass','fail']}},required:['verdict']}`.
|
|
90
|
+
*/
|
|
91
|
+
function buildSchemaLiteral(outputs) {
|
|
92
|
+
const props = [];
|
|
93
|
+
const required = [];
|
|
94
|
+
for (const [field, values] of Object.entries(outputs)) {
|
|
95
|
+
props.push(`${field}: { enum: [${values.map((v) => sq(v)).join(", ")}] }`);
|
|
96
|
+
required.push(sq(field));
|
|
97
|
+
}
|
|
98
|
+
return `{ type: 'object', properties: { ${props.join(", ")} }, required: [${required.join(", ")}] }`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the options object for an `agent(prompt, opts)` call.
|
|
102
|
+
* Returns the literal (without surrounding `{}`) or undefined when no opts.
|
|
103
|
+
*/
|
|
104
|
+
function buildAgentOpts(agent, extraLabel) {
|
|
105
|
+
const fields = [];
|
|
106
|
+
const comments = [];
|
|
107
|
+
fields.push(`label: ${sq(extraLabel ?? agent.id)}`);
|
|
108
|
+
if (agent.model)
|
|
109
|
+
fields.push(`model: ${sq(agent.model)}`);
|
|
110
|
+
if (agent.isolation)
|
|
111
|
+
fields.push(`isolation: ${sq(agent.isolation)}`);
|
|
112
|
+
if (agent.outputs && Object.keys(agent.outputs).length > 0) {
|
|
113
|
+
fields.push(`schema: ${buildSchemaLiteral(agent.outputs)}`);
|
|
114
|
+
}
|
|
115
|
+
// §8: permissions are session-fixed, not per-agent in-script → comment only.
|
|
116
|
+
if (agent.permissions) {
|
|
117
|
+
comments.push(`// permissions: ${agent.permissions} — session-fixed (set the launch session's allowlist, not in-script)`);
|
|
118
|
+
}
|
|
119
|
+
if (agent.maxTurns != null) {
|
|
120
|
+
comments.push(`// max-turns: ${agent.maxTurns} — UNREPRESENTABLE in-script (prompt-soft only)`);
|
|
121
|
+
}
|
|
122
|
+
return { opts: fields.length ? `{ ${fields.join(", ")} }` : undefined, comments };
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Phase extraction
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Seam constants/helpers are the single source of truth shared with the
|
|
128
|
+
// claude-code (host) binding — see ./lib/seam.ts. Local alias kept for brevity.
|
|
129
|
+
const isWorkflowSeam = isWorkflowSeamAgent;
|
|
130
|
+
/** Resolve an agent's phase (defaults to a large value so unphased trails). */
|
|
131
|
+
function phaseOf(agent) {
|
|
132
|
+
return agent.phase ?? Number.MAX_SAFE_INTEGER;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Determine which phases compile into the workflow.
|
|
136
|
+
*
|
|
137
|
+
* Per the spec: agents marked `execution: workflow` — AND every agent sharing
|
|
138
|
+
* that phase — compile into the embedded workflow. We collect the set of
|
|
139
|
+
* phases that contain at least one seam-marked agent.
|
|
140
|
+
*/
|
|
141
|
+
function workflowPhases(agents) {
|
|
142
|
+
const phases = new Set();
|
|
143
|
+
for (const a of agents) {
|
|
144
|
+
if (isWorkflowSeam(a))
|
|
145
|
+
phases.add(phaseOf(a));
|
|
146
|
+
}
|
|
147
|
+
return phases;
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// The workflow.js emitter
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
/** Generate one `phase()` block of agent calls (parallel when >1 agent). */
|
|
153
|
+
function emitPhaseBlock(phaseNum, agents, roles, ledger) {
|
|
154
|
+
const lines = [];
|
|
155
|
+
const title = `Phase ${phaseNum}`;
|
|
156
|
+
lines.push(` phase(${sq(title)});`);
|
|
157
|
+
// Build each agent's call expression + collect comments.
|
|
158
|
+
const calls = [];
|
|
159
|
+
for (const agent of agents) {
|
|
160
|
+
const prompt = buildAgentPrompt(agent, roles);
|
|
161
|
+
// scale → deterministic parallel fan-out (literal n from scale.max).
|
|
162
|
+
if (agent.scale) {
|
|
163
|
+
const n = agent.scale.max;
|
|
164
|
+
const { comments } = buildAgentOpts(agent);
|
|
165
|
+
ledger.add({
|
|
166
|
+
severity: "LOSSY",
|
|
167
|
+
primitive: "agent.scale",
|
|
168
|
+
scope: agent.id,
|
|
169
|
+
reason: `scale {min:${agent.scale.min}, max:${agent.scale.max}, by:${agent.scale.by}} → deterministic parallel fan-out of ${n} (literal max). The script cannot count fs/source items at runtime, so it fans out to the static max; tune via args before launch.`,
|
|
170
|
+
});
|
|
171
|
+
const optsFields = [
|
|
172
|
+
`label: \`${tl(agent.id)}-\${i}\``,
|
|
173
|
+
];
|
|
174
|
+
if (agent.model)
|
|
175
|
+
optsFields.push(`model: ${sq(agent.model)}`);
|
|
176
|
+
if (agent.isolation)
|
|
177
|
+
optsFields.push(`isolation: ${sq(agent.isolation)}`);
|
|
178
|
+
if (agent.outputs && Object.keys(agent.outputs).length > 0) {
|
|
179
|
+
optsFields.push(`schema: ${buildSchemaLiteral(agent.outputs)}`);
|
|
180
|
+
}
|
|
181
|
+
const promptExpr = `\`${tl(prompt)} (slice \${i + 1} of ${n})\``;
|
|
182
|
+
const thunk = [
|
|
183
|
+
`parallel(`,
|
|
184
|
+
` Array.from({ length: ${n} }, (_, i) => () =>`,
|
|
185
|
+
` agent(${promptExpr}, { ${optsFields.join(", ")} })`,
|
|
186
|
+
` )`,
|
|
187
|
+
`)`,
|
|
188
|
+
].join("\n");
|
|
189
|
+
calls.push({
|
|
190
|
+
thunk,
|
|
191
|
+
comments: [`// agent: ${agent.id} — scaled fan-out (deterministic, n=${n})`, ...comments],
|
|
192
|
+
scaled: true,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const { opts, comments } = buildAgentOpts(agent);
|
|
197
|
+
const callExpr = opts
|
|
198
|
+
? `agent(${sq(prompt)}, ${opts})`
|
|
199
|
+
: `agent(${sq(prompt)})`;
|
|
200
|
+
calls.push({ thunk: callExpr, comments });
|
|
201
|
+
}
|
|
202
|
+
if (calls.length === 1) {
|
|
203
|
+
const c = calls[0];
|
|
204
|
+
for (const cm of c.comments)
|
|
205
|
+
lines.push(` ${cm}`);
|
|
206
|
+
if (c.scaled) {
|
|
207
|
+
// already a parallel(...) expression
|
|
208
|
+
lines.push(indent(`await ${c.thunk};`, 2));
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
lines.push(` await ${c.thunk};`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Same-phase agents → parallel([...]) (barrier).
|
|
216
|
+
lines.push(` await parallel([`);
|
|
217
|
+
for (let i = 0; i < calls.length; i++) {
|
|
218
|
+
const c = calls[i];
|
|
219
|
+
for (const cm of c.comments)
|
|
220
|
+
lines.push(` ${cm}`);
|
|
221
|
+
if (c.scaled) {
|
|
222
|
+
lines.push(indent(`() => ${c.thunk},`, 4));
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
lines.push(` () => ${c.thunk},`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
lines.push(` ]);`);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Emit a gate as an imperative guard.
|
|
235
|
+
* bounce-back → bounded for-loop with per-iteration labels.
|
|
236
|
+
* halt → throw.
|
|
237
|
+
* advisory → log() (no throw).
|
|
238
|
+
*/
|
|
239
|
+
function emitGate(gate, ledger) {
|
|
240
|
+
const lines = [];
|
|
241
|
+
const checks = gate.checks ?? [];
|
|
242
|
+
const onFail = gate.onFail ?? "halt";
|
|
243
|
+
const behavior = gate.behavior ?? "blocking";
|
|
244
|
+
const gatePrompt = `Run the ${gate.id} gate. Verify: ${checks.length ? checks.join(", ") : "the prior phase outputs"}. ` +
|
|
245
|
+
(gate.run ? `(host script: ${gate.run}) ` : "") +
|
|
246
|
+
`Return {pass:boolean, reason:string}.`;
|
|
247
|
+
const schema = `{ type: 'object', properties: { pass: { type: 'boolean' }, reason: { type: 'string' } }, required: ['pass'] }`;
|
|
248
|
+
ledger.add({
|
|
249
|
+
severity: "LOSSY",
|
|
250
|
+
primitive: "gate",
|
|
251
|
+
scope: gate.id,
|
|
252
|
+
reason: `gate run-script '${gate.run ?? "(none)"}' cannot execute in-script (NO_SHELL). Re-expressed as a verify agent() that returns {pass,reason}; on-fail=${onFail}, behavior=${behavior}.`,
|
|
253
|
+
});
|
|
254
|
+
if (behavior === "advisory") {
|
|
255
|
+
lines.push(` // gate: ${gate.id} (advisory — logs, never halts)`);
|
|
256
|
+
lines.push(` {`);
|
|
257
|
+
lines.push(` const g = await agent(${sq(gatePrompt)}, { label: ${sq("gate-" + gate.id)}, schema: ${schema} });`);
|
|
258
|
+
lines.push(` if (!g.pass) log(\`advisory gate ${tl(gate.id)} flagged: \${g.reason}\`);`);
|
|
259
|
+
lines.push(` }`);
|
|
260
|
+
lines.push("");
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
if (onFail === "bounce-back") {
|
|
264
|
+
const max = gate.retry ?? 2;
|
|
265
|
+
lines.push(` // gate: ${gate.id} (bounce-back, bounded to ${max} retries)`);
|
|
266
|
+
lines.push(` let ${gate.id.replace(/[^a-zA-Z0-9_]/g, "_")}_pass = false;`);
|
|
267
|
+
const v = `${gate.id.replace(/[^a-zA-Z0-9_]/g, "_")}_pass`;
|
|
268
|
+
lines.push(` for (let attempt = 0; attempt < ${max}; attempt++) {`);
|
|
269
|
+
lines.push(` const g = await agent(${sq(gatePrompt)}, { label: \`gate-${tl(gate.id)}-attempt-\${attempt}\`, schema: ${schema} });`);
|
|
270
|
+
lines.push(` if (g.pass) { ${v} = true; break; }`);
|
|
271
|
+
lines.push(` log(\`gate ${tl(gate.id)} failed (attempt \${attempt + 1}/${max}): \${g.reason} — bouncing back\`);`);
|
|
272
|
+
lines.push(` // bounce-back: re-run the producing phase here before the next attempt`);
|
|
273
|
+
lines.push(` }`);
|
|
274
|
+
lines.push(` if (!${v}) throw new Error(${sq(`gate ${gate.id} did not pass after ${max} attempts`)});`);
|
|
275
|
+
lines.push("");
|
|
276
|
+
return lines.join("\n");
|
|
277
|
+
}
|
|
278
|
+
// halt (default)
|
|
279
|
+
lines.push(` // gate: ${gate.id} (halt on fail)`);
|
|
280
|
+
lines.push(` {`);
|
|
281
|
+
lines.push(` const g = await agent(${sq(gatePrompt)}, { label: ${sq("gate-" + gate.id)}, schema: ${schema} });`);
|
|
282
|
+
lines.push(` if (!g.pass) throw new Error(\`gate ${tl(gate.id)} failed: \${g.reason}\`);`);
|
|
283
|
+
lines.push(` }`);
|
|
284
|
+
lines.push("");
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Generate the workflow.js file.
|
|
289
|
+
*
|
|
290
|
+
* Returns the file plus the human-split boundary info (if any) so the caller
|
|
291
|
+
* can emit the handoff README.
|
|
292
|
+
*/
|
|
293
|
+
function generateWorkflowScript(ast, ledger) {
|
|
294
|
+
const name = ast.topology.name;
|
|
295
|
+
const agents = ast.nodes.filter((n) => n.type === "agent");
|
|
296
|
+
const wfPhases = workflowPhases(agents);
|
|
297
|
+
if (wfPhases.size === 0) {
|
|
298
|
+
// No seam markers — nothing to compile. The whole topology stays on the host.
|
|
299
|
+
ledger.add({
|
|
300
|
+
severity: "LOSSY",
|
|
301
|
+
primitive: "topology",
|
|
302
|
+
scope: name,
|
|
303
|
+
reason: `No phases marked execution:workflow; nothing to compile to the Workflow runtime. No workflow.js is emitted — the entire topology stays on the host (claude-code binding). Mark a deterministic fan-out phase with extensions.${SEAM_NS}.execution = "workflow" to emit an embedded workflow.`,
|
|
304
|
+
});
|
|
305
|
+
return { file: null, humanNode: null, phasesEmitted: [] };
|
|
306
|
+
}
|
|
307
|
+
// --- Human split (topology-level): the binding SPLITS at the human node. The
|
|
308
|
+
// workflow.js is the autonomous deterministic rung; the human node is a HOST
|
|
309
|
+
// concern far downstream of the workflow phase. We still report it loud +
|
|
310
|
+
// emit the README, but the human node never appears in this script. ---
|
|
311
|
+
const humanNode = ast.nodes.find((n) => n.type === "human") ?? null;
|
|
312
|
+
if (humanNode) {
|
|
313
|
+
ledger.add({
|
|
314
|
+
severity: "UNREPRESENTABLE",
|
|
315
|
+
primitive: "human",
|
|
316
|
+
scope: humanNode.id,
|
|
317
|
+
reason: `The Workflow runtime prohibits mid-run human input (NO_HUMAN_INPUT). The topology is SPLIT: the workflow.js is only the deterministic workflow-marked rung; the human node '${humanNode.id}' is a HOST concern downstream of it. After the host runs verify + gate, the human promotes, which triggers the SEPARATE downstream workflow (e.g. regression). See ${name}-README.md.`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
// --- STRICT SEAM: only phases that have ≥1 agent marked execution:workflow
|
|
321
|
+
// (plus agents sharing those phases) compile into the script. Everything else
|
|
322
|
+
// is a HOST concern and is NOT folded in. ---
|
|
323
|
+
const byPhase = new Map();
|
|
324
|
+
for (const a of agents) {
|
|
325
|
+
const p = phaseOf(a);
|
|
326
|
+
if (!wfPhases.has(p))
|
|
327
|
+
continue; // host phase — not the workflow's concern
|
|
328
|
+
if (!byPhase.has(p))
|
|
329
|
+
byPhase.set(p, []);
|
|
330
|
+
byPhase.get(p).push(a);
|
|
331
|
+
}
|
|
332
|
+
const orderedPhases = [...byPhase.keys()].sort((x, y) => x - y);
|
|
333
|
+
// --- Gates: include ONLY a gate that sits BETWEEN two workflow-marked phases
|
|
334
|
+
// (both its `after` and `before` agents live in workflow phases). A gate whose
|
|
335
|
+
// `after`/`before` touches a host phase stays HOST. ---
|
|
336
|
+
const gates = ast.nodes.filter((n) => n.type === "gate");
|
|
337
|
+
const gatesByAfterPhase = new Map();
|
|
338
|
+
for (const g of gates) {
|
|
339
|
+
const afterAgent = agents.find((a) => a.id === g.after);
|
|
340
|
+
const beforeAgent = agents.find((a) => a.id === g.before);
|
|
341
|
+
const afterInWf = afterAgent ? wfPhases.has(phaseOf(afterAgent)) : false;
|
|
342
|
+
const beforeInWf = beforeAgent ? wfPhases.has(phaseOf(beforeAgent)) : false;
|
|
343
|
+
// Require the gate to be wholly inside the workflow span: its after-agent must
|
|
344
|
+
// be a workflow phase, and (if it has a before-agent) that must be too.
|
|
345
|
+
if (!afterInWf || (beforeAgent && !beforeInWf)) {
|
|
346
|
+
ledger.add({
|
|
347
|
+
severity: "LOSSY",
|
|
348
|
+
primitive: "gate",
|
|
349
|
+
scope: g.id,
|
|
350
|
+
reason: `gate '${g.id}' (after: ${g.after ?? "—"}, before: ${g.before ?? "—"}) is HOST-side — it does not sit between two workflow-marked phases, so it is NOT in the workflow.js. The host (claude-code binding) enforces it after the workflow returns.`,
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const p = phaseOf(afterAgent);
|
|
355
|
+
if (!gatesByAfterPhase.has(p))
|
|
356
|
+
gatesByAfterPhase.set(p, []);
|
|
357
|
+
gatesByAfterPhase.get(p).push(g);
|
|
358
|
+
}
|
|
359
|
+
// --- Build the meta literal (PURE — no computed values). ---
|
|
360
|
+
const metaPhases = orderedPhases.map((p) => {
|
|
361
|
+
const ids = byPhase.get(p).map((a) => a.id);
|
|
362
|
+
return ` { phase: ${p}, agents: [${ids.map((id) => sq(id)).join(", ")}] }`;
|
|
363
|
+
});
|
|
364
|
+
const desc = ast.topology.description || `Embedded deterministic workflow for ${name}.`;
|
|
365
|
+
// --- Per-spec UNREPRESENTABLE primitives present in the topology → comments. ---
|
|
366
|
+
const topComments = collectTopologyLossComments(ast, ledger, humanNode);
|
|
367
|
+
// --- Assemble. ---
|
|
368
|
+
const out = [];
|
|
369
|
+
out.push(`// ${name}.workflow.js — generated by agentopology (target: claude-workflow)`);
|
|
370
|
+
out.push(`// The deterministic workflow-marked rung of the HYBRID host+workflow model.`);
|
|
371
|
+
out.push(`// STRICT SEAM: this script contains ONLY the phases marked execution:workflow`);
|
|
372
|
+
out.push(`// (${orderedPhases.map((p) => `phase ${p}`).join(", ")}). The host (claude-code binding) runs every other`);
|
|
373
|
+
out.push(`// phase, seeds the Blackboard inputs, launches this workflow, then consumes its outputs.`);
|
|
374
|
+
out.push(`// Host glue (Blackboard seam): see ${seamFiles.seamDoc(name)}. Translation losses: ${seamFiles.lossyDoc(name)}.`);
|
|
375
|
+
if (humanNode) {
|
|
376
|
+
out.push(`// HUMAN SPLIT: the human node '${humanNode.id}' is HOST-side, downstream of this rung. See ${name}-README.md.`);
|
|
377
|
+
}
|
|
378
|
+
out.push("");
|
|
379
|
+
if (topComments.length) {
|
|
380
|
+
out.push(...topComments);
|
|
381
|
+
out.push("");
|
|
382
|
+
}
|
|
383
|
+
out.push(`export const meta = {`);
|
|
384
|
+
out.push(` name: ${sq(name)},`);
|
|
385
|
+
out.push(` description: ${sq(desc)},`);
|
|
386
|
+
out.push(` phases: [`);
|
|
387
|
+
out.push(metaPhases.join(",\n"));
|
|
388
|
+
out.push(` ],`);
|
|
389
|
+
out.push(`};`);
|
|
390
|
+
out.push("");
|
|
391
|
+
out.push(`export default async function ${toIdent(name)}(args = {}) {`);
|
|
392
|
+
out.push("");
|
|
393
|
+
for (const p of orderedPhases) {
|
|
394
|
+
out.push(emitPhaseBlock(p, byPhase.get(p), ast.roles, ledger));
|
|
395
|
+
// Gates between this workflow phase and the next (if any).
|
|
396
|
+
for (const g of gatesByAfterPhase.get(p) ?? []) {
|
|
397
|
+
out.push(emitGate(g, ledger));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
out.push(` log(${sq(`workflow rung complete — results written to the Blackboard for the host to consume`)});`);
|
|
401
|
+
out.push("");
|
|
402
|
+
out.push(` return { ok: true };`);
|
|
403
|
+
out.push(`}`);
|
|
404
|
+
out.push("");
|
|
405
|
+
return {
|
|
406
|
+
file: {
|
|
407
|
+
path: seamFiles.workflowScript(name),
|
|
408
|
+
content: out.join("\n"),
|
|
409
|
+
category: "script",
|
|
410
|
+
},
|
|
411
|
+
humanNode,
|
|
412
|
+
phasesEmitted: orderedPhases,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** Sanitize a topology name into a JS identifier for the default export. */
|
|
416
|
+
function toIdent(name) {
|
|
417
|
+
const camel = name.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9_]/g, "");
|
|
418
|
+
return /^[a-zA-Z_]/.test(camel) ? camel : `wf_${camel}`;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Emit `// UNREPRESENTABLE:` / `// LOSSY:` comments for topology-level primitives
|
|
422
|
+
* that the Workflow runtime cannot express, AND record them in the ledger.
|
|
423
|
+
*/
|
|
424
|
+
function collectTopologyLossComments(ast, ledger, humanNode) {
|
|
425
|
+
const comments = [];
|
|
426
|
+
if (humanNode) {
|
|
427
|
+
comments.push(`// UNREPRESENTABLE: human '${humanNode.id}' — mid-run human input forbidden; topology split here (see README).`);
|
|
428
|
+
}
|
|
429
|
+
if (ast.hooks && ast.hooks.length > 0) {
|
|
430
|
+
const evts = ast.hooks.map((h) => h.on).join(", ");
|
|
431
|
+
comments.push(`// UNREPRESENTABLE: hooks (${evts}) — the Workflow runtime has no pre/post event bus. Host-layer concern (claude-code binding emits .claude/settings.json hooks).`);
|
|
432
|
+
ledger.add({
|
|
433
|
+
severity: "UNREPRESENTABLE",
|
|
434
|
+
primitive: "hooks",
|
|
435
|
+
scope: ast.hooks.map((h) => h.name || h.on).join(", "),
|
|
436
|
+
reason: `Hooks are a host-session event bus (PostToolUse/PreToolUse/etc.). The Workflow script has no event hooks. These stay on the HOST (claude-code binding → .claude/settings.json). In the hybrid model, host hooks watch the Blackboard the workflow agents write — that IS the concurrent observability (see SEAM doc).`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
// permissions / settings / sandbox / providers — session-fixed.
|
|
440
|
+
const hasPerm = ast.nodes.some((n) => n.type === "agent" && n.permissions);
|
|
441
|
+
if (hasPerm) {
|
|
442
|
+
comments.push(`// UNREPRESENTABLE: per-agent permissions — session-fixed; agents inherit the launch session allowlist (not settable per-agent in-script). Set via sidecar settings.json at launch.`);
|
|
443
|
+
ledger.add({
|
|
444
|
+
severity: "UNREPRESENTABLE",
|
|
445
|
+
primitive: "permissions",
|
|
446
|
+
scope: "agents",
|
|
447
|
+
reason: `Workflow agents inherit the launching session's permission mode and allowlist (PERMISSION_INHERIT_NOT_CONFIGURABLE). Per-agent permissions/tools cannot be set in-script. Configure them in the session's settings.json (the claude-code host binding emits this).`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (ast.providers && ast.providers.length > 0) {
|
|
451
|
+
comments.push(`// UNREPRESENTABLE: providers — model routing/credentials are session/host config, not in-script.`);
|
|
452
|
+
ledger.add({
|
|
453
|
+
severity: "UNREPRESENTABLE",
|
|
454
|
+
primitive: "providers",
|
|
455
|
+
scope: ast.providers.map((p) => p.name).join(", "),
|
|
456
|
+
reason: `Provider credentials and model routing are session/host configuration. The Workflow script can name a model per agent() (opts.model) but cannot configure providers. Keep providers on the host.`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (ast.schedules && ast.schedules.length > 0) {
|
|
460
|
+
comments.push(`// UNREPRESENTABLE: schedule — the Workflow runtime has no scheduler/timer. Use a sidecar cron to launch the workflow.`);
|
|
461
|
+
ledger.add({
|
|
462
|
+
severity: "UNREPRESENTABLE",
|
|
463
|
+
primitive: "schedule",
|
|
464
|
+
scope: ast.schedules.map((s) => s.id).join(", "),
|
|
465
|
+
reason: `No runtime scheduler in the Workflow tool. Launch the workflow from a sidecar cron / external trigger.`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (ast.interfaces && ast.interfaces.length > 0) {
|
|
469
|
+
comments.push(`// UNREPRESENTABLE: interfaces — Workflows are launched by command/decision, not external ingress (webhook/http/sse).`);
|
|
470
|
+
ledger.add({
|
|
471
|
+
severity: "UNREPRESENTABLE",
|
|
472
|
+
primitive: "interfaces",
|
|
473
|
+
scope: ast.interfaces.map((i) => i.id).join(", "),
|
|
474
|
+
reason: `Workflows are invoked by command or Claude decision, never by external events. External ingress stays on the host / an external launcher.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (ast.metering) {
|
|
478
|
+
ledger.add({
|
|
479
|
+
severity: "LOSSY",
|
|
480
|
+
primitive: "metering",
|
|
481
|
+
scope: "topology",
|
|
482
|
+
reason: `phase()/label()/budget give partial native metering, but the script cannot emit a metrics.jsonl (${ast.metering.output}). Aggregate metering stays on the host.`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (ast.observability && ast.observability.enabled) {
|
|
486
|
+
ledger.add({
|
|
487
|
+
severity: "LOSSY",
|
|
488
|
+
primitive: "observability",
|
|
489
|
+
scope: "topology",
|
|
490
|
+
reason: `phase()/label()/log() give a native trace, but there is no OTLP export (exporter=${ast.observability.exporter}) and no concurrent listener. Live concurrent observability is a HOST-layer feature (hooks polling the Blackboard).`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
// Blackboard / memory.workspace is LOSSY-but-honored via prompt paths.
|
|
494
|
+
if (ast.memory && ast.memory.workspace) {
|
|
495
|
+
ledger.add({
|
|
496
|
+
severity: "LOSSY",
|
|
497
|
+
primitive: "memory.workspace",
|
|
498
|
+
scope: "topology",
|
|
499
|
+
reason: `The Blackboard (memory.workspace) is honored by embedding read/write paths in agent prompts (the script has no filesystem). It is NOT a live mutable substrate the script reads; agents read/write the files directly. The host hooks observe these files live — that is the seam (see SEAM doc).`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return comments;
|
|
503
|
+
}
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Companion docs
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
/** Emit the human-split handoff README. */
|
|
508
|
+
function generateHumanSplitReadme(ast, human) {
|
|
509
|
+
const name = ast.topology.name;
|
|
510
|
+
const downstream = ast.edges.filter((e) => e.from === human.id).map((e) => e.to);
|
|
511
|
+
const lines = [];
|
|
512
|
+
lines.push(`# ${name} — Workflow Split (human handoff)`);
|
|
513
|
+
lines.push("");
|
|
514
|
+
lines.push(`The Claude Workflow runtime **prohibits mid-run human input**. The \`${name}\` topology contains a \`human\` node (\`${human.id}\`), so the binding **split** it at that boundary.`);
|
|
515
|
+
lines.push("");
|
|
516
|
+
lines.push(`## What \`${name}.workflow.js\` does`);
|
|
517
|
+
lines.push("");
|
|
518
|
+
lines.push(`It runs **only** the deterministic phases marked \`execution: workflow\` (STRICT SEAM) and exits with its results written to the Blackboard. It does **not** contain \`${human.id}\` as an agent — a workflow cannot wait on a human, and the human node is a HOST concern downstream of this rung. The host runs every other phase (the contract research, the adversarial verify, the gate, the ship-report) in-session, then the human promotes.`);
|
|
519
|
+
lines.push("");
|
|
520
|
+
lines.push(`## The human boundary: \`${human.id}\``);
|
|
521
|
+
lines.push("");
|
|
522
|
+
if (human.description) {
|
|
523
|
+
lines.push(`> ${flattenPrompt(human.description, "")}`);
|
|
524
|
+
lines.push("");
|
|
525
|
+
}
|
|
526
|
+
if (human.timeout)
|
|
527
|
+
lines.push(`- Timeout: ${human.timeout}`);
|
|
528
|
+
if (human.onTimeout)
|
|
529
|
+
lines.push(`- On timeout: ${human.onTimeout}`);
|
|
530
|
+
lines.push("");
|
|
531
|
+
lines.push(`The operator (host session) reads the Blackboard artifacts, presents them to the human, and the human acts (e.g. promotes).`);
|
|
532
|
+
lines.push("");
|
|
533
|
+
lines.push(`## Downstream is a SEPARATE workflow`);
|
|
534
|
+
lines.push("");
|
|
535
|
+
if (downstream.length > 0) {
|
|
536
|
+
lines.push(`After the human acts, the downstream steps (${downstream.join(", ")}) run as a **separate** workflow/process — not part of this autonomous script. This mirrors the closed-loop rule: regression is a downstream workflow, launched after promotion.`);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
lines.push(`After the human acts, any downstream work runs as a **separate** workflow/process — not part of this autonomous script.`);
|
|
540
|
+
}
|
|
541
|
+
lines.push("");
|
|
542
|
+
lines.push(`## Hand-off mechanics (the seam)`);
|
|
543
|
+
lines.push("");
|
|
544
|
+
lines.push(`See \`${name}-SEAM.md\` for the exact Blackboard file paths both the host and the embedded workflow agree on.`);
|
|
545
|
+
lines.push("");
|
|
546
|
+
return { path: seamFiles.readmeDoc(name), content: lines.join("\n"), category: "machine" };
|
|
547
|
+
}
|
|
548
|
+
/** Emit the Blackboard seam contract doc (host glue agreement). */
|
|
549
|
+
function generateSeamDoc(ast, phasesEmitted) {
|
|
550
|
+
const name = ast.topology.name;
|
|
551
|
+
const agents = ast.nodes.filter((n) => n.type === "agent");
|
|
552
|
+
const human = ast.nodes.find((n) => n.type === "human") ?? null;
|
|
553
|
+
const workspace = ast.memory?.workspace;
|
|
554
|
+
const wfPhases = workflowPhases(agents);
|
|
555
|
+
const wfSet = new Set(phasesEmitted);
|
|
556
|
+
// Group all agent phases into host vs workflow for the explicit run order.
|
|
557
|
+
const allPhases = [...new Set(agents.map(phaseOf))].sort((x, y) => x - y);
|
|
558
|
+
const phaseLabel = (p) => {
|
|
559
|
+
const ids = agents.filter((a) => phaseOf(a) === p).map((a) => a.id).join(", ");
|
|
560
|
+
return `phase ${p} (${ids})`;
|
|
561
|
+
};
|
|
562
|
+
const lines = [];
|
|
563
|
+
lines.push(`# ${name} — Blackboard Seam Contract`);
|
|
564
|
+
lines.push("");
|
|
565
|
+
lines.push(`This is the file-based hand-off contract between the **host** (claude-code binding → \`.claude/\`) and the **embedded workflow** (\`${name}.workflow.js\`). Both sides MUST agree on these paths. The topology already carries the seam via \`memory.workspace\` + each agent's \`reads\`/\`writes\`; this doc makes it explicit so the two bindings stay in sync.`);
|
|
566
|
+
lines.push("");
|
|
567
|
+
lines.push(`**STRICT SEAM.** The workflow.js is **only** the deterministic rung — the phases marked \`execution: workflow\` (${phasesEmitted.map((p) => `phase ${p}`).join(", ") || "none"}). Every other phase, the gates that don't sit between two workflow phases, and the \`human\` node are **HOST** concerns; the host emits and runs them. They are NOT in the workflow.js.`);
|
|
568
|
+
lines.push("");
|
|
569
|
+
lines.push(`## Workspace (Blackboard root)`);
|
|
570
|
+
lines.push("");
|
|
571
|
+
if (workspace?.path) {
|
|
572
|
+
lines.push(`- Root: \`${workspace.path}\``);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
lines.push(`- Root: (no \`memory.workspace.path\` declared — declare one so host + workflow share a root)`);
|
|
576
|
+
}
|
|
577
|
+
if (workspace?.structure && workspace.structure.length > 0) {
|
|
578
|
+
lines.push(`- Files: ${workspace.structure.map((s) => `\`${s}\``).join(", ")}`);
|
|
579
|
+
}
|
|
580
|
+
lines.push("");
|
|
581
|
+
lines.push(`## Run order (who runs what)`);
|
|
582
|
+
lines.push("");
|
|
583
|
+
let step = 1;
|
|
584
|
+
for (const p of allPhases) {
|
|
585
|
+
if (wfSet.has(p)) {
|
|
586
|
+
lines.push(`${step}. **Host launches \`${name}.workflow.js\`** for ${phaseLabel(p)} — the workflow agents read their \`reads\` paths (seeded by the host), do the work, write results to their \`writes\` paths. Host hooks observe those writes LIVE (the concurrent observability the workflow itself cannot express).`);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
lines.push(`${step}. **Host (in-session)** runs ${phaseLabel(p)} — writes its outputs to the Blackboard so the next rung can read them.`);
|
|
590
|
+
}
|
|
591
|
+
step++;
|
|
592
|
+
}
|
|
593
|
+
if (human) {
|
|
594
|
+
lines.push(`${step}. **Host** presents the Blackboard artifacts to the human \`${human.id}\`, who acts (e.g. promotes). Promotion triggers the SEPARATE downstream workflow (e.g. regression).`);
|
|
595
|
+
}
|
|
596
|
+
lines.push("");
|
|
597
|
+
lines.push(`So for this topology, the workflow.js is just the **build rung**: the host seeds the inputs, launches it, and consumes \`build-report.md\` (and the other declared \`writes\`) for the downstream host verify + gate + ship-report.`);
|
|
598
|
+
lines.push("");
|
|
599
|
+
lines.push(`## Per-agent reads/writes (the workflow contract)`);
|
|
600
|
+
lines.push("");
|
|
601
|
+
lines.push(`Only the agents in the workflow-compiled phases (${phasesEmitted.join(", ") || "none"}) are listed; their reads/writes ARE the seam the host must honor when seeding inputs and consuming outputs.`);
|
|
602
|
+
lines.push("");
|
|
603
|
+
for (const a of agents) {
|
|
604
|
+
if (!wfPhases.has(phaseOf(a)))
|
|
605
|
+
continue;
|
|
606
|
+
const reads = a.reads && a.reads.length ? a.reads.join(", ") : "—";
|
|
607
|
+
const writes = a.writes && a.writes.length ? a.writes.join(", ") : "—";
|
|
608
|
+
lines.push(`- **${a.id}** (phase ${phaseOf(a)}): reads [${reads}] → writes [${writes}]`);
|
|
609
|
+
}
|
|
610
|
+
lines.push("");
|
|
611
|
+
lines.push(`> The host (claude-code binding) is responsible for materializing \`.claude/\`, running the non-workflow phases, and the hook scripts that observe these paths. This binding does NOT emit the host files — it only emits the workflow rung + this contract so the two agree.`);
|
|
612
|
+
lines.push("");
|
|
613
|
+
return { path: seamFiles.seamDoc(name), content: lines.join("\n"), category: "machine" };
|
|
614
|
+
}
|
|
615
|
+
/** Emit the LOSSY report listing every translation note. */
|
|
616
|
+
function generateLossyReport(ast, ledger) {
|
|
617
|
+
const name = ast.topology.name;
|
|
618
|
+
const lines = [];
|
|
619
|
+
lines.push(`# ${name} — claude-workflow LOSSY / UNREPRESENTABLE Report`);
|
|
620
|
+
lines.push("");
|
|
621
|
+
lines.push(`Generated when compiling \`${name}.at\` → \`${name}.workflow.js\` (target: claude-workflow).`);
|
|
622
|
+
lines.push("");
|
|
623
|
+
lines.push(`This report lists **exactly** what changed in translation. Nothing is dropped silently. See \`docs/bindings/claude-workflow-mapping.md\` for the per-primitive classification.`);
|
|
624
|
+
lines.push("");
|
|
625
|
+
const unrep = ledger.notes.filter((n) => n.severity === "UNREPRESENTABLE");
|
|
626
|
+
const lossy = ledger.notes.filter((n) => n.severity === "LOSSY");
|
|
627
|
+
lines.push(`## UNREPRESENTABLE (${unrep.length}) — the Workflow runtime genuinely cannot express these`);
|
|
628
|
+
lines.push("");
|
|
629
|
+
if (unrep.length === 0) {
|
|
630
|
+
lines.push(`_None._`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
for (const n of unrep) {
|
|
634
|
+
lines.push(`### ${n.primitive} — \`${n.scope}\``);
|
|
635
|
+
lines.push(n.reason);
|
|
636
|
+
lines.push("");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
lines.push("");
|
|
640
|
+
lines.push(`## LOSSY (${lossy.length}) — representable, but a property is lost`);
|
|
641
|
+
lines.push("");
|
|
642
|
+
if (lossy.length === 0) {
|
|
643
|
+
lines.push(`_None._`);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
for (const n of lossy) {
|
|
647
|
+
lines.push(`### ${n.primitive} — \`${n.scope}\``);
|
|
648
|
+
lines.push(n.reason);
|
|
649
|
+
lines.push("");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
lines.push("");
|
|
653
|
+
lines.push(`## The seam`);
|
|
654
|
+
lines.push("");
|
|
655
|
+
lines.push(`For phases marked \`execution: workflow\`, the host hands work off via the Blackboard (file-based). The host writes the task manifest to the \`workspace/\` paths; the workflow agents write results back; host hooks observe them live. The host \`.claude/\` files are emitted by the **claude-code** binding, NOT this one. The path contract both bindings honor is in \`${name}-SEAM.md\`.`);
|
|
656
|
+
lines.push("");
|
|
657
|
+
return { path: seamFiles.lossyDoc(name), content: lines.join("\n"), category: "machine" };
|
|
658
|
+
}
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Binding
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
export const claudeWorkflowBinding = {
|
|
663
|
+
name: "claude-workflow",
|
|
664
|
+
description: "Claude Workflow tool — compiles the deterministic fan-out phases (execution: workflow) into a <topology>.workflow.js, plus the Blackboard seam contract and a loud LOSSY report. The embedded half of the hybrid host+workflow model.",
|
|
665
|
+
scaffold(ast) {
|
|
666
|
+
const ledger = new LossLedger();
|
|
667
|
+
const files = [];
|
|
668
|
+
const { file, humanNode, phasesEmitted } = generateWorkflowScript(ast, ledger);
|
|
669
|
+
if (file)
|
|
670
|
+
files.push(file);
|
|
671
|
+
// Human-split README (UNREPRESENTABLE human boundary).
|
|
672
|
+
if (humanNode) {
|
|
673
|
+
files.push(generateHumanSplitReadme(ast, humanNode));
|
|
674
|
+
}
|
|
675
|
+
// Blackboard seam contract — always emit when a workflow was produced.
|
|
676
|
+
if (file) {
|
|
677
|
+
files.push(generateSeamDoc(ast, phasesEmitted));
|
|
678
|
+
}
|
|
679
|
+
// LOSSY report — always emit (even if empty, it documents the clean translation).
|
|
680
|
+
files.push(generateLossyReport(ast, ledger));
|
|
681
|
+
return deduplicateFiles(files);
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
//# sourceMappingURL=claude-workflow.js.map
|