agentopology 0.5.2 → 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.
- package/README.md +5 -5
- 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 +154 -0
- package/dist/__tests__/e2e/scaffold-owns.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 +136 -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 +37 -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__/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__/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 +52 -1
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +306 -68
- 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/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 +118 -78
- 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 +36 -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 +76 -22
- 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 +24 -0
- package/dist/parser/run.d.ts.map +1 -0
- package/dist/parser/run.js +55 -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 +218 -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/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 +109 -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/package.json +1 -1
- package/spec/grammar.md +20 -4
- package/spec/testing.md +81 -0
- 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;
|
|
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"}
|
package/dist/parser/validator.js
CHANGED
|
@@ -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
|
-
|
|
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),
|