agentopology 0.5.2 → 0.7.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 +5 -5
- package/dist/__tests__/e2e/compile.test.d.ts +12 -0
- package/dist/__tests__/e2e/compile.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/compile.test.js +219 -0
- package/dist/__tests__/e2e/compile.test.js.map +1 -0
- package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
- package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/roundtrip.test.js +178 -0
- package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js +163 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
- package/dist/__tests__/e2e/sync-cycle.test.d.ts +10 -0
- package/dist/__tests__/e2e/sync-cycle.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/sync-cycle.test.js +49 -0
- package/dist/__tests__/e2e/sync-cycle.test.js.map +1 -0
- package/dist/__tests__/global-setup.d.ts +7 -0
- package/dist/__tests__/global-setup.d.ts.map +1 -0
- package/dist/__tests__/global-setup.js +16 -0
- package/dist/__tests__/global-setup.js.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.js +198 -0
- package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
- package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
- package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
- package/dist/__tests__/live/zz-debug.test.js +4 -0
- package/dist/__tests__/live/zz-debug.test.js.map +1 -0
- package/dist/__tests__/open-findings.test.d.ts +10 -0
- package/dist/__tests__/open-findings.test.d.ts.map +1 -0
- package/dist/__tests__/open-findings.test.js +15 -0
- package/dist/__tests__/open-findings.test.js.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.js +57 -0
- package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
- package/dist/bindings/__tests__/bindings.test.js +19 -19
- package/dist/bindings/__tests__/bindings.test.js.map +1 -1
- package/dist/bindings/__tests__/claude-code-hybrid.test.js +5 -5
- package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -1
- package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
- package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/hook-command.test.js +88 -0
- package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.js +1 -1
- package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
- package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
- package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
- package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts +50 -1
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +636 -231
- package/dist/bindings/claude-code.js.map +1 -1
- package/dist/bindings/index.d.ts +7 -0
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +26 -7
- package/dist/bindings/index.js.map +1 -1
- package/dist/bindings/lib/prompt-fence.d.ts +22 -0
- package/dist/bindings/lib/prompt-fence.d.ts.map +1 -0
- package/dist/bindings/lib/prompt-fence.js +29 -0
- package/dist/bindings/lib/prompt-fence.js.map +1 -0
- package/dist/bindings/types.d.ts +6 -0
- package/dist/bindings/types.d.ts.map +1 -1
- package/dist/bindings/types.js.map +1 -1
- package/dist/cli/__tests__/cli.test.d.ts +1 -1
- package/dist/cli/__tests__/cli.test.js +1 -1
- package/dist/cli/index.js +124 -76
- package/dist/cli/index.js.map +1 -1
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +39 -3
- package/dist/docs/content.js.map +1 -1
- package/dist/docs/teach.d.ts.map +1 -1
- package/dist/docs/teach.js +46 -0
- package/dist/docs/teach.js.map +1 -1
- package/dist/import/__tests__/import.test.js +21 -9
- package/dist/import/__tests__/import.test.js.map +1 -1
- package/dist/import/claude-code.d.ts.map +1 -1
- package/dist/import/claude-code.js +128 -44
- package/dist/import/claude-code.js.map +1 -1
- package/dist/import/serializer.d.ts.map +1 -1
- package/dist/import/serializer.js +10 -0
- package/dist/import/serializer.js.map +1 -1
- package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
- package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
- package/dist/parser/__tests__/mod-hooks.test.js +136 -0
- package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
- package/dist/parser/ast.d.ts +29 -2
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js.map +1 -1
- package/dist/parser/index.d.ts +6 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +102 -25
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/paths.d.ts +15 -0
- package/dist/parser/paths.d.ts.map +1 -0
- package/dist/parser/paths.js +34 -0
- package/dist/parser/paths.js.map +1 -0
- package/dist/parser/run.d.ts +45 -0
- package/dist/parser/run.d.ts.map +1 -0
- package/dist/parser/run.js +91 -0
- package/dist/parser/run.js.map +1 -0
- package/dist/parser/validator.d.ts +12 -0
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +268 -2
- package/dist/parser/validator.js.map +1 -1
- package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
- package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
- package/dist/scaffold/__tests__/safety.test.js +184 -0
- package/dist/scaffold/__tests__/safety.test.js.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.js +2 -1
- package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
- package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
- package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
- package/dist/scaffold/diff.d.ts +17 -0
- package/dist/scaffold/diff.d.ts.map +1 -0
- package/dist/scaffold/diff.js +66 -0
- package/dist/scaffold/diff.js.map +1 -0
- package/dist/scaffold/incremental.d.ts +49 -13
- package/dist/scaffold/incremental.d.ts.map +1 -1
- package/dist/scaffold/incremental.js +235 -95
- package/dist/scaffold/incremental.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +8 -1
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +32 -8
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/merge.d.ts +15 -4
- package/dist/scaffold/merge.d.ts.map +1 -1
- package/dist/scaffold/merge.js +123 -18
- package/dist/scaffold/merge.js.map +1 -1
- package/dist/scaffold/types.d.ts +17 -0
- package/dist/scaffold/types.d.ts.map +1 -1
- package/dist/sync/index.d.ts +1 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +40 -2
- package/dist/sync/index.js.map +1 -1
- package/package.json +1 -1
- package/spec/grammar.md +24 -5
- package/spec/testing.md +85 -0
- package/spec/validation.md +8 -2
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { deduplicateFiles } from "./types.js";
|
|
10
10
|
import { shellStub, STUB_MARKER } from "./lib/stub.js";
|
|
11
|
+
import { PROMPT_FENCE_CLOSE, PROMPT_FENCE_OPEN } from "./lib/prompt-fence.js";
|
|
11
12
|
import { isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
|
|
12
13
|
import { phaseOf as sharedPhaseOf } from "../resolve/phase.js";
|
|
13
14
|
import { gateAnchors } from "../parser/ast.js";
|
|
15
|
+
import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// Helpers
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
@@ -21,13 +23,43 @@ import { gateAnchors } from "../parser/ast.js";
|
|
|
21
23
|
function convertMcpToolName(tool) {
|
|
22
24
|
if (!tool.startsWith("mcp."))
|
|
23
25
|
return tool;
|
|
24
|
-
// mcp.server
|
|
25
|
-
// Only
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// mcp.server → mcp__server (every tool on it); mcp.server.* → mcp__server__*;
|
|
27
|
+
// mcp.server.method_name → mcp__server__method_name. Only the first dot after
|
|
28
|
+
// the server separates; a dotted method name keeps its own dots.
|
|
29
|
+
const rest = tool.slice(4);
|
|
30
|
+
const dot = rest.indexOf(".");
|
|
31
|
+
if (dot === -1)
|
|
32
|
+
return `mcp__${rest}`;
|
|
33
|
+
return `mcp__${rest.slice(0, dot)}__${rest.slice(dot + 1)}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether Claude Code accepts `model` as written: an alias (optionally with a
|
|
37
|
+
* context suffix such as [1m]), inherit, or a full claude-* model id. Any other
|
|
38
|
+
* value (gpt-4o, gemini-2.5-pro) was written to the agent file unchecked.
|
|
39
|
+
*/
|
|
40
|
+
function isClaudeCodeModel(model) {
|
|
41
|
+
return /^(?:sonnet|opus|haiku|fable)(?:\[[0-9a-z]+\])?$|^inherit$|^claude-[a-z0-9.-]+(?:\[[0-9a-z]+\])?$/.test(model);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Claude Code hook events for the language's universal names. A hook on a
|
|
45
|
+
* name Claude Code does not know never fires, so AgentStop used to be dead.
|
|
46
|
+
*/
|
|
47
|
+
const UNIVERSAL_EVENTS = {
|
|
48
|
+
AgentStart: "SubagentStart",
|
|
49
|
+
AgentStop: "SubagentStop",
|
|
50
|
+
ToolUse: "PostToolUse",
|
|
51
|
+
Error: "PostToolUseFailure",
|
|
52
|
+
};
|
|
53
|
+
function claudeEvent(on) {
|
|
54
|
+
return UNIVERSAL_EVENTS[on] ?? on;
|
|
55
|
+
}
|
|
56
|
+
/** A settings.json hook handler for a command or prompt hook. */
|
|
57
|
+
function hookHandler(hook, command) {
|
|
58
|
+
// A prompt hook asks a model to judge the event; its run: is the prompt.
|
|
59
|
+
if (hook.type === "prompt") {
|
|
60
|
+
return { type: "prompt", prompt: hook.run, ...(hook.timeout ? { timeout: hook.timeout } : {}) };
|
|
29
61
|
}
|
|
30
|
-
return
|
|
62
|
+
return { type: "command", command: command(), ...(hook.timeout ? { timeout: hook.timeout } : {}) };
|
|
31
63
|
}
|
|
32
64
|
/**
|
|
33
65
|
* Downshift all markdown headings by one level so they nest properly
|
|
@@ -54,6 +86,8 @@ function toTitle(id) {
|
|
|
54
86
|
*/
|
|
55
87
|
/** Characters that require quoting in YAML scalar values. */
|
|
56
88
|
const YAML_SPECIAL = /[:#\[\]{}&*!|>'"%@`]/;
|
|
89
|
+
/** Plain scalars YAML reads as something other than a string (yes → true, 1.0 → a number). */
|
|
90
|
+
const YAML_NOT_STRING = /^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF|null|Null|NULL|~|[-+]?(?:\d[\d_]*)?\.?\d+(?:[eE][-+]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|\.inf|\.nan)$/;
|
|
57
91
|
function frontmatter(fields) {
|
|
58
92
|
const lines = ["---"];
|
|
59
93
|
for (const [key, value] of Object.entries(fields)) {
|
|
@@ -67,13 +101,13 @@ function frontmatter(fields) {
|
|
|
67
101
|
lines.push(` - ${item}`);
|
|
68
102
|
}
|
|
69
103
|
}
|
|
70
|
-
else if (typeof value === "boolean") {
|
|
104
|
+
else if (typeof value === "boolean" || typeof value === "number") {
|
|
71
105
|
lines.push(`${key}: ${value}`);
|
|
72
106
|
}
|
|
73
107
|
else {
|
|
74
108
|
// Auto-quote strings containing YAML-special characters
|
|
75
109
|
const str = String(value);
|
|
76
|
-
const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str;
|
|
110
|
+
const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str || str === "" || YAML_NOT_STRING.test(str);
|
|
77
111
|
lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
|
|
78
112
|
}
|
|
79
113
|
}
|
|
@@ -105,7 +139,96 @@ function getDelegationMode(ast) {
|
|
|
105
139
|
const orch = ast.nodes.find((n) => n.type === "orchestrator");
|
|
106
140
|
return orch?.delegation === "inline" ? "inline" : "subagent";
|
|
107
141
|
}
|
|
108
|
-
|
|
142
|
+
function gateWiring(ast, gate) {
|
|
143
|
+
if (getDelegationMode(ast) === "inline")
|
|
144
|
+
return { kind: "none" };
|
|
145
|
+
const afters = gateAnchors(gate, "after");
|
|
146
|
+
if (afters.length > 0 && afters.every((a) => isRegisteredSubagentType(ast, a))) {
|
|
147
|
+
return { kind: "subagent-stop", anchors: afters };
|
|
148
|
+
}
|
|
149
|
+
const agents = gateAnchors(gate, "before").filter((b) => isRegisteredSubagentType(ast, b));
|
|
150
|
+
return agents.length > 0 ? { kind: "before-agent", agents } : { kind: "none" };
|
|
151
|
+
}
|
|
152
|
+
/** Agents a human approval step stands before: its direct flow successors. */
|
|
153
|
+
function approvalTargets(ast, human) {
|
|
154
|
+
if (getDelegationMode(ast) === "inline")
|
|
155
|
+
return [];
|
|
156
|
+
const out = ast.edges.filter((e) => e.from === human.id && !e.isError).map((e) => e.to);
|
|
157
|
+
return [...new Set(out)].filter((id) => isRegisteredSubagentType(ast, id));
|
|
158
|
+
}
|
|
159
|
+
/** The settings.json command for the topology's before-agent hook. */
|
|
160
|
+
function beforeAgentCommand(ast) {
|
|
161
|
+
return `bash "$CLAUDE_PROJECT_DIR/.claude/skills/${ast.topology.name}/scripts/before-agent.sh"`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* One PreToolUse hook on the Agent tool for the whole topology: before Claude
|
|
165
|
+
* Code launches a subagent it runs that agent's gates (from actions or
|
|
166
|
+
* humans), then asks the user to approve when a human step stands before it.
|
|
167
|
+
* Null when nothing needs it.
|
|
168
|
+
*/
|
|
169
|
+
function generateBeforeAgentScript(ast) {
|
|
170
|
+
const name = ast.topology.name;
|
|
171
|
+
const steps = new Map();
|
|
172
|
+
const add = (agent, lines) => steps.set(agent, [...(steps.get(agent) ?? []), ...lines]);
|
|
173
|
+
for (const node of ast.nodes) {
|
|
174
|
+
if (node.type !== "gate")
|
|
175
|
+
continue;
|
|
176
|
+
const gate = node;
|
|
177
|
+
if (!gate.run || gate.behavior === "advisory" || !scriptNameFromRun(gate.run))
|
|
178
|
+
continue;
|
|
179
|
+
const wiring = gateWiring(ast, gate);
|
|
180
|
+
if (wiring.kind !== "before-agent")
|
|
181
|
+
continue;
|
|
182
|
+
for (const agent of wiring.agents) {
|
|
183
|
+
add(agent, [
|
|
184
|
+
` # gate ${gate.id} (after: ${gateAnchors(gate, "after").join(", ") || "-"})`,
|
|
185
|
+
` bash "$DIR/gate-${gate.id}.sh" >&2 || exit 2`,
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const node of ast.nodes) {
|
|
190
|
+
if (node.type !== "human")
|
|
191
|
+
continue;
|
|
192
|
+
const human = node;
|
|
193
|
+
const reason = `Approval step "${human.id}"${human.description ? `: ${human.description}` : ""}`;
|
|
194
|
+
for (const agent of approvalTargets(ast, human)) {
|
|
195
|
+
add(agent, [` # approval step ${human.id}`, ` ask ${shellQuote(reason)}`]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (steps.size === 0)
|
|
199
|
+
return null;
|
|
200
|
+
const lines = [
|
|
201
|
+
"#!/usr/bin/env bash",
|
|
202
|
+
`# Before Claude Code launches a subagent of topology "${name}".`,
|
|
203
|
+
"# Auto-generated by agentopology scaffold. Wired in .claude/settings.json as a",
|
|
204
|
+
"# PreToolUse hook on the Agent tool. Exit 2 blocks the launch and shows the",
|
|
205
|
+
"# reason to Claude; an \"ask\" decision makes Claude Code ask the user first.",
|
|
206
|
+
"set -euo pipefail",
|
|
207
|
+
'cd "$CLAUDE_PROJECT_DIR"',
|
|
208
|
+
`DIR=".claude/skills/${name}/scripts"`,
|
|
209
|
+
'AGENT=$(jq -r \'.tool_input.subagent_type // ""\')',
|
|
210
|
+
"",
|
|
211
|
+
"ask() {",
|
|
212
|
+
" jq -n --arg r \"$1\" '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"ask\", permissionDecisionReason: $r}}'",
|
|
213
|
+
"}",
|
|
214
|
+
"",
|
|
215
|
+
'case "$AGENT" in',
|
|
216
|
+
];
|
|
217
|
+
for (const [agent, body] of steps) {
|
|
218
|
+
lines.push(` ${agent})`, ...body, " ;;");
|
|
219
|
+
}
|
|
220
|
+
lines.push("esac", "exit 0", "");
|
|
221
|
+
return {
|
|
222
|
+
path: `.claude/skills/${name}/scripts/before-agent.sh`,
|
|
223
|
+
content: lines.join("\n"),
|
|
224
|
+
category: "script",
|
|
225
|
+
executable: true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Single-quote a string for bash. */
|
|
229
|
+
function shellQuote(text) {
|
|
230
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
231
|
+
}
|
|
109
232
|
function gitkeep(dirPath) {
|
|
110
233
|
return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
|
|
111
234
|
}
|
|
@@ -251,7 +374,7 @@ function workflowRunLabel(ast, run) {
|
|
|
251
374
|
*/
|
|
252
375
|
function mapPermissionMode(perm) {
|
|
253
376
|
// Claude Code native values — pass through
|
|
254
|
-
const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
|
|
377
|
+
const nativeValues = ["plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"];
|
|
255
378
|
if (nativeValues.includes(perm)) {
|
|
256
379
|
// "default" is the default, omit it
|
|
257
380
|
if (perm === "default")
|
|
@@ -264,14 +387,16 @@ function mapPermissionMode(perm) {
|
|
|
264
387
|
return "plan";
|
|
265
388
|
case "autonomous":
|
|
266
389
|
return undefined; // default, omit
|
|
267
|
-
case "
|
|
268
|
-
return undefined; // default
|
|
390
|
+
case "confirm":
|
|
391
|
+
return undefined; // asks before each action: Claude Code's default
|
|
269
392
|
case "interactive":
|
|
270
393
|
return "acceptEdits";
|
|
271
394
|
case "unrestricted":
|
|
395
|
+
case "bypass":
|
|
272
396
|
return "bypassPermissions";
|
|
273
397
|
default:
|
|
274
|
-
|
|
398
|
+
// Claude Code rejects an unknown permissionMode; V27 already warned.
|
|
399
|
+
return undefined;
|
|
275
400
|
}
|
|
276
401
|
}
|
|
277
402
|
/** Format a SchemaType to a human-readable string. Optionally resolves refs. */
|
|
@@ -325,45 +450,24 @@ function generateActionNodes(ast) {
|
|
|
325
450
|
if (action.join) {
|
|
326
451
|
lines.push(`# Join semantics: ${action.join}`);
|
|
327
452
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
case "git":
|
|
347
|
-
lines.push("");
|
|
348
|
-
lines.push("# Git operation");
|
|
349
|
-
if (action.commands && action.commands.length > 0) {
|
|
350
|
-
for (const cmd of action.commands) {
|
|
351
|
-
lines.push(cmd);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
else {
|
|
355
|
-
lines.push('echo "TODO: implement git action"');
|
|
356
|
-
}
|
|
357
|
-
break;
|
|
358
|
-
case "report":
|
|
359
|
-
lines.push("");
|
|
360
|
-
lines.push("# Report generation");
|
|
361
|
-
lines.push('echo "TODO: implement report action"');
|
|
362
|
-
break;
|
|
363
|
-
default:
|
|
364
|
-
lines.push("");
|
|
365
|
-
lines.push(`echo "TODO: implement ${action.kind ?? "unknown"} action"`);
|
|
366
|
-
break;
|
|
453
|
+
if (action.kind === "external")
|
|
454
|
+
lines.push(`# External source: ${action.source ?? "unknown"}`);
|
|
455
|
+
lines.push("");
|
|
456
|
+
// The action's own commands, whatever its kind: an external or report
|
|
457
|
+
// action used to drop them for a TODO line.
|
|
458
|
+
if (action.commands && action.commands.length > 0) {
|
|
459
|
+
lines.push(...action.commands);
|
|
460
|
+
}
|
|
461
|
+
else if (!action.kind || action.kind === "inline") {
|
|
462
|
+
// Done by the orchestrator in conversation (the playbook says how).
|
|
463
|
+
lines.push(`# Performed by the orchestrator in conversation; nothing to run.`);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
// A placeholder that reports itself as one (agentopology stubs), never a
|
|
467
|
+
// silent success.
|
|
468
|
+
lines.splice(3, 0, STUB_MARKER);
|
|
469
|
+
lines.push(`echo "TODO: implement ${action.kind ?? "this"} action ${action.id}" >&2`);
|
|
470
|
+
lines.push("exit 1");
|
|
367
471
|
}
|
|
368
472
|
lines.push("");
|
|
369
473
|
files.push({
|
|
@@ -392,17 +496,24 @@ function generateAgents(ast) {
|
|
|
392
496
|
const fm = {};
|
|
393
497
|
fm.name = agent.id;
|
|
394
498
|
// Description: prefer agent.description, then role description from roles block.
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
499
|
+
// Claude Code skips an agent without one, so a manual agent keeps it and
|
|
500
|
+
// says it runs only when asked (there is no frontmatter field for that).
|
|
501
|
+
const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role ?? toTitle(agent.id);
|
|
502
|
+
fm.description = agent.invocation === "manual"
|
|
503
|
+
? `${desc.replace(/[.\s]*$/, "")}. Use only when the user asks for this agent by name; never delegate to it on your own.`
|
|
504
|
+
: desc;
|
|
505
|
+
if (agent.model) {
|
|
506
|
+
if (isClaudeCodeModel(agent.model)) {
|
|
507
|
+
fm.model = agent.model;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
console.warn(`[claude-code] Agent "${agent.id}" model "${agent.model}" is not a Claude Code model ` +
|
|
511
|
+
`(sonnet, opus, haiku, fable, inherit, or a claude-* id); omitted, so the agent ` +
|
|
512
|
+
`runs on the session's model.`);
|
|
400
513
|
}
|
|
401
514
|
}
|
|
402
|
-
if (agent.model)
|
|
403
|
-
fm.model = agent.model;
|
|
404
515
|
if (agent.maxTurns != null)
|
|
405
|
-
fm.maxTurns =
|
|
516
|
+
fm.maxTurns = agent.maxTurns;
|
|
406
517
|
if (agent.tools && agent.tools.length > 0)
|
|
407
518
|
fm.tools = agent.tools.map(convertMcpToolName).join(", ");
|
|
408
519
|
if (agent.disallowedTools && agent.disallowedTools.length > 0)
|
|
@@ -426,8 +537,6 @@ function generateAgents(ast) {
|
|
|
426
537
|
if (agent.fallbackChain && agent.fallbackChain.length > 0) {
|
|
427
538
|
fm["fallback-chain"] = agent.fallbackChain.join(", ");
|
|
428
539
|
}
|
|
429
|
-
// invocation: manual — Claude Code doesn't parse this field. Instead, omit
|
|
430
|
-
// description to prevent auto-delegation for manual agents.
|
|
431
540
|
if (agent.skills && agent.skills.length > 0)
|
|
432
541
|
fm.skills = agent.skills;
|
|
433
542
|
// scale is NOT a Claude Code frontmatter field — rendered only in body as prompt text
|
|
@@ -449,28 +558,30 @@ function generateAgents(ast) {
|
|
|
449
558
|
const topologyName = ast.topology.name;
|
|
450
559
|
const hooksByEvent = {};
|
|
451
560
|
for (const hook of agent.hooks) {
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
561
|
+
if (hook.type === "mod")
|
|
562
|
+
continue; // engine-wide only (V94)
|
|
563
|
+
// In an agent's frontmatter Claude Code turns Stop into SubagentStop itself.
|
|
564
|
+
const event = hook.on === "AgentStop" ? "Stop" : claudeEvent(hook.on);
|
|
565
|
+
if (!hooksByEvent[event])
|
|
566
|
+
hooksByEvent[event] = [];
|
|
567
|
+
hooksByEvent[event].push(hook);
|
|
455
568
|
}
|
|
456
569
|
const hookLines = ["hooks:"];
|
|
457
570
|
for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
|
|
458
571
|
hookLines.push(` ${eventName}:`);
|
|
459
572
|
for (const hook of hooks) {
|
|
460
|
-
const scriptName = hook.run
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
573
|
+
const scriptName = scriptNameFromRun(hook.run);
|
|
574
|
+
if (hook.type !== "prompt" && !scriptName)
|
|
575
|
+
continue; // V96: prose in run names no script
|
|
576
|
+
const handler = hookHandler(hook, () => `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`);
|
|
577
|
+
// JSON strings are valid YAML double-quoted scalars, so a regex
|
|
578
|
+
// matcher or a prompt with quotes or colons can never break the file.
|
|
579
|
+
hookLines.push(hook.matcher ? ` - matcher: ${JSON.stringify(hook.matcher)}` : ` -`);
|
|
468
580
|
hookLines.push(` hooks:`);
|
|
469
|
-
|
|
470
|
-
hookLines.push(`
|
|
471
|
-
|
|
472
|
-
hookLines.push(`
|
|
473
|
-
}
|
|
581
|
+
const [first, ...rest] = Object.entries(handler);
|
|
582
|
+
hookLines.push(` - ${first[0]}: ${JSON.stringify(first[1])}`);
|
|
583
|
+
for (const [k, v] of rest)
|
|
584
|
+
hookLines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
474
585
|
}
|
|
475
586
|
}
|
|
476
587
|
// Insert hooks before the closing ---
|
|
@@ -488,6 +599,9 @@ function generateAgents(ast) {
|
|
|
488
599
|
}
|
|
489
600
|
sections.push("## Instructions");
|
|
490
601
|
sections.push("");
|
|
602
|
+
// Only the text between the markers is the prompt: sync and re-scaffold
|
|
603
|
+
// read it there, so generated sections below never leak into the .at.
|
|
604
|
+
sections.push(PROMPT_FENCE_OPEN);
|
|
491
605
|
if (agent.prompt) {
|
|
492
606
|
sections.push(downshiftHeadings(agent.prompt));
|
|
493
607
|
}
|
|
@@ -502,6 +616,7 @@ function generateAgents(ast) {
|
|
|
502
616
|
else {
|
|
503
617
|
sections.push("Complete the assigned task.");
|
|
504
618
|
}
|
|
619
|
+
sections.push(PROMPT_FENCE_CLOSE);
|
|
505
620
|
sections.push("");
|
|
506
621
|
// Reads section
|
|
507
622
|
if (agent.reads && agent.reads.length > 0) {
|
|
@@ -809,35 +924,6 @@ function generateAgents(ast) {
|
|
|
809
924
|
}
|
|
810
925
|
return files;
|
|
811
926
|
}
|
|
812
|
-
/** Generate AGENT.md files for each human node. */
|
|
813
|
-
function generateHumanNodes(ast) {
|
|
814
|
-
const files = [];
|
|
815
|
-
for (const node of ast.nodes) {
|
|
816
|
-
if (node.type !== "human")
|
|
817
|
-
continue;
|
|
818
|
-
const human = node;
|
|
819
|
-
const sections = [];
|
|
820
|
-
sections.push(`# ${toTitle(human.id)} (Human-in-the-Loop)`);
|
|
821
|
-
sections.push("");
|
|
822
|
-
if (human.description) {
|
|
823
|
-
sections.push(human.description);
|
|
824
|
-
sections.push("");
|
|
825
|
-
}
|
|
826
|
-
if (human.timeout) {
|
|
827
|
-
sections.push(`Timeout: ${human.timeout}`);
|
|
828
|
-
}
|
|
829
|
-
if (human.onTimeout) {
|
|
830
|
-
sections.push(`On timeout: ${human.onTimeout}`);
|
|
831
|
-
}
|
|
832
|
-
sections.push("");
|
|
833
|
-
files.push({
|
|
834
|
-
path: `.claude/agents/${human.id}/AGENT.md`,
|
|
835
|
-
content: sections.join("\n") + "\n",
|
|
836
|
-
category: "machine",
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
|
-
return files;
|
|
840
|
-
}
|
|
841
927
|
/**
|
|
842
928
|
* Generate group chat artifacts:
|
|
843
929
|
* - Orchestrator AGENT.md — spawns members sequentially
|
|
@@ -1254,6 +1340,29 @@ function generateTopologySkill(ast) {
|
|
|
1254
1340
|
sections.push("");
|
|
1255
1341
|
}
|
|
1256
1342
|
}
|
|
1343
|
+
// Approval steps: a human node is a point where the user decides. It used to
|
|
1344
|
+
// compile to an AGENT.md without frontmatter, which Claude Code ignores.
|
|
1345
|
+
const humans = ast.nodes.filter((n) => n.type === "human");
|
|
1346
|
+
if (humans.length > 0) {
|
|
1347
|
+
sections.push("## Approval steps");
|
|
1348
|
+
sections.push("");
|
|
1349
|
+
sections.push("At each step below, stop and ask the user (AskUserQuestion), then continue only on their approval.");
|
|
1350
|
+
sections.push("");
|
|
1351
|
+
for (const human of humans) {
|
|
1352
|
+
sections.push(`### ${toTitle(human.id)}`);
|
|
1353
|
+
if (human.description)
|
|
1354
|
+
sections.push(human.description);
|
|
1355
|
+
const before = approvalTargets(ast, human);
|
|
1356
|
+
if (before.length > 0) {
|
|
1357
|
+
sections.push(`Before: ${before.join(", ")} (Claude Code also asks the user before launching ${before.length > 1 ? "them" : "it"})`);
|
|
1358
|
+
}
|
|
1359
|
+
if (human.timeout)
|
|
1360
|
+
sections.push(`Timeout: ${human.timeout}`);
|
|
1361
|
+
if (human.onTimeout)
|
|
1362
|
+
sections.push(`On timeout: ${human.onTimeout}`);
|
|
1363
|
+
sections.push("");
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1257
1366
|
// Schedules section
|
|
1258
1367
|
if (ast.schedules.length > 0) {
|
|
1259
1368
|
sections.push("## Schedules");
|
|
@@ -1448,9 +1557,19 @@ function generateSkills(ast) {
|
|
|
1448
1557
|
}
|
|
1449
1558
|
sections.push("");
|
|
1450
1559
|
}
|
|
1560
|
+
// The instructions themselves, or a file that holds them. "See: path" was
|
|
1561
|
+
// written before: Claude Code loads a skill's other files only when the
|
|
1562
|
+
// skill tells it to, so the instructions never reached the model.
|
|
1451
1563
|
if (skill.prompt) {
|
|
1452
|
-
sections.push("##
|
|
1453
|
-
sections.push(
|
|
1564
|
+
sections.push("## Instructions");
|
|
1565
|
+
sections.push("");
|
|
1566
|
+
const path = skill.prompt.trim();
|
|
1567
|
+
if (!/\s/.test(path) && /\.(md|txt)$/.test(path)) {
|
|
1568
|
+
sections.push(`Read \`${path}\` before anything else and follow it: it holds this skill's instructions.`);
|
|
1569
|
+
}
|
|
1570
|
+
else {
|
|
1571
|
+
sections.push(downshiftHeadings(skill.prompt));
|
|
1572
|
+
}
|
|
1454
1573
|
sections.push("");
|
|
1455
1574
|
}
|
|
1456
1575
|
files.push({
|
|
@@ -1641,15 +1760,22 @@ function generateGateScripts(ast) {
|
|
|
1641
1760
|
if (node.type !== "gate")
|
|
1642
1761
|
continue;
|
|
1643
1762
|
const gate = node;
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1763
|
+
const scriptName = scriptNameFromRun(gate.run);
|
|
1764
|
+
if (!scriptName)
|
|
1765
|
+
continue;
|
|
1766
|
+
const { script } = runParts(gate.run);
|
|
1767
|
+
// The user's own script, outside the project: never stubbed.
|
|
1768
|
+
if (script.startsWith("/"))
|
|
1769
|
+
continue;
|
|
1770
|
+
// A project path names the user's script: a stub only fills the gap until
|
|
1771
|
+
// it exists, and is never written over it.
|
|
1772
|
+
const project = isProjectPath(script);
|
|
1773
|
+
files.push({
|
|
1774
|
+
path: project ? script.replace(/^\.\//, "") : `.claude/skills/${name}/scripts/${scriptName}`,
|
|
1775
|
+
content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
|
|
1776
|
+
category: "script",
|
|
1777
|
+
...(project ? { createOnly: true } : {}),
|
|
1778
|
+
});
|
|
1653
1779
|
}
|
|
1654
1780
|
return files;
|
|
1655
1781
|
}
|
|
@@ -1665,18 +1791,22 @@ function generateGateWrapperScripts(ast) {
|
|
|
1665
1791
|
continue;
|
|
1666
1792
|
if (gate.behavior === "advisory")
|
|
1667
1793
|
continue;
|
|
1668
|
-
|
|
1794
|
+
if (!scriptNameFromRun(gate.run))
|
|
1795
|
+
continue; // V96: prose in run names no script
|
|
1796
|
+
const gateCommand = hookCommand(gate.run, name);
|
|
1669
1797
|
const onFailExit = gate.onFail === "halt"
|
|
1670
1798
|
? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
|
|
1671
1799
|
: `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
|
|
1672
1800
|
const inlineMode = getDelegationMode(ast) === "inline";
|
|
1673
|
-
const
|
|
1674
|
-
const wiredAsHook =
|
|
1675
|
-
gateAfters.length > 0 &&
|
|
1676
|
-
gateAfters.every((a) => isRegisteredSubagentType(ast, a));
|
|
1801
|
+
const wiring = gateWiring(ast, gate);
|
|
1802
|
+
const wiredAsHook = wiring.kind === "subagent-stop";
|
|
1677
1803
|
let enforcementNote;
|
|
1678
1804
|
let enforcementNote2;
|
|
1679
|
-
if (
|
|
1805
|
+
if (wiring.kind === "before-agent") {
|
|
1806
|
+
enforcementNote = `# Enforcement: PreToolUse hook (before-agent.sh) before Claude Code launches "${wiring.agents.join('", "')}".`;
|
|
1807
|
+
enforcementNote2 = `# Exit 2 blocks the launch and surfaces stderr to Claude.`;
|
|
1808
|
+
}
|
|
1809
|
+
else if (inlineMode) {
|
|
1680
1810
|
enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
|
|
1681
1811
|
enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
|
|
1682
1812
|
}
|
|
@@ -1712,10 +1842,10 @@ function generateGateWrapperScripts(ast) {
|
|
|
1712
1842
|
contentLines.push("while [ $ATTEMPT -le $MAX_RETRIES ] && [ $GATE_RESULT -ne 0 ]; do");
|
|
1713
1843
|
contentLines.push(" ATTEMPT=$((ATTEMPT + 1))");
|
|
1714
1844
|
if (gate.timeout) {
|
|
1715
|
-
contentLines.push(` timeout ${gate.timeout}
|
|
1845
|
+
contentLines.push(` timeout ${gate.timeout} ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
|
|
1716
1846
|
}
|
|
1717
1847
|
else {
|
|
1718
|
-
contentLines.push(`
|
|
1848
|
+
contentLines.push(` ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
|
|
1719
1849
|
}
|
|
1720
1850
|
contentLines.push(' if [ $GATE_RESULT -ne 0 ] && [ $ATTEMPT -le $MAX_RETRIES ]; then');
|
|
1721
1851
|
contentLines.push(` echo "Gate '${gate.id}' attempt $ATTEMPT failed, retrying..."`);
|
|
@@ -1728,10 +1858,10 @@ function generateGateWrapperScripts(ast) {
|
|
|
1728
1858
|
contentLines.push("# Run the gate check script");
|
|
1729
1859
|
contentLines.push(`GATE_RESULT=0`);
|
|
1730
1860
|
if (gate.timeout) {
|
|
1731
|
-
contentLines.push(`timeout ${gate.timeout}
|
|
1861
|
+
contentLines.push(`timeout ${gate.timeout} ${gateCommand} || GATE_RESULT=$?`);
|
|
1732
1862
|
}
|
|
1733
1863
|
else {
|
|
1734
|
-
contentLines.push(
|
|
1864
|
+
contentLines.push(`${gateCommand} || GATE_RESULT=$?`);
|
|
1735
1865
|
}
|
|
1736
1866
|
}
|
|
1737
1867
|
contentLines.push("");
|
|
@@ -1761,7 +1891,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
|
|
|
1761
1891
|
if (isFinishEvent) {
|
|
1762
1892
|
return [
|
|
1763
1893
|
"#!/usr/bin/env bash",
|
|
1764
|
-
`# Auto-generated by agentopology scaffold
|
|
1894
|
+
`# Auto-generated by agentopology scaffold`,
|
|
1765
1895
|
`# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
|
|
1766
1896
|
"# Replace this stub with your implementation.",
|
|
1767
1897
|
"# Stdin JSON payload from Claude Code is available on FD 0.",
|
|
@@ -1777,7 +1907,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
|
|
|
1777
1907
|
}
|
|
1778
1908
|
return [
|
|
1779
1909
|
"#!/usr/bin/env bash",
|
|
1780
|
-
`# Auto-generated by agentopology scaffold
|
|
1910
|
+
`# Auto-generated by agentopology scaffold`,
|
|
1781
1911
|
`# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
|
|
1782
1912
|
"# Replace this stub with your implementation.",
|
|
1783
1913
|
"# Stdin JSON payload from Claude Code is available on FD 0.",
|
|
@@ -1790,6 +1920,123 @@ function observabilityStub(hookName, eventName, matcherHint) {
|
|
|
1790
1920
|
"",
|
|
1791
1921
|
].join("\n");
|
|
1792
1922
|
}
|
|
1923
|
+
/**
|
|
1924
|
+
* `type: mod` hooks → a Claude Code plugin (function hooks, early access).
|
|
1925
|
+
*
|
|
1926
|
+
* A Mod is a TypeScript module loaded INSIDE the engine; settings.json cannot
|
|
1927
|
+
* carry it. The engine loads ONE module per plugin (hooks.json `modules`,
|
|
1928
|
+
* measured on 2.1.283), so the plugin's single entry is a generated
|
|
1929
|
+
* `hooks/at-register.ts` that imports each declared Mod's `register(on)` and
|
|
1930
|
+
* calls it. A `flag:` is the Mod's OWN switch: the Mod reads it per event with
|
|
1931
|
+
* `$.env.get`, and the launch script sets it. The entry cannot gate it: a
|
|
1932
|
+
* module has no `process`, and the engine refuses an `on` that is wrapped or
|
|
1933
|
+
* kept (measured 2.1.283: "on is always on(\"<event>\", hook)"). The launch script
|
|
1934
|
+
* sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (without it the module never
|
|
1935
|
+
* loads), each flag, and `--plugin-dir`.
|
|
1936
|
+
*
|
|
1937
|
+
* The plugin root is the folder that HOLDS the Mods' modules, because the
|
|
1938
|
+
* engine refuses a hooks module that imports outside its plugin folder
|
|
1939
|
+
* (measured 2.1.283: "cannot import … it is outside the plugin's folder").
|
|
1940
|
+
* The entry has its own name so it never overwrites a hand-written
|
|
1941
|
+
* `hooks/register.ts`. The Mods' own modules are the user's code: nothing is
|
|
1942
|
+
* written at a `run:` path. A topology without Mods gets no plugin.
|
|
1943
|
+
*/
|
|
1944
|
+
export const MOD_PLUGIN_ENV = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
|
|
1945
|
+
export const MOD_ENTRY = "at-register.ts";
|
|
1946
|
+
/**
|
|
1947
|
+
* The plugin root: the deepest folder shared by every Mod's `run:` path, less
|
|
1948
|
+
* a trailing `hooks` (the plugin's own hooks/ folder). "" is the project root.
|
|
1949
|
+
* null when a `run:` is absolute: it cannot be placed inside the project.
|
|
1950
|
+
*/
|
|
1951
|
+
export function modPluginDir(ast) {
|
|
1952
|
+
const runs = ast.hooks.filter((h) => h.type === "mod" && h.run).map((h) => h.run);
|
|
1953
|
+
if (runs.length === 0 || runs.some((r) => r.startsWith("/")))
|
|
1954
|
+
return null;
|
|
1955
|
+
const dirs = runs.map((r) => r.split("/").filter((p) => p && p !== ".").slice(0, -1));
|
|
1956
|
+
const shared = [];
|
|
1957
|
+
for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++)
|
|
1958
|
+
shared.push(dirs[0][i]);
|
|
1959
|
+
if (shared[shared.length - 1] === "hooks")
|
|
1960
|
+
shared.pop();
|
|
1961
|
+
return shared.join("/");
|
|
1962
|
+
}
|
|
1963
|
+
function generateModPlugin(ast) {
|
|
1964
|
+
const mods = ast.hooks.filter((h) => h.type === "mod" && h.run);
|
|
1965
|
+
if (mods.length === 0)
|
|
1966
|
+
return [];
|
|
1967
|
+
const name = ast.topology.name;
|
|
1968
|
+
const dir = modPluginDir(ast);
|
|
1969
|
+
if (dir === null) {
|
|
1970
|
+
console.warn(`[claude-code] Mods ${mods.map((m) => m.name).join(", ")}: a run: path is absolute, so no plugin folder ` +
|
|
1971
|
+
`can hold the modules (the engine refuses imports outside it). Use project-relative run: paths. Skipped.`);
|
|
1972
|
+
return [];
|
|
1973
|
+
}
|
|
1974
|
+
const at = (p) => (dir ? `${dir}/${p}` : p);
|
|
1975
|
+
const hooksDir = at("hooks");
|
|
1976
|
+
// `run:` is project-relative (or absolute); the import is relative to hooks/.
|
|
1977
|
+
// Bindings stay free of node: imports, so the relative path is computed here.
|
|
1978
|
+
const importPath = (run) => {
|
|
1979
|
+
const target = run.replace(/\.(ts|js|mjs)$/, "");
|
|
1980
|
+
if (target.startsWith("/"))
|
|
1981
|
+
return target;
|
|
1982
|
+
const to = target.split("/").filter((p) => p && p !== ".");
|
|
1983
|
+
const from = hooksDir.split("/");
|
|
1984
|
+
let common = 0;
|
|
1985
|
+
while (common < from.length && common < to.length - 1 && from[common] === to[common])
|
|
1986
|
+
common++;
|
|
1987
|
+
const rel = [...from.slice(common).map(() => ".."), ...to.slice(common)].join("/");
|
|
1988
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
1989
|
+
};
|
|
1990
|
+
const ident = (hookName) => "mod_" + hookName.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1991
|
+
const summary = mods
|
|
1992
|
+
.map((m) => `${m.name} (${m.on}${m.flag ? `, ${m.flag}` : ""})`)
|
|
1993
|
+
.join("; ");
|
|
1994
|
+
const pluginJson = {
|
|
1995
|
+
name: `${name}-mods`,
|
|
1996
|
+
version: ast.topology.version || "0.0.0",
|
|
1997
|
+
description: `Mods of the ${name} topology, generated by agentopology: ${summary}.`,
|
|
1998
|
+
};
|
|
1999
|
+
const hooksJson = {
|
|
2000
|
+
description: `The engine loads ONE module per plugin: ${MOD_ENTRY} registers every Mod ` +
|
|
2001
|
+
`declared in ${name}.at. Off unless ${MOD_PLUGIN_ENV}=1.`,
|
|
2002
|
+
modules: [`./${MOD_ENTRY}`],
|
|
2003
|
+
};
|
|
2004
|
+
const register = [
|
|
2005
|
+
"// Generated by agentopology scaffold from the topology's `type: mod` hooks.",
|
|
2006
|
+
"// The engine loads ONE module per plugin: this one registers every Mod.",
|
|
2007
|
+
"// Edit the .at file, not this file.",
|
|
2008
|
+
"import type { On } from 'claude-code'",
|
|
2009
|
+
"",
|
|
2010
|
+
...mods.map((m) => `import { register as ${ident(m.name)} } from '${importPath(m.run)}'`),
|
|
2011
|
+
"",
|
|
2012
|
+
"export function register(on: On) {",
|
|
2013
|
+
...mods.map((m) => ` // ${m.name} · on ${m.on}${m.flag ? ` · the Mod reads its own switch, ${m.flag}` : ""}\n ${ident(m.name)}(on)`),
|
|
2014
|
+
"}",
|
|
2015
|
+
"",
|
|
2016
|
+
].join("\n");
|
|
2017
|
+
const flags = [...new Set(mods.map((m) => m.flag).filter((f) => !!f))];
|
|
2018
|
+
const launch = [
|
|
2019
|
+
"#!/usr/bin/env bash",
|
|
2020
|
+
`# Launch Claude Code with the Mods of ${name} (generated by agentopology).`,
|
|
2021
|
+
"# Mods run inside the engine (function hooks, early access).",
|
|
2022
|
+
"# Each flag defaults to on here; set it to 0 before launching to turn a Mod off.",
|
|
2023
|
+
"set -euo pipefail",
|
|
2024
|
+
'PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
|
|
2025
|
+
`export ${MOD_PLUGIN_ENV}=1`,
|
|
2026
|
+
...flags.map((f) => {
|
|
2027
|
+
const users = mods.filter((m) => m.flag === f).map((m) => `${m.name} · ${m.on}`).join(", ");
|
|
2028
|
+
return `export ${f}="\${${f}:-1}" # ${users}`;
|
|
2029
|
+
}),
|
|
2030
|
+
'exec claude --plugin-dir "$PLUGIN_DIR" "$@"',
|
|
2031
|
+
"",
|
|
2032
|
+
].join("\n");
|
|
2033
|
+
return [
|
|
2034
|
+
{ path: at(".claude-plugin/plugin.json"), content: JSON.stringify(pluginJson, null, 2) + "\n" },
|
|
2035
|
+
{ path: `${hooksDir}/hooks.json`, content: JSON.stringify(hooksJson, null, 2) + "\n" },
|
|
2036
|
+
{ path: `${hooksDir}/${MOD_ENTRY}`, content: register },
|
|
2037
|
+
{ path: at("launch-mods.sh"), content: launch, executable: true },
|
|
2038
|
+
];
|
|
2039
|
+
}
|
|
1793
2040
|
/** Generate hook script stubs for every hook's `run` field (Changes 1, 4, 5). */
|
|
1794
2041
|
function generateHookScripts(ast) {
|
|
1795
2042
|
const files = [];
|
|
@@ -1797,18 +2044,36 @@ function generateHookScripts(ast) {
|
|
|
1797
2044
|
for (const hook of ast.hooks) {
|
|
1798
2045
|
if (!hook.run)
|
|
1799
2046
|
continue;
|
|
2047
|
+
// A Mod's run: is the user's TypeScript module — never overwrite it with a
|
|
2048
|
+
// bash stub. generateModPlugin imports it instead.
|
|
2049
|
+
if (hook.type === "mod" || hook.type === "prompt")
|
|
2050
|
+
continue;
|
|
1800
2051
|
const runPath = hook.run;
|
|
1801
2052
|
// If the run path starts with '.' or '/' it is already project-relative —
|
|
1802
2053
|
// generate the stub at exactly that path (observability pattern).
|
|
1803
2054
|
// Otherwise scope the stub into this topology's skill scripts directory.
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2055
|
+
if (proseInRun(runPath))
|
|
2056
|
+
continue; // V96: prose in run names no script
|
|
2057
|
+
const { script } = runParts(runPath);
|
|
2058
|
+
// An absolute script is the user's own, outside the project: never stubbed.
|
|
2059
|
+
if (script.startsWith("/"))
|
|
2060
|
+
continue;
|
|
2061
|
+
const scoped = scriptNameFromRun(script);
|
|
2062
|
+
const filePath = isProjectPath(script)
|
|
2063
|
+
? script
|
|
2064
|
+
: scoped
|
|
2065
|
+
? `.claude/skills/${name}/scripts/${scoped}`
|
|
2066
|
+
: null;
|
|
2067
|
+
if (!filePath)
|
|
2068
|
+
continue;
|
|
1807
2069
|
files.push({
|
|
1808
2070
|
path: filePath,
|
|
1809
2071
|
content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
|
|
1810
2072
|
category: "script",
|
|
1811
2073
|
executable: true,
|
|
2074
|
+
// A run that names its own path names the user's script: a stub only
|
|
2075
|
+
// fills the gap until it exists. --force used to overwrite it.
|
|
2076
|
+
...(isProjectPath(script) ? { createOnly: true } : {}),
|
|
1812
2077
|
});
|
|
1813
2078
|
}
|
|
1814
2079
|
return files;
|
|
@@ -1824,9 +2089,11 @@ function generateAgentHookScripts(ast) {
|
|
|
1824
2089
|
if (!agent.hooks || agent.hooks.length === 0)
|
|
1825
2090
|
continue;
|
|
1826
2091
|
for (const hook of agent.hooks) {
|
|
1827
|
-
if (!hook.run)
|
|
2092
|
+
if (!hook.run || hook.type === "mod" || hook.type === "prompt")
|
|
1828
2093
|
continue;
|
|
1829
|
-
const scriptName = hook.run
|
|
2094
|
+
const scriptName = scriptNameFromRun(hook.run);
|
|
2095
|
+
if (!scriptName)
|
|
2096
|
+
continue; // V96: prose in run names no script
|
|
1830
2097
|
files.push({
|
|
1831
2098
|
path: `.claude/skills/${name}/scripts/${agent.id}-${scriptName}`,
|
|
1832
2099
|
content: [
|
|
@@ -1845,19 +2112,45 @@ function generateAgentHookScripts(ast) {
|
|
|
1845
2112
|
return files;
|
|
1846
2113
|
}
|
|
1847
2114
|
/**
|
|
1848
|
-
*
|
|
2115
|
+
* The shell command for a command hook's or gate's `run:`.
|
|
1849
2116
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
2117
|
+
* A `run:` whose script (see runParts) is a project path names the user's own
|
|
2118
|
+
* script. A project-relative one runs through `$CLAUDE_PROJECT_DIR` (a hook's
|
|
2119
|
+
* cwd is the session's, which moves); an absolute one is kept as written. It
|
|
2120
|
+
* runs by the interpreter the `run:` names, else the one its extension names
|
|
2121
|
+
* (`.py` → python3, anything else → bash). Arguments are kept.
|
|
2122
|
+
*
|
|
2123
|
+
* Any other `run:` is a script this topology's skill scaffolds, by basename.
|
|
1852
2124
|
*/
|
|
1853
|
-
function
|
|
1854
|
-
const
|
|
2125
|
+
export function hookCommand(run, topologyName) {
|
|
2126
|
+
const { interpreter: named, script: first, args: rest } = runParts(run);
|
|
2127
|
+
if (!isProjectPath(first)) {
|
|
2128
|
+
const base = first.replace(/^.*\//, "");
|
|
2129
|
+
return [named ?? "bash", `.claude/skills/${topologyName}/scripts/${base}`, ...rest].join(" ");
|
|
2130
|
+
}
|
|
2131
|
+
const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
|
|
2132
|
+
const where = first.startsWith("/")
|
|
2133
|
+
? first
|
|
2134
|
+
: `"$CLAUDE_PROJECT_DIR/${first.replace(/^\.\//, "")}"`;
|
|
2135
|
+
return [interpreter, where, ...rest].join(" ");
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* The settings.json `hooks` entries of the topology's own `hooks {}` block
|
|
2139
|
+
* (command hooks only; a Mod is a plugin, see generateModPlugin), grouped by
|
|
2140
|
+
* event. Gate wiring is added by generateSettings, not here: a caller that
|
|
2141
|
+
* wants the reflexes alone (e.g. a drift check against a live settings file)
|
|
2142
|
+
* reads this.
|
|
2143
|
+
*/
|
|
2144
|
+
export function settingsHooksOf(ast) {
|
|
1855
2145
|
const topologyName = ast.topology.name;
|
|
1856
2146
|
const isInline = getDelegationMode(ast) === "inline";
|
|
1857
|
-
// Hooks section — grouped by event name
|
|
1858
2147
|
const hooksByEvent = {};
|
|
1859
2148
|
for (const hook of ast.hooks) {
|
|
1860
|
-
|
|
2149
|
+
// A Mod is a module inside the engine, not a settings.json command: it is
|
|
2150
|
+
// emitted as a plugin (generateModPlugin).
|
|
2151
|
+
if (hook.type === "mod")
|
|
2152
|
+
continue;
|
|
2153
|
+
const eventName = claudeEvent(hook.on);
|
|
1861
2154
|
// Inline orchestrator mode never spawns subagents, so SubagentStop /
|
|
1862
2155
|
// SubagentStart hooks would never fire. Warn the user and skip emission
|
|
1863
2156
|
// — keeping them in settings.json would be a maintenance trap.
|
|
@@ -1868,27 +2161,13 @@ function generateSettings(ast) {
|
|
|
1868
2161
|
`delegation to "subagent".`);
|
|
1869
2162
|
continue;
|
|
1870
2163
|
}
|
|
2164
|
+
if (hook.type !== "prompt" && proseInRun(hook.run))
|
|
2165
|
+
continue; // V96: prose in run names no script
|
|
1871
2166
|
if (!hooksByEvent[eventName]) {
|
|
1872
2167
|
hooksByEvent[eventName] = [];
|
|
1873
2168
|
}
|
|
1874
|
-
// Build the script command.
|
|
1875
|
-
// If hook.run starts with '.' or '/' it is already a project-relative or
|
|
1876
|
-
// absolute path — use it verbatim so observability hooks like
|
|
1877
|
-
// ".claude/scripts/log-subagent.sh" land at the correct location.
|
|
1878
|
-
// Otherwise treat it as a bare filename scoped to this topology's skill
|
|
1879
|
-
// scripts directory.
|
|
1880
|
-
const runPath = hook.run;
|
|
1881
|
-
const command = runPath.startsWith(".") || runPath.startsWith("/")
|
|
1882
|
-
? `bash ${runPath}`
|
|
1883
|
-
: `bash .claude/skills/${topologyName}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
|
|
1884
2169
|
const hookEntry = {
|
|
1885
|
-
hooks: [
|
|
1886
|
-
{
|
|
1887
|
-
type: hook.type ?? "command",
|
|
1888
|
-
command,
|
|
1889
|
-
...(hook.timeout ? { timeout: hook.timeout } : {}),
|
|
1890
|
-
},
|
|
1891
|
-
],
|
|
2170
|
+
hooks: [hookHandler(hook, () => hookCommand(hook.run, topologyName))],
|
|
1892
2171
|
};
|
|
1893
2172
|
// Only add matcher when it has a value
|
|
1894
2173
|
if (hook.matcher) {
|
|
@@ -1896,6 +2175,19 @@ function generateSettings(ast) {
|
|
|
1896
2175
|
}
|
|
1897
2176
|
hooksByEvent[eventName].push(hookEntry);
|
|
1898
2177
|
}
|
|
2178
|
+
return hooksByEvent;
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Generate hook configuration in .claude/settings.json.
|
|
2182
|
+
*
|
|
2183
|
+
* Hooks are grouped by event name as an object:
|
|
2184
|
+
* { "hooks": { "PostToolUse": [ { hooks: [...], matcher? } ], ... } }
|
|
2185
|
+
*/
|
|
2186
|
+
function generateSettings(ast) {
|
|
2187
|
+
const settings = {};
|
|
2188
|
+
const topologyName = ast.topology.name;
|
|
2189
|
+
// Hooks section — grouped by event name
|
|
2190
|
+
const hooksByEvent = settingsHooksOf(ast);
|
|
1899
2191
|
// Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
|
|
1900
2192
|
// This is the Claude Code native approach: hooks in the agent file are scoped
|
|
1901
2193
|
// to that agent automatically.
|
|
@@ -1927,8 +2219,8 @@ function generateSettings(ast) {
|
|
|
1927
2219
|
const delegationMode = getDelegationMode(ast);
|
|
1928
2220
|
const gates = ast.nodes.filter((n) => n.type === "gate");
|
|
1929
2221
|
for (const gate of gates) {
|
|
1930
|
-
if (!gate.run)
|
|
1931
|
-
continue;
|
|
2222
|
+
if (!scriptNameFromRun(gate.run))
|
|
2223
|
+
continue; // no run, or V96 prose
|
|
1932
2224
|
if (gate.behavior === "advisory")
|
|
1933
2225
|
continue; // advisory gates stay in markdown only
|
|
1934
2226
|
if (delegationMode === "inline") {
|
|
@@ -1938,11 +2230,15 @@ function generateSettings(ast) {
|
|
|
1938
2230
|
`orchestrator playbook at the right step.`);
|
|
1939
2231
|
continue;
|
|
1940
2232
|
}
|
|
1941
|
-
// SubagentStop matcher must be a registered subagent_type.
|
|
1942
|
-
//
|
|
1943
|
-
// skip so we don't
|
|
2233
|
+
// SubagentStop matcher must be a registered subagent_type. A gate after an
|
|
2234
|
+
// action or a human runs before its next agent instead (before-agent.sh,
|
|
2235
|
+
// wired below); with no agent on either side, warn and skip so we don't
|
|
2236
|
+
// ship dead config that never fires.
|
|
1944
2237
|
const anchors = gateAnchors(gate, "after");
|
|
1945
|
-
|
|
2238
|
+
const wiring = gateWiring(ast, gate);
|
|
2239
|
+
if (wiring.kind === "before-agent")
|
|
2240
|
+
continue;
|
|
2241
|
+
if (wiring.kind !== "subagent-stop") {
|
|
1946
2242
|
const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
|
|
1947
2243
|
const targetDesc = anchors.length
|
|
1948
2244
|
? `non-agent target ${bad.map((b) => `"${b}"`).join(", ")}`
|
|
@@ -1981,6 +2277,15 @@ function generateSettings(ast) {
|
|
|
1981
2277
|
// Blackboard implies a host-side observer that watches the workflow rung's
|
|
1982
2278
|
// Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
|
|
1983
2279
|
// observability (the deterministic workflow rung cannot express it).
|
|
2280
|
+
if (generateBeforeAgentScript(ast)) {
|
|
2281
|
+
if (!hooksByEvent.PreToolUse)
|
|
2282
|
+
hooksByEvent.PreToolUse = [];
|
|
2283
|
+
hooksByEvent.PreToolUse.push({
|
|
2284
|
+
// Agent is the subagent tool; Task is its earlier name.
|
|
2285
|
+
matcher: "Agent|Task",
|
|
2286
|
+
hooks: [{ type: "command", command: beforeAgentCommand(ast) }],
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
1984
2289
|
const observeHook = buildHybridObserveHook(ast, topologyName);
|
|
1985
2290
|
if (observeHook) {
|
|
1986
2291
|
if (!hooksByEvent[observeHook.event])
|
|
@@ -1999,13 +2304,12 @@ function generateSettings(ast) {
|
|
|
1999
2304
|
// Permissions section from settings block — nested under "permissions" key
|
|
2000
2305
|
const perms = ast.settings;
|
|
2001
2306
|
if (perms) {
|
|
2002
|
-
const allow = perms.allow;
|
|
2003
|
-
const deny = perms.deny;
|
|
2004
2307
|
const permObj = {};
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2308
|
+
for (const key of ["allow", "ask", "deny"]) {
|
|
2309
|
+
const rules = perms[key];
|
|
2310
|
+
if (rules && rules.length > 0)
|
|
2311
|
+
permObj[key] = rules.map(convertMcpToolName);
|
|
2312
|
+
}
|
|
2009
2313
|
if (Object.keys(permObj).length > 0)
|
|
2010
2314
|
settings.permissions = permObj;
|
|
2011
2315
|
}
|
|
@@ -2059,9 +2363,12 @@ function generateMcpJson(ast) {
|
|
|
2059
2363
|
for (const [name, config] of Object.entries(ast.mcpServers)) {
|
|
2060
2364
|
const entry = {};
|
|
2061
2365
|
for (const [key, value] of Object.entries(config)) {
|
|
2062
|
-
if (key === "command" || key === "url") {
|
|
2366
|
+
if (key === "command" || key === "url" || key === "type") {
|
|
2063
2367
|
entry[key] = value;
|
|
2064
2368
|
}
|
|
2369
|
+
else if (key === "headers" && typeof value === "object" && value !== null) {
|
|
2370
|
+
entry.headers = value;
|
|
2371
|
+
}
|
|
2065
2372
|
else if (key === "args" && Array.isArray(value)) {
|
|
2066
2373
|
entry.args = value;
|
|
2067
2374
|
}
|
|
@@ -2073,8 +2380,11 @@ function generateMcpJson(ast) {
|
|
|
2073
2380
|
// Only include servers that have a command or url (filter parser artifacts)
|
|
2074
2381
|
if (!("command" in entry) && !("url" in entry))
|
|
2075
2382
|
continue;
|
|
2076
|
-
//
|
|
2077
|
-
if (!("
|
|
2383
|
+
// Claude Code reads a url entry without type as stdio and skips it.
|
|
2384
|
+
if ("url" in entry && !("type" in entry))
|
|
2385
|
+
entry.type = "http";
|
|
2386
|
+
// Always include env field for a stdio server in .mcp.json
|
|
2387
|
+
if (!("url" in entry) && !("env" in entry)) {
|
|
2078
2388
|
entry.env = {};
|
|
2079
2389
|
}
|
|
2080
2390
|
servers[name] = entry;
|
|
@@ -2155,6 +2465,7 @@ function generateToolScripts(ast) {
|
|
|
2155
2465
|
shebang,
|
|
2156
2466
|
`# Tool: ${tool.id} — ${tool.description}`,
|
|
2157
2467
|
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2468
|
+
tool.lang === "node" ? `// ${STUB_MARKER}` : STUB_MARKER,
|
|
2158
2469
|
"",
|
|
2159
2470
|
];
|
|
2160
2471
|
if (tool.lang === "bash" || !tool.lang) {
|
|
@@ -2175,14 +2486,50 @@ function generateToolScripts(ast) {
|
|
|
2175
2486
|
lines.push(`echo "TODO: implement ${tool.id}"`);
|
|
2176
2487
|
}
|
|
2177
2488
|
lines.push("");
|
|
2489
|
+
// The stub goes where the tool's script: says, not to a copy beside it: a
|
|
2490
|
+
// project path is the user's script (create-only); an absolute one is never
|
|
2491
|
+
// touched; a bare name lives in the skill's scripts folder.
|
|
2492
|
+
const script = tool.script ? runParts(tool.script).script : "";
|
|
2493
|
+
if (script.startsWith("/"))
|
|
2494
|
+
continue;
|
|
2495
|
+
const project = isProjectPath(script);
|
|
2496
|
+
const base = script.replace(/^.*\//, "");
|
|
2178
2497
|
files.push({
|
|
2179
|
-
path:
|
|
2498
|
+
path: project
|
|
2499
|
+
? script.replace(/^\.\//, "")
|
|
2500
|
+
: `.claude/skills/${name}/scripts/${base || `${tool.id}.${ext}`}`,
|
|
2180
2501
|
content: lines.join("\n"),
|
|
2181
2502
|
category: "script",
|
|
2503
|
+
...(project ? { createOnly: true } : {}),
|
|
2182
2504
|
});
|
|
2183
2505
|
}
|
|
2184
2506
|
return files;
|
|
2185
2507
|
}
|
|
2508
|
+
/**
|
|
2509
|
+
* A cron expression for an `every:` duration, or null when cron cannot say
|
|
2510
|
+
* it. Only "1h", "daily" and a few words converted before; anything else
|
|
2511
|
+
* became a commented-out crontab line that never ran.
|
|
2512
|
+
*/
|
|
2513
|
+
function everyToCron(every) {
|
|
2514
|
+
const text = every.trim().toLowerCase();
|
|
2515
|
+
const words = { hourly: "0 * * * *", daily: "0 0 * * *", weekly: "0 0 * * 0" };
|
|
2516
|
+
if (words[text])
|
|
2517
|
+
return words[text];
|
|
2518
|
+
const m = text.match(/^(\d+)\s*(m|min|mins|minutes?|h|hours?|d|days?|w|weeks?)$/);
|
|
2519
|
+
if (!m)
|
|
2520
|
+
return null;
|
|
2521
|
+
const n = Number(m[1]);
|
|
2522
|
+
const unit = m[2][0];
|
|
2523
|
+
if (n <= 0)
|
|
2524
|
+
return null;
|
|
2525
|
+
if (unit === "m")
|
|
2526
|
+
return n < 60 && 60 % n === 0 ? (n === 1 ? "* * * * *" : `*/${n} * * * *`) : null;
|
|
2527
|
+
if (unit === "h")
|
|
2528
|
+
return n < 24 && 24 % n === 0 ? (n === 1 ? "0 * * * *" : `0 */${n} * * *`) : null;
|
|
2529
|
+
if (unit === "d")
|
|
2530
|
+
return n === 1 ? "0 0 * * *" : null;
|
|
2531
|
+
return n === 1 ? "0 0 * * 0" : null;
|
|
2532
|
+
}
|
|
2186
2533
|
/** Generate schema documentation files. */
|
|
2187
2534
|
function generateSchemas(ast) {
|
|
2188
2535
|
const files = [];
|
|
@@ -2227,15 +2574,18 @@ function generateScheduleScripts(ast) {
|
|
|
2227
2574
|
`# Schedule: ${job.id} — invokes ${target}`,
|
|
2228
2575
|
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2229
2576
|
'set -euo pipefail',
|
|
2577
|
+
"# cron starts in $HOME: run from the project root (this script is in scripts/).",
|
|
2578
|
+
'cd "$(dirname "$0")/.."',
|
|
2230
2579
|
"",
|
|
2231
2580
|
];
|
|
2232
2581
|
if (job.agent) {
|
|
2582
|
+
// Headless: cron has no terminal, so an interactive session never ran.
|
|
2233
2583
|
lines.push(`# Invoke agent: ${job.agent}`);
|
|
2234
|
-
lines.push(`claude --agent ${job.agent}`);
|
|
2584
|
+
lines.push(`claude -p "Run your scheduled task (${job.id})." --agent ${job.agent}`);
|
|
2235
2585
|
}
|
|
2236
2586
|
else if (job.action) {
|
|
2237
2587
|
lines.push(`# Invoke action: ${job.action}`);
|
|
2238
|
-
lines.push(`bash scripts/action-${job.action}.sh`);
|
|
2588
|
+
lines.push(`bash .claude/skills/${ast.topology.name}/scripts/action-${job.action}.sh`);
|
|
2239
2589
|
}
|
|
2240
2590
|
if (job.enabled === false) {
|
|
2241
2591
|
lines.unshift("# DISABLED — this schedule is not enabled");
|
|
@@ -2245,18 +2595,12 @@ function generateScheduleScripts(ast) {
|
|
|
2245
2595
|
// Add crontab entry
|
|
2246
2596
|
let cronExpr = job.cron ?? "# every: " + (job.every ?? "unknown");
|
|
2247
2597
|
if (!job.cron && job.every) {
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
"
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
"1 day": "0 0 * * *",
|
|
2255
|
-
"daily": "0 0 * * *",
|
|
2256
|
-
"1w": "0 0 * * 0",
|
|
2257
|
-
"weekly": "0 0 * * 0",
|
|
2258
|
-
};
|
|
2259
|
-
cronExpr = everyMap[job.every.toLowerCase()] ?? `# every: ${job.every}`;
|
|
2598
|
+
const converted = everyToCron(job.every);
|
|
2599
|
+
if (!converted) {
|
|
2600
|
+
console.warn(`[claude-code] Schedule "${job.id}" every: ${job.every} has no cron form; ` +
|
|
2601
|
+
`its crontab line is commented out. Use cron: instead.`);
|
|
2602
|
+
}
|
|
2603
|
+
cronExpr = converted ?? `# every: ${job.every}`;
|
|
2260
2604
|
}
|
|
2261
2605
|
const enabledPrefix = job.enabled === false ? "# DISABLED: " : "";
|
|
2262
2606
|
cronLines.push(`${enabledPrefix}${cronExpr} bash ${scriptPath} # ${job.id}`);
|
|
@@ -2424,7 +2768,11 @@ function generateWorkspaceProtocol(ast) {
|
|
|
2424
2768
|
* destroy their content.
|
|
2425
2769
|
*/
|
|
2426
2770
|
function generateContextFile(ast) {
|
|
2427
|
-
|
|
2771
|
+
// A rules file, one per topology: Claude Code loads .claude/rules/*.md like
|
|
2772
|
+
// CLAUDE.md, so the context still reaches every session, but it never
|
|
2773
|
+
// competes with the project's own CLAUDE.md or .claude/CLAUDE.md (it used to
|
|
2774
|
+
// be written to .claude/CLAUDE.md, a conflict in any project that had one).
|
|
2775
|
+
const DEFAULT_PATH = `.claude/rules/${ast.topology.name}.md`;
|
|
2428
2776
|
let fileName = ast.context.file ?? DEFAULT_PATH;
|
|
2429
2777
|
// Safety: never overwrite the user's root-level CLAUDE.md
|
|
2430
2778
|
if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {
|
|
@@ -2621,7 +2969,7 @@ function generateWorkflowLaunchArtifacts(ast) {
|
|
|
2621
2969
|
}
|
|
2622
2970
|
/**
|
|
2623
2971
|
* HYBRID: build the "Hybrid run order" markdown block appended to the generated
|
|
2624
|
-
* context file (.claude/
|
|
2972
|
+
* context file (.claude/rules/<topology>.md) and the topology SKILL.md. It states the
|
|
2625
2973
|
* host's interleaving with the embedded workflow rung, in the SAME run-order
|
|
2626
2974
|
* vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
|
|
2627
2975
|
* stay in sync. Returns null when the topology is not a hybrid.
|
|
@@ -2738,49 +3086,28 @@ function generateObserveBlackboardScript(ast) {
|
|
|
2738
3086
|
/**
|
|
2739
3087
|
* Merge `ast.env` into the settings.json output under an `env` key.
|
|
2740
3088
|
*
|
|
2741
|
-
*
|
|
2742
|
-
*
|
|
2743
|
-
*
|
|
3089
|
+
* Claude Code applies settings `env` values exactly as written — no shell
|
|
3090
|
+
* expands them — and they override the variable exported in the shell. So a
|
|
3091
|
+
* reference such as `${ANTHROPIC_API_KEY}` must never be emitted: it replaced
|
|
3092
|
+
* the real key with that literal text, and every session in the project failed
|
|
3093
|
+
* with "Invalid API key" (6 of the 10 examples did this). Only literal values
|
|
3094
|
+
* are written; references, secrets and encrypted values stay with the shell.
|
|
3095
|
+
* The anthropic provider's api-key is the same kind of reference and is not
|
|
3096
|
+
* emitted either.
|
|
2744
3097
|
*
|
|
2745
3098
|
* Returns the env record if non-empty, otherwise null.
|
|
2746
3099
|
*/
|
|
2747
3100
|
function generateEnvToSettings(ast) {
|
|
2748
3101
|
const env = {};
|
|
2749
3102
|
for (const [key, val] of Object.entries(ast.env)) {
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
env[`# ${key}_SECRET`] = sv.secretRef.uri;
|
|
2759
|
-
}
|
|
2760
|
-
else if (sv.sensitive) {
|
|
2761
|
-
// Sensitive value — never emit the actual value
|
|
2762
|
-
env[key] = `\${${key}}`;
|
|
2763
|
-
}
|
|
2764
|
-
else if (sv.encrypted) {
|
|
2765
|
-
// Encrypted value — emit placeholder with warning
|
|
2766
|
-
env[key] = `\${${key}}`;
|
|
2767
|
-
env[`# ${key}_ENCRYPTED`] = "WARNING: value is SOPS-encrypted, decrypt before use";
|
|
2768
|
-
}
|
|
2769
|
-
else {
|
|
2770
|
-
env[key] = sv.value;
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2774
|
-
// If an anthropic provider exists, scaffold the API key env var
|
|
2775
|
-
if (ast.providers && ast.providers.length > 0) {
|
|
2776
|
-
const anthropicProvider = ast.providers.find((p) => p.name === "anthropic");
|
|
2777
|
-
if (anthropicProvider?.apiKey) {
|
|
2778
|
-
// Extract env var name from ${ENV_VAR} format
|
|
2779
|
-
const envVarMatch = anthropicProvider.apiKey.match(/^\$\{(.+)\}$/);
|
|
2780
|
-
if (envVarMatch) {
|
|
2781
|
-
env[envVarMatch[1]] = anthropicProvider.apiKey;
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
3103
|
+
const literal = typeof val === "string"
|
|
3104
|
+
? val
|
|
3105
|
+
: val.secretRef || val.sensitive || val.encrypted
|
|
3106
|
+
? undefined
|
|
3107
|
+
: val.value;
|
|
3108
|
+
if (literal === undefined || literal.includes("${"))
|
|
3109
|
+
continue;
|
|
3110
|
+
env[key] = literal;
|
|
2784
3111
|
}
|
|
2785
3112
|
if (Object.keys(env).length === 0)
|
|
2786
3113
|
return null;
|
|
@@ -2804,6 +3131,80 @@ function generateTriggers(ast) {
|
|
|
2804
3131
|
// Binding export
|
|
2805
3132
|
// ---------------------------------------------------------------------------
|
|
2806
3133
|
/** Claude Code binding — the reference binding implementation. */
|
|
3134
|
+
/**
|
|
3135
|
+
* Drop every generated file that belongs to a hand-written node (`file:`).
|
|
3136
|
+
*
|
|
3137
|
+
* An authored node keeps its file (a group also its skill and runtime
|
|
3138
|
+
* folder); an authored skill keeps its whole folder
|
|
3139
|
+
* (SKILL.md, scripts, references); an authored command keeps its file. The
|
|
3140
|
+
* topology still describes them — gates still wire to an authored agent by
|
|
3141
|
+
* name — but the binding never writes inside them. Without this, importing a
|
|
3142
|
+
* hand-built .claude and scaffolding it back replaced 37 of 72 files and cut
|
|
3143
|
+
* the rest down (a 404-line SKILL.md came back as 9 lines).
|
|
3144
|
+
*/
|
|
3145
|
+
function withoutAuthored(ast, files) {
|
|
3146
|
+
const exact = new Set();
|
|
3147
|
+
const folders = [];
|
|
3148
|
+
for (const node of ast.nodes) {
|
|
3149
|
+
if (!node.file)
|
|
3150
|
+
continue;
|
|
3151
|
+
// Everything the binding would write for this node: its agent file, and
|
|
3152
|
+
// for a group its skill and its runtime folder.
|
|
3153
|
+
exact.add(`.claude/agents/${node.id}/AGENT.md`);
|
|
3154
|
+
exact.add(`.claude/agents/${node.id}.md`);
|
|
3155
|
+
exact.add(normalizeRel(node.file));
|
|
3156
|
+
if (node.type === "group") {
|
|
3157
|
+
folders.push(`.claude/skills/${node.id}/`, `.claude/groups/${node.id}/`);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
for (const skill of ast.skills ?? []) {
|
|
3161
|
+
if (!skill.file)
|
|
3162
|
+
continue;
|
|
3163
|
+
folders.push(`.claude/skills/${skill.id}/`);
|
|
3164
|
+
exact.add(normalizeRel(skill.file));
|
|
3165
|
+
}
|
|
3166
|
+
for (const trigger of ast.triggers ?? []) {
|
|
3167
|
+
if (!trigger.file)
|
|
3168
|
+
continue;
|
|
3169
|
+
exact.add(`.claude/commands/${trigger.name}.md`);
|
|
3170
|
+
exact.add(normalizeRel(trigger.file));
|
|
3171
|
+
}
|
|
3172
|
+
if (exact.size === 0 && folders.length === 0)
|
|
3173
|
+
return files;
|
|
3174
|
+
const isAuthored = (path) => {
|
|
3175
|
+
const p = normalizeRel(path);
|
|
3176
|
+
return exact.has(p) || folders.some((dir) => p.startsWith(dir));
|
|
3177
|
+
};
|
|
3178
|
+
const dropped = files.filter((f) => isAuthored(f.path)).map((f) => normalizeRel(f.path));
|
|
3179
|
+
return files
|
|
3180
|
+
.filter((f) => !isAuthored(f.path))
|
|
3181
|
+
.map((f) => (/(^|\/)\.claude\/settings\.json$/.test(f.path) ? withoutHooksRunning(f, dropped) : f));
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* settings.json without the hook groups that run a script this scaffold no
|
|
3185
|
+
* longer writes. A gate whose wrapper sits in an authored skill folder would
|
|
3186
|
+
* otherwise be wired to a file that does not exist, and fire on every stop.
|
|
3187
|
+
*/
|
|
3188
|
+
function withoutHooksRunning(file, dropped) {
|
|
3189
|
+
if (dropped.length === 0)
|
|
3190
|
+
return file;
|
|
3191
|
+
const settings = JSON.parse(file.content);
|
|
3192
|
+
if (!settings.hooks)
|
|
3193
|
+
return file;
|
|
3194
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
3195
|
+
const kept = groups.filter((g) => !(g.hooks ?? []).some((h) => dropped.some((p) => (h.command ?? "").includes(p))));
|
|
3196
|
+
if (kept.length > 0)
|
|
3197
|
+
settings.hooks[event] = kept;
|
|
3198
|
+
else
|
|
3199
|
+
delete settings.hooks[event];
|
|
3200
|
+
}
|
|
3201
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
3202
|
+
delete settings.hooks;
|
|
3203
|
+
return { ...file, content: JSON.stringify(settings, null, 2) + "\n" };
|
|
3204
|
+
}
|
|
3205
|
+
function normalizeRel(p) {
|
|
3206
|
+
return p.replace(/^\.\//, "");
|
|
3207
|
+
}
|
|
2807
3208
|
export const claudeCodeBinding = {
|
|
2808
3209
|
name: "claude-code",
|
|
2809
3210
|
description: "Anthropic Claude Code CLI — generates .claude/ directory structure, .mcp.json, and scripts.",
|
|
@@ -2815,7 +3216,7 @@ export const claudeCodeBinding = {
|
|
|
2815
3216
|
files.push(...generateTopologySkill(ast));
|
|
2816
3217
|
files.push(...generateAgents(ast));
|
|
2817
3218
|
files.push(...generateSchemas(ast));
|
|
2818
|
-
return deduplicateFiles(files);
|
|
3219
|
+
return withoutAuthored(ast, deduplicateFiles(files));
|
|
2819
3220
|
}
|
|
2820
3221
|
const files = [];
|
|
2821
3222
|
// Action nodes
|
|
@@ -2825,7 +3226,6 @@ export const claudeCodeBinding = {
|
|
|
2825
3226
|
// Agent files — may include group conversation protocol sections
|
|
2826
3227
|
files.push(...generateAgents(ast));
|
|
2827
3228
|
// Human nodes
|
|
2828
|
-
files.push(...generateHumanNodes(ast));
|
|
2829
3229
|
// Main topology skill (with frontmatter)
|
|
2830
3230
|
const skillFiles = generateTopologySkill(ast);
|
|
2831
3231
|
// Append trigger info to the main skill
|
|
@@ -2844,10 +3244,15 @@ export const claudeCodeBinding = {
|
|
|
2844
3244
|
// Gate scripts
|
|
2845
3245
|
files.push(...generateGateScripts(ast));
|
|
2846
3246
|
files.push(...generateGateWrapperScripts(ast));
|
|
3247
|
+
const beforeAgent = generateBeforeAgentScript(ast);
|
|
3248
|
+
if (beforeAgent)
|
|
3249
|
+
files.push(beforeAgent);
|
|
2847
3250
|
// Hook scripts (global)
|
|
2848
3251
|
files.push(...generateHookScripts(ast));
|
|
2849
3252
|
// Hook scripts (per-agent)
|
|
2850
3253
|
files.push(...generateAgentHookScripts(ast));
|
|
3254
|
+
// Mods (type: mod) → a plugin directory; never settings.json
|
|
3255
|
+
files.push(...generateModPlugin(ast));
|
|
2851
3256
|
// HYBRID: host-side launch directives for the embedded workflow rung(s).
|
|
2852
3257
|
files.push(...generateWorkflowLaunchArtifacts(ast));
|
|
2853
3258
|
// HYBRID: host-layer concurrent Blackboard observer script (paired with the
|
|
@@ -2879,7 +3284,7 @@ export const claudeCodeBinding = {
|
|
|
2879
3284
|
files.push(meteringFile);
|
|
2880
3285
|
// Context file (.claude/CONTEXT.md or custom — never root CLAUDE.md)
|
|
2881
3286
|
files.push(generateContextFile(ast));
|
|
2882
|
-
return deduplicateFiles(files);
|
|
3287
|
+
return withoutAuthored(ast, deduplicateFiles(files));
|
|
2883
3288
|
},
|
|
2884
3289
|
};
|
|
2885
3290
|
//# sourceMappingURL=claude-code.js.map
|