agentopology 0.6.0 → 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/scaffold-owns.test.js +12 -3
- package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -1
- 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__/live/claude-code.live.test.js +66 -4
- package/dist/__tests__/live/claude-code.live.test.js.map +1 -1
- package/dist/__tests__/open-findings.test.js +0 -22
- package/dist/__tests__/open-findings.test.js.map +1 -1
- 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__/hybrid-e2e.test.js +1 -1
- package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
- package/dist/bindings/claude-code.d.ts +6 -8
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +361 -194
- package/dist/bindings/claude-code.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/cli/index.js +9 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/docs/teach.d.ts.map +1 -1
- package/dist/docs/teach.js +10 -0
- package/dist/docs/teach.js.map +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +26 -3
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/run.d.ts +23 -2
- package/dist/parser/run.d.ts.map +1 -1
- package/dist/parser/run.js +39 -3
- package/dist/parser/run.js.map +1 -1
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +52 -2
- package/dist/parser/validator.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/merge.d.ts.map +1 -1
- package/dist/scaffold/merge.js +14 -0
- package/dist/scaffold/merge.js.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 +4 -1
- package/spec/testing.md +26 -22
- package/spec/validation.md +3 -1
|
@@ -8,10 +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";
|
|
14
|
-
import { proseInRun, scriptNameFromRun } from "../parser/run.js";
|
|
15
|
+
import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Helpers
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -22,13 +23,43 @@ import { proseInRun, scriptNameFromRun } from "../parser/run.js";
|
|
|
22
23
|
function convertMcpToolName(tool) {
|
|
23
24
|
if (!tool.startsWith("mcp."))
|
|
24
25
|
return tool;
|
|
25
|
-
// mcp.server
|
|
26
|
-
// Only
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 } : {}) };
|
|
30
61
|
}
|
|
31
|
-
return
|
|
62
|
+
return { type: "command", command: command(), ...(hook.timeout ? { timeout: hook.timeout } : {}) };
|
|
32
63
|
}
|
|
33
64
|
/**
|
|
34
65
|
* Downshift all markdown headings by one level so they nest properly
|
|
@@ -55,6 +86,8 @@ function toTitle(id) {
|
|
|
55
86
|
*/
|
|
56
87
|
/** Characters that require quoting in YAML scalar values. */
|
|
57
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)$/;
|
|
58
91
|
function frontmatter(fields) {
|
|
59
92
|
const lines = ["---"];
|
|
60
93
|
for (const [key, value] of Object.entries(fields)) {
|
|
@@ -68,13 +101,13 @@ function frontmatter(fields) {
|
|
|
68
101
|
lines.push(` - ${item}`);
|
|
69
102
|
}
|
|
70
103
|
}
|
|
71
|
-
else if (typeof value === "boolean") {
|
|
104
|
+
else if (typeof value === "boolean" || typeof value === "number") {
|
|
72
105
|
lines.push(`${key}: ${value}`);
|
|
73
106
|
}
|
|
74
107
|
else {
|
|
75
108
|
// Auto-quote strings containing YAML-special characters
|
|
76
109
|
const str = String(value);
|
|
77
|
-
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);
|
|
78
111
|
lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
|
|
79
112
|
}
|
|
80
113
|
}
|
|
@@ -106,7 +139,96 @@ function getDelegationMode(ast) {
|
|
|
106
139
|
const orch = ast.nodes.find((n) => n.type === "orchestrator");
|
|
107
140
|
return orch?.delegation === "inline" ? "inline" : "subagent";
|
|
108
141
|
}
|
|
109
|
-
|
|
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
|
+
}
|
|
110
232
|
function gitkeep(dirPath) {
|
|
111
233
|
return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
|
|
112
234
|
}
|
|
@@ -252,7 +374,7 @@ function workflowRunLabel(ast, run) {
|
|
|
252
374
|
*/
|
|
253
375
|
function mapPermissionMode(perm) {
|
|
254
376
|
// Claude Code native values — pass through
|
|
255
|
-
const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
|
|
377
|
+
const nativeValues = ["plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"];
|
|
256
378
|
if (nativeValues.includes(perm)) {
|
|
257
379
|
// "default" is the default, omit it
|
|
258
380
|
if (perm === "default")
|
|
@@ -265,14 +387,16 @@ function mapPermissionMode(perm) {
|
|
|
265
387
|
return "plan";
|
|
266
388
|
case "autonomous":
|
|
267
389
|
return undefined; // default, omit
|
|
268
|
-
case "
|
|
269
|
-
return undefined; // default
|
|
390
|
+
case "confirm":
|
|
391
|
+
return undefined; // asks before each action: Claude Code's default
|
|
270
392
|
case "interactive":
|
|
271
393
|
return "acceptEdits";
|
|
272
394
|
case "unrestricted":
|
|
395
|
+
case "bypass":
|
|
273
396
|
return "bypassPermissions";
|
|
274
397
|
default:
|
|
275
|
-
|
|
398
|
+
// Claude Code rejects an unknown permissionMode; V27 already warned.
|
|
399
|
+
return undefined;
|
|
276
400
|
}
|
|
277
401
|
}
|
|
278
402
|
/** Format a SchemaType to a human-readable string. Optionally resolves refs. */
|
|
@@ -326,45 +450,24 @@ function generateActionNodes(ast) {
|
|
|
326
450
|
if (action.join) {
|
|
327
451
|
lines.push(`# Join semantics: ${action.join}`);
|
|
328
452
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
case "git":
|
|
348
|
-
lines.push("");
|
|
349
|
-
lines.push("# Git operation");
|
|
350
|
-
if (action.commands && action.commands.length > 0) {
|
|
351
|
-
for (const cmd of action.commands) {
|
|
352
|
-
lines.push(cmd);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
lines.push('echo "TODO: implement git action"');
|
|
357
|
-
}
|
|
358
|
-
break;
|
|
359
|
-
case "report":
|
|
360
|
-
lines.push("");
|
|
361
|
-
lines.push("# Report generation");
|
|
362
|
-
lines.push('echo "TODO: implement report action"');
|
|
363
|
-
break;
|
|
364
|
-
default:
|
|
365
|
-
lines.push("");
|
|
366
|
-
lines.push(`echo "TODO: implement ${action.kind ?? "unknown"} action"`);
|
|
367
|
-
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");
|
|
368
471
|
}
|
|
369
472
|
lines.push("");
|
|
370
473
|
files.push({
|
|
@@ -393,17 +496,24 @@ function generateAgents(ast) {
|
|
|
393
496
|
const fm = {};
|
|
394
497
|
fm.name = agent.id;
|
|
395
498
|
// Description: prefer agent.description, then role description from roles block.
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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.`);
|
|
401
513
|
}
|
|
402
514
|
}
|
|
403
|
-
if (agent.model)
|
|
404
|
-
fm.model = agent.model;
|
|
405
515
|
if (agent.maxTurns != null)
|
|
406
|
-
fm.maxTurns =
|
|
516
|
+
fm.maxTurns = agent.maxTurns;
|
|
407
517
|
if (agent.tools && agent.tools.length > 0)
|
|
408
518
|
fm.tools = agent.tools.map(convertMcpToolName).join(", ");
|
|
409
519
|
if (agent.disallowedTools && agent.disallowedTools.length > 0)
|
|
@@ -427,8 +537,6 @@ function generateAgents(ast) {
|
|
|
427
537
|
if (agent.fallbackChain && agent.fallbackChain.length > 0) {
|
|
428
538
|
fm["fallback-chain"] = agent.fallbackChain.join(", ");
|
|
429
539
|
}
|
|
430
|
-
// invocation: manual — Claude Code doesn't parse this field. Instead, omit
|
|
431
|
-
// description to prevent auto-delegation for manual agents.
|
|
432
540
|
if (agent.skills && agent.skills.length > 0)
|
|
433
541
|
fm.skills = agent.skills;
|
|
434
542
|
// scale is NOT a Claude Code frontmatter field — rendered only in body as prompt text
|
|
@@ -452,30 +560,28 @@ function generateAgents(ast) {
|
|
|
452
560
|
for (const hook of agent.hooks) {
|
|
453
561
|
if (hook.type === "mod")
|
|
454
562
|
continue; // engine-wide only (V94)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
hooksByEvent[
|
|
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);
|
|
458
568
|
}
|
|
459
569
|
const hookLines = ["hooks:"];
|
|
460
570
|
for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
|
|
461
571
|
hookLines.push(` ${eventName}:`);
|
|
462
572
|
for (const hook of hooks) {
|
|
463
573
|
const scriptName = scriptNameFromRun(hook.run);
|
|
464
|
-
if (!scriptName)
|
|
574
|
+
if (hook.type !== "prompt" && !scriptName)
|
|
465
575
|
continue; // V96: prose in run names no script
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
else {
|
|
471
|
-
hookLines.push(` -`);
|
|
472
|
-
}
|
|
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)}` : ` -`);
|
|
473
580
|
hookLines.push(` hooks:`);
|
|
474
|
-
|
|
475
|
-
hookLines.push(`
|
|
476
|
-
|
|
477
|
-
hookLines.push(`
|
|
478
|
-
}
|
|
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)}`);
|
|
479
585
|
}
|
|
480
586
|
}
|
|
481
587
|
// Insert hooks before the closing ---
|
|
@@ -493,6 +599,9 @@ function generateAgents(ast) {
|
|
|
493
599
|
}
|
|
494
600
|
sections.push("## Instructions");
|
|
495
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);
|
|
496
605
|
if (agent.prompt) {
|
|
497
606
|
sections.push(downshiftHeadings(agent.prompt));
|
|
498
607
|
}
|
|
@@ -507,6 +616,7 @@ function generateAgents(ast) {
|
|
|
507
616
|
else {
|
|
508
617
|
sections.push("Complete the assigned task.");
|
|
509
618
|
}
|
|
619
|
+
sections.push(PROMPT_FENCE_CLOSE);
|
|
510
620
|
sections.push("");
|
|
511
621
|
// Reads section
|
|
512
622
|
if (agent.reads && agent.reads.length > 0) {
|
|
@@ -814,35 +924,6 @@ function generateAgents(ast) {
|
|
|
814
924
|
}
|
|
815
925
|
return files;
|
|
816
926
|
}
|
|
817
|
-
/** Generate AGENT.md files for each human node. */
|
|
818
|
-
function generateHumanNodes(ast) {
|
|
819
|
-
const files = [];
|
|
820
|
-
for (const node of ast.nodes) {
|
|
821
|
-
if (node.type !== "human")
|
|
822
|
-
continue;
|
|
823
|
-
const human = node;
|
|
824
|
-
const sections = [];
|
|
825
|
-
sections.push(`# ${toTitle(human.id)} (Human-in-the-Loop)`);
|
|
826
|
-
sections.push("");
|
|
827
|
-
if (human.description) {
|
|
828
|
-
sections.push(human.description);
|
|
829
|
-
sections.push("");
|
|
830
|
-
}
|
|
831
|
-
if (human.timeout) {
|
|
832
|
-
sections.push(`Timeout: ${human.timeout}`);
|
|
833
|
-
}
|
|
834
|
-
if (human.onTimeout) {
|
|
835
|
-
sections.push(`On timeout: ${human.onTimeout}`);
|
|
836
|
-
}
|
|
837
|
-
sections.push("");
|
|
838
|
-
files.push({
|
|
839
|
-
path: `.claude/agents/${human.id}/AGENT.md`,
|
|
840
|
-
content: sections.join("\n") + "\n",
|
|
841
|
-
category: "machine",
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
return files;
|
|
845
|
-
}
|
|
846
927
|
/**
|
|
847
928
|
* Generate group chat artifacts:
|
|
848
929
|
* - Orchestrator AGENT.md — spawns members sequentially
|
|
@@ -1259,6 +1340,29 @@ function generateTopologySkill(ast) {
|
|
|
1259
1340
|
sections.push("");
|
|
1260
1341
|
}
|
|
1261
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
|
+
}
|
|
1262
1366
|
// Schedules section
|
|
1263
1367
|
if (ast.schedules.length > 0) {
|
|
1264
1368
|
sections.push("## Schedules");
|
|
@@ -1453,9 +1557,19 @@ function generateSkills(ast) {
|
|
|
1453
1557
|
}
|
|
1454
1558
|
sections.push("");
|
|
1455
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.
|
|
1456
1563
|
if (skill.prompt) {
|
|
1457
|
-
sections.push("##
|
|
1458
|
-
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
|
+
}
|
|
1459
1573
|
sections.push("");
|
|
1460
1574
|
}
|
|
1461
1575
|
files.push({
|
|
@@ -1647,13 +1761,21 @@ function generateGateScripts(ast) {
|
|
|
1647
1761
|
continue;
|
|
1648
1762
|
const gate = node;
|
|
1649
1763
|
const scriptName = scriptNameFromRun(gate.run);
|
|
1650
|
-
if (scriptName)
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
+
});
|
|
1657
1779
|
}
|
|
1658
1780
|
return files;
|
|
1659
1781
|
}
|
|
@@ -1669,20 +1791,22 @@ function generateGateWrapperScripts(ast) {
|
|
|
1669
1791
|
continue;
|
|
1670
1792
|
if (gate.behavior === "advisory")
|
|
1671
1793
|
continue;
|
|
1672
|
-
|
|
1673
|
-
if (!gateScriptName)
|
|
1794
|
+
if (!scriptNameFromRun(gate.run))
|
|
1674
1795
|
continue; // V96: prose in run names no script
|
|
1796
|
+
const gateCommand = hookCommand(gate.run, name);
|
|
1675
1797
|
const onFailExit = gate.onFail === "halt"
|
|
1676
1798
|
? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
|
|
1677
1799
|
: `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
|
|
1678
1800
|
const inlineMode = getDelegationMode(ast) === "inline";
|
|
1679
|
-
const
|
|
1680
|
-
const wiredAsHook =
|
|
1681
|
-
gateAfters.length > 0 &&
|
|
1682
|
-
gateAfters.every((a) => isRegisteredSubagentType(ast, a));
|
|
1801
|
+
const wiring = gateWiring(ast, gate);
|
|
1802
|
+
const wiredAsHook = wiring.kind === "subagent-stop";
|
|
1683
1803
|
let enforcementNote;
|
|
1684
1804
|
let enforcementNote2;
|
|
1685
|
-
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) {
|
|
1686
1810
|
enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
|
|
1687
1811
|
enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
|
|
1688
1812
|
}
|
|
@@ -1718,10 +1842,10 @@ function generateGateWrapperScripts(ast) {
|
|
|
1718
1842
|
contentLines.push("while [ $ATTEMPT -le $MAX_RETRIES ] && [ $GATE_RESULT -ne 0 ]; do");
|
|
1719
1843
|
contentLines.push(" ATTEMPT=$((ATTEMPT + 1))");
|
|
1720
1844
|
if (gate.timeout) {
|
|
1721
|
-
contentLines.push(` timeout ${gate.timeout}
|
|
1845
|
+
contentLines.push(` timeout ${gate.timeout} ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
|
|
1722
1846
|
}
|
|
1723
1847
|
else {
|
|
1724
|
-
contentLines.push(`
|
|
1848
|
+
contentLines.push(` ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
|
|
1725
1849
|
}
|
|
1726
1850
|
contentLines.push(' if [ $GATE_RESULT -ne 0 ] && [ $ATTEMPT -le $MAX_RETRIES ]; then');
|
|
1727
1851
|
contentLines.push(` echo "Gate '${gate.id}' attempt $ATTEMPT failed, retrying..."`);
|
|
@@ -1734,10 +1858,10 @@ function generateGateWrapperScripts(ast) {
|
|
|
1734
1858
|
contentLines.push("# Run the gate check script");
|
|
1735
1859
|
contentLines.push(`GATE_RESULT=0`);
|
|
1736
1860
|
if (gate.timeout) {
|
|
1737
|
-
contentLines.push(`timeout ${gate.timeout}
|
|
1861
|
+
contentLines.push(`timeout ${gate.timeout} ${gateCommand} || GATE_RESULT=$?`);
|
|
1738
1862
|
}
|
|
1739
1863
|
else {
|
|
1740
|
-
contentLines.push(
|
|
1864
|
+
contentLines.push(`${gateCommand} || GATE_RESULT=$?`);
|
|
1741
1865
|
}
|
|
1742
1866
|
}
|
|
1743
1867
|
contentLines.push("");
|
|
@@ -1767,7 +1891,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
|
|
|
1767
1891
|
if (isFinishEvent) {
|
|
1768
1892
|
return [
|
|
1769
1893
|
"#!/usr/bin/env bash",
|
|
1770
|
-
`# Auto-generated by agentopology scaffold
|
|
1894
|
+
`# Auto-generated by agentopology scaffold`,
|
|
1771
1895
|
`# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
|
|
1772
1896
|
"# Replace this stub with your implementation.",
|
|
1773
1897
|
"# Stdin JSON payload from Claude Code is available on FD 0.",
|
|
@@ -1783,7 +1907,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
|
|
|
1783
1907
|
}
|
|
1784
1908
|
return [
|
|
1785
1909
|
"#!/usr/bin/env bash",
|
|
1786
|
-
`# Auto-generated by agentopology scaffold
|
|
1910
|
+
`# Auto-generated by agentopology scaffold`,
|
|
1787
1911
|
`# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
|
|
1788
1912
|
"# Replace this stub with your implementation.",
|
|
1789
1913
|
"# Stdin JSON payload from Claude Code is available on FD 0.",
|
|
@@ -1922,7 +2046,7 @@ function generateHookScripts(ast) {
|
|
|
1922
2046
|
continue;
|
|
1923
2047
|
// A Mod's run: is the user's TypeScript module — never overwrite it with a
|
|
1924
2048
|
// bash stub. generateModPlugin imports it instead.
|
|
1925
|
-
if (hook.type === "mod")
|
|
2049
|
+
if (hook.type === "mod" || hook.type === "prompt")
|
|
1926
2050
|
continue;
|
|
1927
2051
|
const runPath = hook.run;
|
|
1928
2052
|
// If the run path starts with '.' or '/' it is already project-relative —
|
|
@@ -1930,12 +2054,12 @@ function generateHookScripts(ast) {
|
|
|
1930
2054
|
// Otherwise scope the stub into this topology's skill scripts directory.
|
|
1931
2055
|
if (proseInRun(runPath))
|
|
1932
2056
|
continue; // V96: prose in run names no script
|
|
1933
|
-
const { script } =
|
|
2057
|
+
const { script } = runParts(runPath);
|
|
1934
2058
|
// An absolute script is the user's own, outside the project: never stubbed.
|
|
1935
2059
|
if (script.startsWith("/"))
|
|
1936
2060
|
continue;
|
|
1937
2061
|
const scoped = scriptNameFromRun(script);
|
|
1938
|
-
const filePath =
|
|
2062
|
+
const filePath = isProjectPath(script)
|
|
1939
2063
|
? script
|
|
1940
2064
|
: scoped
|
|
1941
2065
|
? `.claude/skills/${name}/scripts/${scoped}`
|
|
@@ -1949,7 +2073,7 @@ function generateHookScripts(ast) {
|
|
|
1949
2073
|
executable: true,
|
|
1950
2074
|
// A run that names its own path names the user's script: a stub only
|
|
1951
2075
|
// fills the gap until it exists. --force used to overwrite it.
|
|
1952
|
-
...(
|
|
2076
|
+
...(isProjectPath(script) ? { createOnly: true } : {}),
|
|
1953
2077
|
});
|
|
1954
2078
|
}
|
|
1955
2079
|
return files;
|
|
@@ -1965,7 +2089,7 @@ function generateAgentHookScripts(ast) {
|
|
|
1965
2089
|
if (!agent.hooks || agent.hooks.length === 0)
|
|
1966
2090
|
continue;
|
|
1967
2091
|
for (const hook of agent.hooks) {
|
|
1968
|
-
if (!hook.run || hook.type === "mod")
|
|
2092
|
+
if (!hook.run || hook.type === "mod" || hook.type === "prompt")
|
|
1969
2093
|
continue;
|
|
1970
2094
|
const scriptName = scriptNameFromRun(hook.run);
|
|
1971
2095
|
if (!scriptName)
|
|
@@ -1987,31 +2111,22 @@ function generateAgentHookScripts(ast) {
|
|
|
1987
2111
|
}
|
|
1988
2112
|
return files;
|
|
1989
2113
|
}
|
|
1990
|
-
const isHookPath = (w = "") => w.startsWith(".") || w.startsWith("/");
|
|
1991
|
-
/** A hook's `run:` split: the interpreter it names (if any), its script, its arguments. */
|
|
1992
|
-
function hookRun(run) {
|
|
1993
|
-
const words = run.trim().split(/\s+/);
|
|
1994
|
-
const named = words.length > 1 && !/\.(py|sh|ts|mjs|js)$/.test(words[0]) && isHookPath(words[1]);
|
|
1995
|
-
const [script = "", ...args] = named ? words.slice(1) : words;
|
|
1996
|
-
return { ...(named ? { interpreter: words[0] } : {}), script, args };
|
|
1997
|
-
}
|
|
1998
2114
|
/**
|
|
1999
|
-
* The shell command for a command hook's `run:`.
|
|
2115
|
+
* The shell command for a command hook's or gate's `run:`.
|
|
2000
2116
|
*
|
|
2001
|
-
* A `run:` whose script (
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
* names, else the one its extension names (`.py` → python3, anything else →
|
|
2007
|
-
* bash), with its arguments kept.
|
|
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.
|
|
2008
2122
|
*
|
|
2009
2123
|
* Any other `run:` is a script this topology's skill scaffolds, by basename.
|
|
2010
2124
|
*/
|
|
2011
2125
|
export function hookCommand(run, topologyName) {
|
|
2012
|
-
const { interpreter: named, script: first, args: rest } =
|
|
2013
|
-
if (!
|
|
2014
|
-
|
|
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(" ");
|
|
2015
2130
|
}
|
|
2016
2131
|
const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
|
|
2017
2132
|
const where = first.startsWith("/")
|
|
@@ -2035,7 +2150,7 @@ export function settingsHooksOf(ast) {
|
|
|
2035
2150
|
// emitted as a plugin (generateModPlugin).
|
|
2036
2151
|
if (hook.type === "mod")
|
|
2037
2152
|
continue;
|
|
2038
|
-
const eventName = hook.on;
|
|
2153
|
+
const eventName = claudeEvent(hook.on);
|
|
2039
2154
|
// Inline orchestrator mode never spawns subagents, so SubagentStop /
|
|
2040
2155
|
// SubagentStart hooks would never fire. Warn the user and skip emission
|
|
2041
2156
|
// — keeping them in settings.json would be a maintenance trap.
|
|
@@ -2046,19 +2161,13 @@ export function settingsHooksOf(ast) {
|
|
|
2046
2161
|
`delegation to "subagent".`);
|
|
2047
2162
|
continue;
|
|
2048
2163
|
}
|
|
2049
|
-
if (proseInRun(hook.run))
|
|
2164
|
+
if (hook.type !== "prompt" && proseInRun(hook.run))
|
|
2050
2165
|
continue; // V96: prose in run names no script
|
|
2051
2166
|
if (!hooksByEvent[eventName]) {
|
|
2052
2167
|
hooksByEvent[eventName] = [];
|
|
2053
2168
|
}
|
|
2054
2169
|
const hookEntry = {
|
|
2055
|
-
hooks: [
|
|
2056
|
-
{
|
|
2057
|
-
type: hook.type ?? "command",
|
|
2058
|
-
command: hookCommand(hook.run, topologyName),
|
|
2059
|
-
...(hook.timeout ? { timeout: hook.timeout } : {}),
|
|
2060
|
-
},
|
|
2061
|
-
],
|
|
2170
|
+
hooks: [hookHandler(hook, () => hookCommand(hook.run, topologyName))],
|
|
2062
2171
|
};
|
|
2063
2172
|
// Only add matcher when it has a value
|
|
2064
2173
|
if (hook.matcher) {
|
|
@@ -2121,11 +2230,15 @@ function generateSettings(ast) {
|
|
|
2121
2230
|
`orchestrator playbook at the right step.`);
|
|
2122
2231
|
continue;
|
|
2123
2232
|
}
|
|
2124
|
-
// SubagentStop matcher must be a registered subagent_type.
|
|
2125
|
-
//
|
|
2126
|
-
// 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.
|
|
2127
2237
|
const anchors = gateAnchors(gate, "after");
|
|
2128
|
-
|
|
2238
|
+
const wiring = gateWiring(ast, gate);
|
|
2239
|
+
if (wiring.kind === "before-agent")
|
|
2240
|
+
continue;
|
|
2241
|
+
if (wiring.kind !== "subagent-stop") {
|
|
2129
2242
|
const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
|
|
2130
2243
|
const targetDesc = anchors.length
|
|
2131
2244
|
? `non-agent target ${bad.map((b) => `"${b}"`).join(", ")}`
|
|
@@ -2164,6 +2277,15 @@ function generateSettings(ast) {
|
|
|
2164
2277
|
// Blackboard implies a host-side observer that watches the workflow rung's
|
|
2165
2278
|
// Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
|
|
2166
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
|
+
}
|
|
2167
2289
|
const observeHook = buildHybridObserveHook(ast, topologyName);
|
|
2168
2290
|
if (observeHook) {
|
|
2169
2291
|
if (!hooksByEvent[observeHook.event])
|
|
@@ -2182,13 +2304,12 @@ function generateSettings(ast) {
|
|
|
2182
2304
|
// Permissions section from settings block — nested under "permissions" key
|
|
2183
2305
|
const perms = ast.settings;
|
|
2184
2306
|
if (perms) {
|
|
2185
|
-
const allow = perms.allow;
|
|
2186
|
-
const deny = perms.deny;
|
|
2187
2307
|
const permObj = {};
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
+
}
|
|
2192
2313
|
if (Object.keys(permObj).length > 0)
|
|
2193
2314
|
settings.permissions = permObj;
|
|
2194
2315
|
}
|
|
@@ -2242,9 +2363,12 @@ function generateMcpJson(ast) {
|
|
|
2242
2363
|
for (const [name, config] of Object.entries(ast.mcpServers)) {
|
|
2243
2364
|
const entry = {};
|
|
2244
2365
|
for (const [key, value] of Object.entries(config)) {
|
|
2245
|
-
if (key === "command" || key === "url") {
|
|
2366
|
+
if (key === "command" || key === "url" || key === "type") {
|
|
2246
2367
|
entry[key] = value;
|
|
2247
2368
|
}
|
|
2369
|
+
else if (key === "headers" && typeof value === "object" && value !== null) {
|
|
2370
|
+
entry.headers = value;
|
|
2371
|
+
}
|
|
2248
2372
|
else if (key === "args" && Array.isArray(value)) {
|
|
2249
2373
|
entry.args = value;
|
|
2250
2374
|
}
|
|
@@ -2256,8 +2380,11 @@ function generateMcpJson(ast) {
|
|
|
2256
2380
|
// Only include servers that have a command or url (filter parser artifacts)
|
|
2257
2381
|
if (!("command" in entry) && !("url" in entry))
|
|
2258
2382
|
continue;
|
|
2259
|
-
//
|
|
2260
|
-
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)) {
|
|
2261
2388
|
entry.env = {};
|
|
2262
2389
|
}
|
|
2263
2390
|
servers[name] = entry;
|
|
@@ -2338,6 +2465,7 @@ function generateToolScripts(ast) {
|
|
|
2338
2465
|
shebang,
|
|
2339
2466
|
`# Tool: ${tool.id} — ${tool.description}`,
|
|
2340
2467
|
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2468
|
+
tool.lang === "node" ? `// ${STUB_MARKER}` : STUB_MARKER,
|
|
2341
2469
|
"",
|
|
2342
2470
|
];
|
|
2343
2471
|
if (tool.lang === "bash" || !tool.lang) {
|
|
@@ -2358,14 +2486,50 @@ function generateToolScripts(ast) {
|
|
|
2358
2486
|
lines.push(`echo "TODO: implement ${tool.id}"`);
|
|
2359
2487
|
}
|
|
2360
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(/^.*\//, "");
|
|
2361
2497
|
files.push({
|
|
2362
|
-
path:
|
|
2498
|
+
path: project
|
|
2499
|
+
? script.replace(/^\.\//, "")
|
|
2500
|
+
: `.claude/skills/${name}/scripts/${base || `${tool.id}.${ext}`}`,
|
|
2363
2501
|
content: lines.join("\n"),
|
|
2364
2502
|
category: "script",
|
|
2503
|
+
...(project ? { createOnly: true } : {}),
|
|
2365
2504
|
});
|
|
2366
2505
|
}
|
|
2367
2506
|
return files;
|
|
2368
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
|
+
}
|
|
2369
2533
|
/** Generate schema documentation files. */
|
|
2370
2534
|
function generateSchemas(ast) {
|
|
2371
2535
|
const files = [];
|
|
@@ -2410,15 +2574,18 @@ function generateScheduleScripts(ast) {
|
|
|
2410
2574
|
`# Schedule: ${job.id} — invokes ${target}`,
|
|
2411
2575
|
"# Auto-generated by agentopology scaffold — edit as needed.",
|
|
2412
2576
|
'set -euo pipefail',
|
|
2577
|
+
"# cron starts in $HOME: run from the project root (this script is in scripts/).",
|
|
2578
|
+
'cd "$(dirname "$0")/.."',
|
|
2413
2579
|
"",
|
|
2414
2580
|
];
|
|
2415
2581
|
if (job.agent) {
|
|
2582
|
+
// Headless: cron has no terminal, so an interactive session never ran.
|
|
2416
2583
|
lines.push(`# Invoke agent: ${job.agent}`);
|
|
2417
|
-
lines.push(`claude --agent ${job.agent}`);
|
|
2584
|
+
lines.push(`claude -p "Run your scheduled task (${job.id})." --agent ${job.agent}`);
|
|
2418
2585
|
}
|
|
2419
2586
|
else if (job.action) {
|
|
2420
2587
|
lines.push(`# Invoke action: ${job.action}`);
|
|
2421
|
-
lines.push(`bash scripts/action-${job.action}.sh`);
|
|
2588
|
+
lines.push(`bash .claude/skills/${ast.topology.name}/scripts/action-${job.action}.sh`);
|
|
2422
2589
|
}
|
|
2423
2590
|
if (job.enabled === false) {
|
|
2424
2591
|
lines.unshift("# DISABLED — this schedule is not enabled");
|
|
@@ -2428,18 +2595,12 @@ function generateScheduleScripts(ast) {
|
|
|
2428
2595
|
// Add crontab entry
|
|
2429
2596
|
let cronExpr = job.cron ?? "# every: " + (job.every ?? "unknown");
|
|
2430
2597
|
if (!job.cron && job.every) {
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
"
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
"1 day": "0 0 * * *",
|
|
2438
|
-
"daily": "0 0 * * *",
|
|
2439
|
-
"1w": "0 0 * * 0",
|
|
2440
|
-
"weekly": "0 0 * * 0",
|
|
2441
|
-
};
|
|
2442
|
-
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}`;
|
|
2443
2604
|
}
|
|
2444
2605
|
const enabledPrefix = job.enabled === false ? "# DISABLED: " : "";
|
|
2445
2606
|
cronLines.push(`${enabledPrefix}${cronExpr} bash ${scriptPath} # ${job.id}`);
|
|
@@ -2607,7 +2768,11 @@ function generateWorkspaceProtocol(ast) {
|
|
|
2607
2768
|
* destroy their content.
|
|
2608
2769
|
*/
|
|
2609
2770
|
function generateContextFile(ast) {
|
|
2610
|
-
|
|
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`;
|
|
2611
2776
|
let fileName = ast.context.file ?? DEFAULT_PATH;
|
|
2612
2777
|
// Safety: never overwrite the user's root-level CLAUDE.md
|
|
2613
2778
|
if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {
|
|
@@ -2804,7 +2969,7 @@ function generateWorkflowLaunchArtifacts(ast) {
|
|
|
2804
2969
|
}
|
|
2805
2970
|
/**
|
|
2806
2971
|
* HYBRID: build the "Hybrid run order" markdown block appended to the generated
|
|
2807
|
-
* context file (.claude/
|
|
2972
|
+
* context file (.claude/rules/<topology>.md) and the topology SKILL.md. It states the
|
|
2808
2973
|
* host's interleaving with the embedded workflow rung, in the SAME run-order
|
|
2809
2974
|
* vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
|
|
2810
2975
|
* stay in sync. Returns null when the topology is not a hybrid.
|
|
@@ -3061,7 +3226,6 @@ export const claudeCodeBinding = {
|
|
|
3061
3226
|
// Agent files — may include group conversation protocol sections
|
|
3062
3227
|
files.push(...generateAgents(ast));
|
|
3063
3228
|
// Human nodes
|
|
3064
|
-
files.push(...generateHumanNodes(ast));
|
|
3065
3229
|
// Main topology skill (with frontmatter)
|
|
3066
3230
|
const skillFiles = generateTopologySkill(ast);
|
|
3067
3231
|
// Append trigger info to the main skill
|
|
@@ -3080,6 +3244,9 @@ export const claudeCodeBinding = {
|
|
|
3080
3244
|
// Gate scripts
|
|
3081
3245
|
files.push(...generateGateScripts(ast));
|
|
3082
3246
|
files.push(...generateGateWrapperScripts(ast));
|
|
3247
|
+
const beforeAgent = generateBeforeAgentScript(ast);
|
|
3248
|
+
if (beforeAgent)
|
|
3249
|
+
files.push(beforeAgent);
|
|
3083
3250
|
// Hook scripts (global)
|
|
3084
3251
|
files.push(...generateHookScripts(ast));
|
|
3085
3252
|
// Hook scripts (per-agent)
|