agentopology 0.5.1 → 0.6.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.
Files changed (121) hide show
  1. package/README.md +5 -5
  2. package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
  3. package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/roundtrip.test.js +178 -0
  5. package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
  6. package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
  7. package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
  8. package/dist/__tests__/e2e/scaffold-owns.test.js +154 -0
  9. package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
  10. package/dist/__tests__/global-setup.d.ts +7 -0
  11. package/dist/__tests__/global-setup.d.ts.map +1 -0
  12. package/dist/__tests__/global-setup.js +16 -0
  13. package/dist/__tests__/global-setup.js.map +1 -0
  14. package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
  15. package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
  16. package/dist/__tests__/live/claude-code.live.test.js +136 -0
  17. package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
  18. package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
  19. package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
  20. package/dist/__tests__/live/zz-debug.test.js +4 -0
  21. package/dist/__tests__/live/zz-debug.test.js.map +1 -0
  22. package/dist/__tests__/open-findings.test.d.ts +10 -0
  23. package/dist/__tests__/open-findings.test.d.ts.map +1 -0
  24. package/dist/__tests__/open-findings.test.js +37 -0
  25. package/dist/__tests__/open-findings.test.js.map +1 -0
  26. package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
  27. package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
  28. package/dist/__tests__/spec-testing-sync.test.js +57 -0
  29. package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
  30. package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
  31. package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
  32. package/dist/bindings/__tests__/hook-command.test.js +88 -0
  33. package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
  34. package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
  35. package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
  36. package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
  37. package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
  38. package/dist/bindings/claude-code.d.ts +52 -1
  39. package/dist/bindings/claude-code.d.ts.map +1 -1
  40. package/dist/bindings/claude-code.js +306 -68
  41. package/dist/bindings/claude-code.js.map +1 -1
  42. package/dist/bindings/index.d.ts +7 -0
  43. package/dist/bindings/index.d.ts.map +1 -1
  44. package/dist/bindings/index.js +26 -7
  45. package/dist/bindings/index.js.map +1 -1
  46. package/dist/bindings/types.d.ts +6 -0
  47. package/dist/bindings/types.d.ts.map +1 -1
  48. package/dist/bindings/types.js.map +1 -1
  49. package/dist/cli/__tests__/cli.test.d.ts +1 -1
  50. package/dist/cli/__tests__/cli.test.js +1 -1
  51. package/dist/cli/index.js +118 -78
  52. package/dist/cli/index.js.map +1 -1
  53. package/dist/docs/content.d.ts.map +1 -1
  54. package/dist/docs/content.js +39 -3
  55. package/dist/docs/content.js.map +1 -1
  56. package/dist/docs/teach.d.ts.map +1 -1
  57. package/dist/docs/teach.js +36 -0
  58. package/dist/docs/teach.js.map +1 -1
  59. package/dist/import/__tests__/import.test.js +21 -9
  60. package/dist/import/__tests__/import.test.js.map +1 -1
  61. package/dist/import/claude-code.d.ts.map +1 -1
  62. package/dist/import/claude-code.js +128 -44
  63. package/dist/import/claude-code.js.map +1 -1
  64. package/dist/import/serializer.d.ts.map +1 -1
  65. package/dist/import/serializer.js +10 -0
  66. package/dist/import/serializer.js.map +1 -1
  67. package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
  68. package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
  69. package/dist/parser/__tests__/mod-hooks.test.js +136 -0
  70. package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
  71. package/dist/parser/ast.d.ts +29 -2
  72. package/dist/parser/ast.d.ts.map +1 -1
  73. package/dist/parser/ast.js.map +1 -1
  74. package/dist/parser/index.d.ts +6 -1
  75. package/dist/parser/index.d.ts.map +1 -1
  76. package/dist/parser/index.js +76 -22
  77. package/dist/parser/index.js.map +1 -1
  78. package/dist/parser/paths.d.ts +15 -0
  79. package/dist/parser/paths.d.ts.map +1 -0
  80. package/dist/parser/paths.js +34 -0
  81. package/dist/parser/paths.js.map +1 -0
  82. package/dist/parser/run.d.ts +24 -0
  83. package/dist/parser/run.d.ts.map +1 -0
  84. package/dist/parser/run.js +55 -0
  85. package/dist/parser/run.js.map +1 -0
  86. package/dist/parser/validator.d.ts +12 -0
  87. package/dist/parser/validator.d.ts.map +1 -1
  88. package/dist/parser/validator.js +218 -2
  89. package/dist/parser/validator.js.map +1 -1
  90. package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
  91. package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
  92. package/dist/scaffold/__tests__/safety.test.js +184 -0
  93. package/dist/scaffold/__tests__/safety.test.js.map +1 -0
  94. package/dist/scaffold/__tests__/scaffold.test.js +2 -1
  95. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  96. package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
  97. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
  98. package/dist/scaffold/incremental.d.ts +49 -13
  99. package/dist/scaffold/incremental.d.ts.map +1 -1
  100. package/dist/scaffold/incremental.js +235 -95
  101. package/dist/scaffold/incremental.js.map +1 -1
  102. package/dist/scaffold/manifest.d.ts +8 -1
  103. package/dist/scaffold/manifest.d.ts.map +1 -1
  104. package/dist/scaffold/manifest.js +32 -8
  105. package/dist/scaffold/manifest.js.map +1 -1
  106. package/dist/scaffold/merge.d.ts +15 -4
  107. package/dist/scaffold/merge.d.ts.map +1 -1
  108. package/dist/scaffold/merge.js +109 -18
  109. package/dist/scaffold/merge.js.map +1 -1
  110. package/dist/scaffold/types.d.ts +17 -0
  111. package/dist/scaffold/types.d.ts.map +1 -1
  112. package/dist/visualizer/__tests__/gate-connectors.test.d.ts +2 -0
  113. package/dist/visualizer/__tests__/gate-connectors.test.d.ts.map +1 -0
  114. package/dist/visualizer/__tests__/gate-connectors.test.js +88 -0
  115. package/dist/visualizer/__tests__/gate-connectors.test.js.map +1 -0
  116. package/dist/visualizer/index.js +41 -4
  117. package/dist/visualizer/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/spec/grammar.md +20 -4
  120. package/spec/testing.md +81 -0
  121. package/spec/validation.md +6 -2
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What a `run:` value is allowed to be: a command, not a sentence.
3
+ *
4
+ * A gate or hook `run` names something to execute. Bindings derive a script
5
+ * file name from it, so prose there became files named `E;`, `fail:` and
6
+ * `outputs.py)` — and a " / " in a sentence produced an empty name that
7
+ * crashed scaffold with EISDIR. Both the validator (V96) and every binding go
8
+ * through these two functions so they cannot disagree.
9
+ *
10
+ * @module
11
+ */
12
+ /**
13
+ * The script file name a binding writes for `run`, or null when `run` does not
14
+ * name one. Keeps the historical derivation (basename of the last path, first
15
+ * word) so every valid topology compiles exactly as before.
16
+ */
17
+ export declare function scriptNameFromRun(run: string | undefined): string | null;
18
+ /**
19
+ * Why `run` reads as prose rather than a command, or null when it looks like a
20
+ * command. Deliberately conservative: it flags only what a shell would reject
21
+ * or what no command contains, so real commands are never refused.
22
+ */
23
+ export declare function proseInRun(run: string): string | null;
24
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/parser/run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAIxE;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgBrD"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What a `run:` value is allowed to be: a command, not a sentence.
3
+ *
4
+ * A gate or hook `run` names something to execute. Bindings derive a script
5
+ * file name from it, so prose there became files named `E;`, `fail:` and
6
+ * `outputs.py)` — and a " / " in a sentence produced an empty name that
7
+ * crashed scaffold with EISDIR. Both the validator (V96) and every binding go
8
+ * through these two functions so they cannot disagree.
9
+ *
10
+ * @module
11
+ */
12
+ /** One path-ish token: optional ./ ../ ~/ or / prefix, then safe characters (dotfolders such as .claude/ included). */
13
+ const EXECUTABLE = /^(?:\.{1,2}\/|~\/|\/)?[A-Za-z0-9_.][A-Za-z0-9_.\/@+-]*$/;
14
+ /** A file name a binding may write under its scripts/ directory. */
15
+ const SCRIPT_FILE = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
16
+ /**
17
+ * The script file name a binding writes for `run`, or null when `run` does not
18
+ * name one. Keeps the historical derivation (basename of the last path, first
19
+ * word) so every valid topology compiles exactly as before.
20
+ */
21
+ export function scriptNameFromRun(run) {
22
+ if (!run)
23
+ return null;
24
+ const name = run.trim().replace(/^.*\//, "").replace(/\s.*$/, "");
25
+ return SCRIPT_FILE.test(name) ? name : null;
26
+ }
27
+ /**
28
+ * Why `run` reads as prose rather than a command, or null when it looks like a
29
+ * command. Deliberately conservative: it flags only what a shell would reject
30
+ * or what no command contains, so real commands are never refused.
31
+ */
32
+ export function proseInRun(run) {
33
+ const text = run.trim();
34
+ if (text === "")
35
+ return "it is empty";
36
+ if (/\r|\n/.test(text))
37
+ return "it spans several lines";
38
+ const first = text.split(/\s+/)[0];
39
+ if (/[:;,]$/.test(first))
40
+ return `its first word "${first}" ends in punctuation`;
41
+ if (!EXECUTABLE.test(first))
42
+ return `its first word "${first}" is not a command or path`;
43
+ // Judge only what the shell would see unquoted.
44
+ const bare = text.replace(/'[^']*'|"[^"]*"/g, "''");
45
+ if (/[^\x00-\x7F]/.test(bare))
46
+ return "it contains non-ASCII text outside quotes";
47
+ if (/\s->\s|\s=>\s|\s<=\s|\s>=\s/.test(bare))
48
+ return "it contains an arrow or comparison (->, >=, <=)";
49
+ if (/(^|[^$])\(/.test(bare))
50
+ return "it contains an unquoted parenthesis";
51
+ if (scriptNameFromRun(text) === null)
52
+ return "no script file name can be derived from it";
53
+ return null;
54
+ }
55
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/parser/run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,uHAAuH;AACvH,MAAM,UAAU,GAAG,yDAAyD,CAAC;AAE7E,oEAAoE;AACpE,MAAM,WAAW,GAAG,+BAA+B,CAAC;AAEpD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAuB;IACvD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAClE,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AAC9C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACxB,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,aAAa,CAAC;IACtC,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,wBAAwB,CAAC;IAExD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACnC,IAAI,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,mBAAmB,KAAK,uBAAuB,CAAC;IACjF,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,mBAAmB,KAAK,4BAA4B,CAAC;IAEzF,gDAAgD;IAChD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,kBAAkB,EAAE,IAAI,CAAC,CAAC;IACpD,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,2CAA2C,CAAC;IAClF,IAAI,6BAA6B,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,iDAAiD,CAAC;IACvG,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,qCAAqC,CAAC;IAC1E,IAAI,iBAAiB,CAAC,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,4CAA4C,CAAC;IAC1F,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -44,5 +44,17 @@ export declare function findBackEdges(edges: EdgeDef[], nodeIds: Set<string>): E
44
44
  * @param ast - The parsed topology AST.
45
45
  * @returns An array of validation results. An empty array means no issues found.
46
46
  */
47
+ /**
48
+ * Engine events a `type: mod` hook may subscribe to (Claude Code function
49
+ * hooks, early access, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
50
+ *
51
+ * PROVEN: a Mod was loaded and fired on these (Claude Code 2.1.283).
52
+ * SEEN: the name is in the 2.1.283 binary but no Mod has been observed on it,
53
+ * so V95 accepts it with a warning rather than an error.
54
+ */
55
+ export declare const MOD_EVENTS_PROVEN: readonly ["tool.call", "prompt.submit"];
56
+ export declare const MOD_EVENTS_SEEN: readonly ["prompt.context", "session.start", "session.end", "model.complete", "message.received"];
57
+ /** Hook types the grammar defines (`spec/grammar.md` §4.16). */
58
+ export declare const HOOK_TYPES: readonly ["command", "prompt", "mod"];
47
59
  export declare function validate(ast: TopologyAST): ValidationResult[];
48
60
  //# sourceMappingURL=validator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/parser/validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EACV,WAAW,EAKX,OAAO,EAkBR,MAAM,UAAU,CAAC;AAMlB,mCAAmC;AACnC,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,sBAAsB;IACtB,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAyMD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAyD/E;AAo0FD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB,EAAE,CAgG7D"}
1
+ {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/parser/validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,OAAO,KAAK,EAEV,WAAW,EAKX,OAAO,EAkBR,MAAM,UAAU,CAAC;AAMlB,mCAAmC;AACnC,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,sBAAsB;IACtB,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAyMD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAyD/E;AA44FD;;;;;GAKG;AACH;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,yCAA0C,CAAC;AACzE,eAAO,MAAM,eAAe,mGAElB,CAAC;AAEX,gEAAgE;AAChE,eAAO,MAAM,UAAU,uCAAwC,CAAC;AAyHhE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB,EAAE,CAoG7D"}
@@ -20,6 +20,8 @@
20
20
  */
21
21
  import { MISSING } from "./index.js";
22
22
  import { gateAnchors } from "./ast.js";
23
+ import { proseInRun } from "./run.js";
24
+ import { unsafePathReason } from "./paths.js";
23
25
  // ---------------------------------------------------------------------------
24
26
  // Reserved keywords (from spec section 7)
25
27
  // ---------------------------------------------------------------------------
@@ -489,7 +491,7 @@ function v10PromptsExist(ast) {
489
491
  }
490
492
  return results;
491
493
  }
492
- /** V11: If agent A writes X and agent B reads X, a path A -> B must exist in flow. */
494
+ /** V11: If agent A writes X and agent B reads X, a path A -> B must exist in flow (unless either runs on demand). */
493
495
  function v11ReadWriteConsistency(ast) {
494
496
  const results = [];
495
497
  // Build a reachability map using BFS from each node
@@ -554,7 +556,16 @@ function v11ReadWriteConsistency(ast) {
554
556
  const keyWriters = writers.get(key);
555
557
  if (!keyWriters)
556
558
  continue; // no writer = could be external, skip
557
- const hasPath = keyWriters.some((writerId) => isReachable(writerId, node.id) ||
559
+ // An on-demand agent (invocation: manual) sits outside the flow by
560
+ // definition (V4), so no flow path can exist to or from it — the order
561
+ // is the user's. Demanding one made every hand-built project, whose
562
+ // subagents are all on demand, fail on import.
563
+ const manual = (id) => {
564
+ const n = ast.nodes.find((x) => x.id === id);
565
+ return !!n && isAgent(n) && n.invocation === "manual";
566
+ };
567
+ const hasPath = manual(node.id) || keyWriters.some((writerId) => manual(writerId) ||
568
+ isReachable(writerId, node.id) ||
558
569
  (coGroupMembers.get(node.id)?.has(writerId) ?? false));
559
570
  if (!hasPath) {
560
571
  results.push({
@@ -984,6 +995,73 @@ function v91RequiredFieldPlaceholder(ast) {
984
995
  * `handoff-overlap-empty` pre-flagged ambiguity; having it here makes it work
985
996
  * under `scaffold` too, and for anyone who never runs `plan`.
986
997
  */
998
+ /**
999
+ * V96: a `run:` value is a command, not a sentence.
1000
+ *
1001
+ * Gates and hooks compile to scripts named after their `run`. Prose there
1002
+ * passed validation, then scaffold wrote junk files named `E;` and `fail:`
1003
+ * and crashed with EISDIR on a " / " — after it had already written other
1004
+ * files. The explanation belongs in `description`; `run` keeps the command.
1005
+ */
1006
+ function v96RunIsCommand(ast) {
1007
+ const results = [];
1008
+ const check = (owner, run, nodeId) => {
1009
+ if (run === undefined)
1010
+ return;
1011
+ const why = proseInRun(run);
1012
+ if (!why)
1013
+ return;
1014
+ results.push({
1015
+ rule: "V96",
1016
+ level: "error",
1017
+ message: `${owner} "run" is not a command — ${why}. Keep only the command in ` +
1018
+ `"run" (e.g. run: "scripts/check.sh") and move the explanation into "description"`,
1019
+ ...(nodeId ? { node: nodeId, line: lookupLine(ast, nodeId) } : {}),
1020
+ });
1021
+ };
1022
+ for (const node of ast.nodes) {
1023
+ if (isGate(node))
1024
+ check(`Gate "${node.id}"`, node.run, node.id);
1025
+ if (node.type === "agent") {
1026
+ for (const hook of node.hooks ?? []) {
1027
+ check(`Hook "${hook.name}" on agent "${node.id}"`, hook.run, node.id);
1028
+ }
1029
+ }
1030
+ }
1031
+ for (const hook of ast.hooks ?? [])
1032
+ check(`Hook "${hook.name}"`, hook.run);
1033
+ return results;
1034
+ }
1035
+ /**
1036
+ * V97: a `file:` is a safe project-relative path.
1037
+ *
1038
+ * `file:` marks a node's internals as hand-written at that path; scaffold
1039
+ * never writes it. A path outside the project ('..', absolute) or with an
1040
+ * unsafe name could not be honoured, so it is refused here, not at scaffold.
1041
+ */
1042
+ function v97FileIsProjectPath(ast) {
1043
+ const results = [];
1044
+ const check = (owner, file, nodeId) => {
1045
+ if (file === undefined)
1046
+ return;
1047
+ const why = unsafePathReason(file.replace(/^\.\//, ""));
1048
+ if (!why)
1049
+ return;
1050
+ results.push({
1051
+ rule: "V97",
1052
+ level: "error",
1053
+ message: `${owner} "file" is not a project-relative path — ${why}. Name the file from the project root, e.g. file: ".claude/agents/reviewer.md"`,
1054
+ ...(nodeId ? { node: nodeId, line: lookupLine(ast, nodeId) } : {}),
1055
+ });
1056
+ };
1057
+ for (const node of ast.nodes)
1058
+ check(`Node "${node.id}"`, node.file, node.id);
1059
+ for (const skill of ast.skills ?? [])
1060
+ check(`Skill "${skill.id}"`, skill.file);
1061
+ for (const trigger of ast.triggers ?? [])
1062
+ check(`Command "${trigger.name}"`, trigger.file);
1063
+ return results;
1064
+ }
987
1065
  function v93EdgeHandoffDeclared(ast) {
988
1066
  const results = [];
989
1067
  const byId = new Map(ast.nodes.map((n) => [n.id, n]));
@@ -2939,6 +3017,140 @@ function v88CustodyRefsValid(ast) {
2939
3017
  * @param ast - The parsed topology AST.
2940
3018
  * @returns An array of validation results. An empty array means no issues found.
2941
3019
  */
3020
+ /**
3021
+ * Engine events a `type: mod` hook may subscribe to (Claude Code function
3022
+ * hooks, early access, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
3023
+ *
3024
+ * PROVEN: a Mod was loaded and fired on these (Claude Code 2.1.283).
3025
+ * SEEN: the name is in the 2.1.283 binary but no Mod has been observed on it,
3026
+ * so V95 accepts it with a warning rather than an error.
3027
+ */
3028
+ export const MOD_EVENTS_PROVEN = ["tool.call", "prompt.submit"];
3029
+ export const MOD_EVENTS_SEEN = [
3030
+ "prompt.context", "session.start", "session.end", "model.complete", "message.received",
3031
+ ];
3032
+ /** Hook types the grammar defines (`spec/grammar.md` §4.16). */
3033
+ export const HOOK_TYPES = ["command", "prompt", "mod"];
3034
+ /** Every hook in the topology — global first, then per-agent — with its owner. */
3035
+ function allHooks(ast) {
3036
+ const out = ast.hooks.map((hook) => ({ hook, where: hook.name }));
3037
+ for (const n of ast.nodes) {
3038
+ if (n.type !== "agent" || !n.hooks)
3039
+ continue;
3040
+ for (const hook of n.hooks)
3041
+ out.push({ hook, where: `${n.id}.${hook.name}`, agent: n.id });
3042
+ }
3043
+ return out;
3044
+ }
3045
+ /**
3046
+ * V94: a hook's `type` must be one the grammar defines.
3047
+ *
3048
+ * `type: mod` passed silently before the grammar knew it, and the claude-code
3049
+ * binding wrote it into settings.json as a bash command. A misspelt type
3050
+ * (`type: comand`) did the same.
3051
+ */
3052
+ function v94HookTypeKnown(ast) {
3053
+ const results = [];
3054
+ for (const { hook, where, agent } of allHooks(ast)) {
3055
+ const type = hook.type ?? "command";
3056
+ if (!HOOK_TYPES.includes(type)) {
3057
+ results.push({
3058
+ rule: "V94",
3059
+ level: "error",
3060
+ message: `Hook "${where}" has type "${type}" — a hook is one of: ${HOOK_TYPES.join(", ")}. ` +
3061
+ `Use "mod" for a TypeScript module that runs inside the engine.`,
3062
+ node: where,
3063
+ });
3064
+ continue;
3065
+ }
3066
+ if (type === "mod" && agent) {
3067
+ results.push({
3068
+ rule: "V94",
3069
+ level: "error",
3070
+ message: `Hook "${where}" is a mod inside an agent — a Mod runs in the engine for the whole session; declare it in the topology's hooks block.`,
3071
+ node: where,
3072
+ });
3073
+ }
3074
+ if (hook.flag !== undefined && type !== "mod") {
3075
+ results.push({
3076
+ rule: "V94",
3077
+ level: "error",
3078
+ message: `Hook "${where}" sets flag: but is type "${type}" — flag: is the env switch of a type: mod hook.`,
3079
+ node: where,
3080
+ });
3081
+ }
3082
+ else if (hook.flag !== undefined && !/^[A-Z_][A-Z0-9_]*$/.test(hook.flag)) {
3083
+ results.push({
3084
+ rule: "V94",
3085
+ level: "error",
3086
+ message: `Hook "${where}" has flag "${hook.flag}" — a flag is an env variable name (e.g. MY_MOD_ON).`,
3087
+ node: where,
3088
+ });
3089
+ }
3090
+ }
3091
+ return results;
3092
+ }
3093
+ /**
3094
+ * V95: a hook's `on:` must be an event its type can subscribe to.
3095
+ *
3096
+ * A mod subscribes to a dotted engine event (`tool.call`); a command or prompt
3097
+ * hook to a PascalCase lifecycle event (`PreToolUse`, spec §4.16: "any
3098
+ * identifier in PascalCase"). Mixing them is a hook that never fires. A value
3099
+ * with whitespace is left to V90 (a swallowed field), not reported twice.
3100
+ */
3101
+ function v95HookEventKnown(ast) {
3102
+ const results = [];
3103
+ const proven = MOD_EVENTS_PROVEN;
3104
+ const seen = MOD_EVENTS_SEEN;
3105
+ for (const { hook, where } of allHooks(ast)) {
3106
+ const type = hook.type ?? "command";
3107
+ const on = hook.on.trim();
3108
+ if (/\s/.test(on))
3109
+ continue; // V90
3110
+ if (!on) {
3111
+ results.push({ rule: "V95", level: "error", message: `Hook "${where}" has no on: — name the event it fires on.`, node: where });
3112
+ continue;
3113
+ }
3114
+ if (type === "mod") {
3115
+ if (!hook.run) {
3116
+ results.push({ rule: "V95", level: "error", message: `Mod "${where}" has no run: — name its module (e.g. run: "hooks/redact.ts").`, node: where });
3117
+ }
3118
+ if (proven.includes(on))
3119
+ continue;
3120
+ if (seen.includes(on)) {
3121
+ results.push({
3122
+ rule: "V95",
3123
+ level: "warning",
3124
+ message: `Mod "${where}" subscribes to "${on}" — the engine names this event, but no Mod has been observed firing on it. Proven: ${proven.join(", ")}.`,
3125
+ node: where,
3126
+ });
3127
+ continue;
3128
+ }
3129
+ const hint = /^[A-Z]/.test(on)
3130
+ ? ` "${on}" is a command-hook event; a mod subscribes to an engine event.`
3131
+ : "";
3132
+ results.push({
3133
+ rule: "V95",
3134
+ level: "error",
3135
+ message: `Mod "${where}" subscribes to unknown engine event "${on}".${hint} Engine events: ${[...proven, ...seen].join(", ")}.`,
3136
+ node: where,
3137
+ });
3138
+ continue;
3139
+ }
3140
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(on)) {
3141
+ const hint = proven.includes(on) || seen.includes(on)
3142
+ ? ` "${on}" is an engine event — set type: mod to subscribe a module to it.`
3143
+ : "";
3144
+ results.push({
3145
+ rule: "V95",
3146
+ level: "error",
3147
+ message: `Hook "${where}" (type ${type}) fires on "${on}" — a ${type} hook takes a PascalCase event (e.g. PreToolUse, AgentStop).${hint}`,
3148
+ node: where,
3149
+ });
3150
+ }
3151
+ }
3152
+ return results;
3153
+ }
2942
3154
  export function validate(ast) {
2943
3155
  return [
2944
3156
  ...v1UniqueNames(ast),
@@ -2970,6 +3182,10 @@ export function validate(ast) {
2970
3182
  ...v91RequiredFieldPlaceholder(ast),
2971
3183
  ...v92EdgeTwoBrackets(ast),
2972
3184
  ...v93EdgeHandoffDeclared(ast),
3185
+ ...v94HookTypeKnown(ast),
3186
+ ...v95HookEventKnown(ast),
3187
+ ...v96RunIsCommand(ast),
3188
+ ...v97FileIsProjectPath(ast),
2973
3189
  ...v25BounceBackAdvisory(ast),
2974
3190
  ...v26ActionKindEnum(ast),
2975
3191
  ...v27AgentPermissionsEnum(ast),