agentopology 0.2.0 → 0.2.1

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.
@@ -48,6 +48,8 @@ function toTitle(id) {
48
48
  * - item1
49
49
  * - item2
50
50
  */
51
+ /** Characters that require quoting in YAML scalar values. */
52
+ const YAML_SPECIAL = /[:#\[\]{}&*!|>'"%@`]/;
51
53
  function frontmatter(fields) {
52
54
  const lines = ["---"];
53
55
  for (const [key, value] of Object.entries(fields)) {
@@ -65,7 +67,10 @@ function frontmatter(fields) {
65
67
  lines.push(`${key}: ${value}`);
66
68
  }
67
69
  else {
68
- lines.push(`${key}: ${value}`);
70
+ // Auto-quote strings containing YAML-special characters
71
+ const str = String(value);
72
+ const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str;
73
+ lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
69
74
  }
70
75
  }
71
76
  lines.push("---");
@@ -93,10 +98,10 @@ function gitkeep(dirPath) {
93
98
  */
94
99
  function mapPermissionMode(perm) {
95
100
  // Claude Code native values — pass through
96
- const nativeValues = ["plan", "auto", "confirm", "bypassPermissions"];
101
+ const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
97
102
  if (nativeValues.includes(perm)) {
98
- // "auto" is the default, omit it
99
- if (perm === "auto")
103
+ // "default" is the default, omit it
104
+ if (perm === "default")
100
105
  return undefined;
101
106
  return perm;
102
107
  }
@@ -106,8 +111,10 @@ function mapPermissionMode(perm) {
106
111
  return "plan";
107
112
  case "autonomous":
108
113
  return undefined; // default, omit
114
+ case "auto":
115
+ return undefined; // default, omit
109
116
  case "interactive":
110
- return "default";
117
+ return "acceptEdits";
111
118
  case "unrestricted":
112
119
  return "bypassPermissions";
113
120
  default:
@@ -229,7 +236,7 @@ function generateAgents(ast) {
229
236
  if (agent.invocation !== "manual") {
230
237
  const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role;
231
238
  if (desc) {
232
- fm.description = `"${desc}"`;
239
+ fm.description = desc;
233
240
  }
234
241
  }
235
242
  if (agent.model)
@@ -237,9 +244,9 @@ function generateAgents(ast) {
237
244
  if (agent.maxTurns != null)
238
245
  fm.maxTurns = String(agent.maxTurns);
239
246
  if (agent.tools && agent.tools.length > 0)
240
- fm.tools = agent.tools.map(convertMcpToolName);
247
+ fm.tools = agent.tools.map(convertMcpToolName).join(", ");
241
248
  if (agent.disallowedTools && agent.disallowedTools.length > 0)
242
- fm.disallowedTools = agent.disallowedTools.map(convertMcpToolName);
249
+ fm.disallowedTools = agent.disallowedTools.map(convertMcpToolName).join(", ");
243
250
  if (agent.mcpServers && agent.mcpServers.length > 0)
244
251
  fm.mcpServers = agent.mcpServers;
245
252
  if (agent.background === true)
@@ -836,7 +843,7 @@ function generateGroupNodes(ast) {
836
843
  const skillFm = {};
837
844
  skillFm.name = group.id;
838
845
  if (group.description)
839
- skillFm.description = `"${group.description}"`;
846
+ skillFm.description = group.description;
840
847
  skillFm["user-invocable"] = true;
841
848
  const skillSections = [];
842
849
  skillSections.push(frontmatter(skillFm));
@@ -853,7 +860,7 @@ function generateGroupNodes(ast) {
853
860
  const agentFm = {};
854
861
  agentFm.name = group.id;
855
862
  if (group.description)
856
- agentFm.description = `"${group.description}"`;
863
+ agentFm.description = group.description;
857
864
  const agentSections = [];
858
865
  agentSections.push(frontmatter(agentFm));
859
866
  agentSections.push("");
@@ -879,9 +886,9 @@ function generateTopologySkill(ast) {
879
886
  const fm = {};
880
887
  fm.name = name;
881
888
  if (ast.topology.description) {
882
- fm.description = `"${ast.topology.description}"`;
889
+ fm.description = ast.topology.description;
883
890
  }
884
- fm.version = `"${ast.topology.version}"`;
891
+ fm.version = ast.topology.version;
885
892
  fm.topology = name;
886
893
  if (ast.topology.patterns.length > 0) {
887
894
  fm.patterns = ast.topology.patterns;
@@ -905,7 +912,7 @@ function generateTopologySkill(ast) {
905
912
  if (mainSkill.agent)
906
913
  fm.agent = mainSkill.agent;
907
914
  if (mainSkill.allowedTools && mainSkill.allowedTools.length > 0) {
908
- fm["allowed-tools"] = mainSkill.allowedTools.map(convertMcpToolName);
915
+ fm["allowed-tools"] = mainSkill.allowedTools.map(convertMcpToolName).join(", ");
909
916
  }
910
917
  }
911
918
  const sections = [];
@@ -1222,7 +1229,7 @@ function generateSkills(ast) {
1222
1229
  const sfm = {};
1223
1230
  sfm.name = skill.id;
1224
1231
  if (skill.description)
1225
- sfm.description = `"${skill.description}"`;
1232
+ sfm.description = skill.description;
1226
1233
  if (skill.disableModelInvocation != null) {
1227
1234
  sfm["disable-model-invocation"] = skill.disableModelInvocation;
1228
1235
  }
@@ -1234,7 +1241,7 @@ function generateSkills(ast) {
1234
1241
  if (skill.agent)
1235
1242
  sfm.agent = skill.agent;
1236
1243
  if (skill.allowedTools && skill.allowedTools.length > 0) {
1237
- sfm["allowed-tools"] = skill.allowedTools.map(convertMcpToolName);
1244
+ sfm["allowed-tools"] = skill.allowedTools.map(convertMcpToolName).join(", ");
1238
1245
  }
1239
1246
  // Only emit frontmatter if there are fields beyond just the name
1240
1247
  if (Object.keys(sfm).length > 1) {
@@ -1434,7 +1441,7 @@ function generateGateScripts(ast) {
1434
1441
  }
1435
1442
  return files;
1436
1443
  }
1437
- /** Generate gate wrapper scripts that filter Task calls to the correct agent. */
1444
+ /** Generate gate wrapper scripts that run validation when agents complete. */
1438
1445
  function generateGateWrapperScripts(ast) {
1439
1446
  const files = [];
1440
1447
  const name = ast.topology.name;
@@ -1447,11 +1454,13 @@ function generateGateWrapperScripts(ast) {
1447
1454
  if (gate.behavior === "advisory")
1448
1455
  continue;
1449
1456
  const gateScriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1450
- const onFailExit = gate.onFail === "halt" ? 'exit 1' : 'echo "Gate failed — bounce-back requested (advisory)"';
1457
+ const onFailExit = gate.onFail === "halt"
1458
+ ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1459
+ : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1451
1460
  const contentLines = [
1452
1461
  "#!/usr/bin/env bash",
1453
1462
  `# Gate wrapper: ${gate.id}`,
1454
- `# Enforces gate "${gate.id}" on Task tool calls.`,
1463
+ `# Enforces gate "${gate.id}" on SubagentStop (after: ${gate.after || "any"}).`,
1455
1464
  `# Auto-generated by agentopology scaffold — edit as needed.`,
1456
1465
  "set -euo pipefail",
1457
1466
  "",
@@ -1601,14 +1610,17 @@ function generateSettings(ast) {
1601
1610
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1602
1611
  // This is the Claude Code native approach: hooks in the agent file are scoped
1603
1612
  // to that agent automatically.
1604
- // Gate enforcement — compile gates with run: to PreToolUse hooks
1613
+ // Gate enforcement — compile gates with run: to SubagentStop hooks
1614
+ // Gates fire AFTER an agent finishes (SubagentStop), not before a tool runs.
1615
+ // The matcher is the gate's `after:` agent name so the hook fires when that
1616
+ // specific agent completes.
1605
1617
  const gates = ast.nodes.filter((n) => n.type === "gate");
1606
1618
  for (const gate of gates) {
1607
1619
  if (!gate.run)
1608
1620
  continue;
1609
1621
  if (gate.behavior === "advisory")
1610
1622
  continue; // advisory gates stay in markdown only
1611
- const eventName = "PreToolUse";
1623
+ const eventName = "SubagentStop";
1612
1624
  if (!hooksByEvent[eventName])
1613
1625
  hooksByEvent[eventName] = [];
1614
1626
  const scriptName = `gate-${gate.id}.sh`;
@@ -1620,8 +1632,12 @@ function generateSettings(ast) {
1620
1632
  command,
1621
1633
  },
1622
1634
  ],
1623
- matcher: "Task", // Gates fire on Task tool (agent spawning)
1624
1635
  };
1636
+ // Use the gate's after: agent as matcher so the hook only fires
1637
+ // when that specific agent completes. Without after:, match all agents.
1638
+ if (gate.after) {
1639
+ hookEntry.matcher = gate.after;
1640
+ }
1625
1641
  hooksByEvent[eventName].push(hookEntry);
1626
1642
  }
1627
1643
  // Write hooksByEvent to settings if any hooks or gates were registered
@@ -1633,66 +1649,21 @@ function generateSettings(ast) {
1633
1649
  if (envSettings) {
1634
1650
  settings.env = envSettings;
1635
1651
  }
1636
- // Permissions section from settings block
1652
+ // Permissions section from settings block — nested under "permissions" key
1637
1653
  const perms = ast.settings;
1638
1654
  if (perms) {
1639
1655
  const allow = perms.allow;
1640
1656
  const deny = perms.deny;
1641
- const ask = perms.ask;
1657
+ const permObj = {};
1642
1658
  if (allow && allow.length > 0)
1643
- settings.allow = allow;
1659
+ permObj.allow = allow;
1644
1660
  if (deny && deny.length > 0)
1645
- settings.deny = deny;
1646
- if (ask && ask.length > 0)
1647
- settings.ask = ask;
1648
- }
1649
- // Defaults documentation
1650
- if (ast.defaults) {
1651
- const d = ast.defaults;
1652
- const defaultsDoc = {};
1653
- if (d.temperature != null)
1654
- defaultsDoc.temperature = d.temperature;
1655
- if (d.maxTokens != null)
1656
- defaultsDoc.maxTokens = d.maxTokens;
1657
- if (d.topP != null)
1658
- defaultsDoc.topP = d.topP;
1659
- if (d.topK != null)
1660
- defaultsDoc.topK = d.topK;
1661
- if (d.stop)
1662
- defaultsDoc.stop = d.stop;
1663
- if (d.seed != null)
1664
- defaultsDoc.seed = d.seed;
1665
- if (d.thinking)
1666
- defaultsDoc.thinking = d.thinking;
1667
- if (d.thinkingBudget != null)
1668
- defaultsDoc.thinkingBudget = d.thinkingBudget;
1669
- if (d.outputFormat)
1670
- defaultsDoc.outputFormat = d.outputFormat;
1671
- if (d.timeout)
1672
- defaultsDoc.timeout = d.timeout;
1673
- if (d.logLevel)
1674
- defaultsDoc.logLevel = d.logLevel;
1675
- if (Object.keys(defaultsDoc).length > 0) {
1676
- settings._defaults = defaultsDoc;
1677
- }
1678
- }
1679
- // Observability documentation
1680
- if (ast.observability) {
1681
- const o = ast.observability;
1682
- const obsDoc = {
1683
- enabled: o.enabled,
1684
- level: o.level,
1685
- exporter: o.exporter,
1686
- sampleRate: o.sampleRate,
1687
- };
1688
- if (o.endpoint)
1689
- obsDoc.endpoint = o.endpoint;
1690
- if (o.service)
1691
- obsDoc.service = o.service;
1692
- obsDoc.capture = o.capture;
1693
- obsDoc.spans = o.spans;
1694
- settings._observability = obsDoc;
1661
+ permObj.deny = deny;
1662
+ if (Object.keys(permObj).length > 0)
1663
+ settings.permissions = permObj;
1695
1664
  }
1665
+ // Defaults and observability are documented in the CLAUDE.md context file,
1666
+ // not in settings.json (Claude Code doesn't recognize these keys).
1696
1667
  // Top-level extensions for claude-code
1697
1668
  if (ast.extensions?.["claude-code"]) {
1698
1669
  for (const [k, v] of Object.entries(ast.extensions["claude-code"])) {
@@ -1955,21 +1926,23 @@ function generateCommandFiles(ast) {
1955
1926
  // Frontmatter
1956
1927
  const fm = {};
1957
1928
  if (ast.topology.description) {
1958
- fm.description = `"${ast.topology.description}"`;
1929
+ fm.description = ast.topology.description;
1930
+ }
1931
+ if (trigger.argument) {
1932
+ fm["argument-hint"] = `[${trigger.argument}]`;
1959
1933
  }
1960
1934
  sections.push(frontmatter(fm));
1961
1935
  sections.push("");
1962
- // Header
1936
+ // Header — use $ARGUMENTS for Claude Code command argument substitution
1963
1937
  sections.push(`# /${trigger.name}`);
1964
1938
  sections.push("");
1965
- sections.push(trigger.pattern);
1966
- sections.push("");
1967
- // Arguments
1968
1939
  if (trigger.argument) {
1969
- sections.push("## Arguments");
1970
- sections.push(`- ${trigger.argument}: extracted from the command pattern`);
1971
- sections.push("");
1940
+ sections.push(`/${trigger.name} $ARGUMENTS`);
1972
1941
  }
1942
+ else {
1943
+ sections.push(trigger.pattern);
1944
+ }
1945
+ sections.push("");
1973
1946
  // Pipeline (from edges, enhanced)
1974
1947
  if (ast.edges.length > 0) {
1975
1948
  sections.push("## Pipeline");
@@ -2076,7 +2049,7 @@ function generateWorkspaceProtocol(ast) {
2076
2049
  * destroy their content.
2077
2050
  */
2078
2051
  function generateContextFile(ast) {
2079
- const DEFAULT_PATH = ".claude/CONTEXT.md";
2052
+ const DEFAULT_PATH = ".claude/CLAUDE.md";
2080
2053
  let fileName = ast.context.file ?? DEFAULT_PATH;
2081
2054
  // Safety: never overwrite the user's root-level CLAUDE.md
2082
2055
  if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {