agentopology 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/__tests__/e2e/compile.test.d.ts +12 -0
- package/dist/__tests__/e2e/compile.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/compile.test.js +219 -0
- package/dist/__tests__/e2e/compile.test.js.map +1 -0
- package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
- package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/roundtrip.test.js +178 -0
- package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js +163 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
- package/dist/__tests__/e2e/sync-cycle.test.d.ts +10 -0
- package/dist/__tests__/e2e/sync-cycle.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/sync-cycle.test.js +49 -0
- package/dist/__tests__/e2e/sync-cycle.test.js.map +1 -0
- package/dist/__tests__/global-setup.d.ts +7 -0
- package/dist/__tests__/global-setup.d.ts.map +1 -0
- package/dist/__tests__/global-setup.js +16 -0
- package/dist/__tests__/global-setup.js.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.js +198 -0
- package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
- package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
- package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
- package/dist/__tests__/live/zz-debug.test.js +4 -0
- package/dist/__tests__/live/zz-debug.test.js.map +1 -0
- package/dist/__tests__/open-findings.test.d.ts +10 -0
- package/dist/__tests__/open-findings.test.d.ts.map +1 -0
- package/dist/__tests__/open-findings.test.js +15 -0
- package/dist/__tests__/open-findings.test.js.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.js +57 -0
- package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
- package/dist/bindings/__tests__/bindings.test.js +19 -19
- package/dist/bindings/__tests__/bindings.test.js.map +1 -1
- package/dist/bindings/__tests__/claude-code-hybrid.test.js +5 -5
- package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -1
- package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
- package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/hook-command.test.js +88 -0
- package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
- package/dist/bindings/__tests__/hybrid-e2e.test.js +1 -1
- package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
- package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
- package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
- package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts +50 -1
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +636 -231
- package/dist/bindings/claude-code.js.map +1 -1
- package/dist/bindings/index.d.ts +7 -0
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +26 -7
- package/dist/bindings/index.js.map +1 -1
- package/dist/bindings/lib/prompt-fence.d.ts +22 -0
- package/dist/bindings/lib/prompt-fence.d.ts.map +1 -0
- package/dist/bindings/lib/prompt-fence.js +29 -0
- package/dist/bindings/lib/prompt-fence.js.map +1 -0
- package/dist/bindings/types.d.ts +6 -0
- package/dist/bindings/types.d.ts.map +1 -1
- package/dist/bindings/types.js.map +1 -1
- package/dist/cli/__tests__/cli.test.d.ts +1 -1
- package/dist/cli/__tests__/cli.test.js +1 -1
- package/dist/cli/index.js +124 -76
- package/dist/cli/index.js.map +1 -1
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +39 -3
- package/dist/docs/content.js.map +1 -1
- package/dist/docs/teach.d.ts.map +1 -1
- package/dist/docs/teach.js +46 -0
- package/dist/docs/teach.js.map +1 -1
- package/dist/import/__tests__/import.test.js +21 -9
- package/dist/import/__tests__/import.test.js.map +1 -1
- package/dist/import/claude-code.d.ts.map +1 -1
- package/dist/import/claude-code.js +128 -44
- package/dist/import/claude-code.js.map +1 -1
- package/dist/import/serializer.d.ts.map +1 -1
- package/dist/import/serializer.js +10 -0
- package/dist/import/serializer.js.map +1 -1
- package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
- package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
- package/dist/parser/__tests__/mod-hooks.test.js +136 -0
- package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
- package/dist/parser/ast.d.ts +29 -2
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js.map +1 -1
- package/dist/parser/index.d.ts +6 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +102 -25
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/paths.d.ts +15 -0
- package/dist/parser/paths.d.ts.map +1 -0
- package/dist/parser/paths.js +34 -0
- package/dist/parser/paths.js.map +1 -0
- package/dist/parser/run.d.ts +45 -0
- package/dist/parser/run.d.ts.map +1 -0
- package/dist/parser/run.js +91 -0
- package/dist/parser/run.js.map +1 -0
- package/dist/parser/validator.d.ts +12 -0
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +268 -2
- package/dist/parser/validator.js.map +1 -1
- package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
- package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
- package/dist/scaffold/__tests__/safety.test.js +184 -0
- package/dist/scaffold/__tests__/safety.test.js.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.js +2 -1
- package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
- package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
- package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
- package/dist/scaffold/diff.d.ts +17 -0
- package/dist/scaffold/diff.d.ts.map +1 -0
- package/dist/scaffold/diff.js +66 -0
- package/dist/scaffold/diff.js.map +1 -0
- package/dist/scaffold/incremental.d.ts +49 -13
- package/dist/scaffold/incremental.d.ts.map +1 -1
- package/dist/scaffold/incremental.js +235 -95
- package/dist/scaffold/incremental.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +8 -1
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +32 -8
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/merge.d.ts +15 -4
- package/dist/scaffold/merge.d.ts.map +1 -1
- package/dist/scaffold/merge.js +123 -18
- package/dist/scaffold/merge.js.map +1 -1
- package/dist/scaffold/types.d.ts +17 -0
- package/dist/scaffold/types.d.ts.map +1 -1
- package/dist/sync/index.d.ts +1 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +40 -2
- package/dist/sync/index.js.map +1 -1
- package/package.json +1 -1
- package/spec/grammar.md +24 -5
- package/spec/testing.md +85 -0
- package/spec/validation.md +8 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which project-relative paths are safe to write or to name as a node's file.
|
|
3
|
+
*
|
|
4
|
+
* Shared by scaffold (every generated path) and the validator (every `file:`),
|
|
5
|
+
* so a path the language accepts is a path scaffold can honour.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/** One path segment a binding may produce. Junk like `E;`, `fail:`, `x)` fails. */
|
|
10
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9_.@+-]+$/;
|
|
11
|
+
/**
|
|
12
|
+
* Why a generated relative path is unsafe to write, or null when it is fine.
|
|
13
|
+
*
|
|
14
|
+
* @param relPath - Path relative to the project root, as a binding emits it
|
|
15
|
+
*/
|
|
16
|
+
export function unsafePathReason(relPath) {
|
|
17
|
+
if (relPath.trim() === "")
|
|
18
|
+
return "empty path";
|
|
19
|
+
if (relPath.endsWith("/"))
|
|
20
|
+
return "ends in '/' (no file name)";
|
|
21
|
+
if (relPath.startsWith("/"))
|
|
22
|
+
return "absolute path";
|
|
23
|
+
const segments = relPath.split("/");
|
|
24
|
+
for (const seg of segments) {
|
|
25
|
+
if (seg === "")
|
|
26
|
+
return "empty path segment";
|
|
27
|
+
if (seg === "..")
|
|
28
|
+
return "climbs out of the project ('..')";
|
|
29
|
+
if (!SAFE_SEGMENT.test(seg))
|
|
30
|
+
return `unsafe name "${seg}"`;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/parser/paths.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,mFAAmF;AACnF,MAAM,YAAY,GAAG,qBAAqB,CAAC;AAE3C;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC9C,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,YAAY,CAAC;IAC/C,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,4BAA4B,CAAC;IAC/D,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,eAAe,CAAC;IACpD,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACpC,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;QAC3B,IAAI,GAAG,KAAK,EAAE;YAAE,OAAO,oBAAoB,CAAC;QAC5C,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,kCAAkC,CAAC;QAC5D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,gBAAgB,GAAG,GAAG,CAAC;IAC7D,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
* A `run:` split into the interpreter it names (if any), its script, and the
|
|
14
|
+
* script's arguments. The script is the first word, or the second when the
|
|
15
|
+
* first is an interpreter (`python3 x.py`, `/opt/homebrew/bin/python3 ./y.py`).
|
|
16
|
+
*
|
|
17
|
+
* The script name used to be the text after the LAST slash of the whole line,
|
|
18
|
+
* so an argument became the script: `python3 tool.py check show/SLUG` planned
|
|
19
|
+
* a stub named `SLUG`, and `pxframes.py gate OUT/frames-critic.json` one named
|
|
20
|
+
* `frames-critic.json`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runParts(run: string): {
|
|
23
|
+
interpreter?: string;
|
|
24
|
+
script: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Whether a script word names a file in the project (run where it is) rather
|
|
29
|
+
* than one in the topology skill's scripts/ folder (scaffolded by basename).
|
|
30
|
+
* `./x`, `/x` and any path with a folder count, except the historical
|
|
31
|
+
* `scripts/x.sh`, which has always meant the skill's own scripts folder.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isProjectPath(script?: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The script file name a binding writes for `run`, or null when `run` does not
|
|
36
|
+
* name one: the basename of the script word (see runParts).
|
|
37
|
+
*/
|
|
38
|
+
export declare function scriptNameFromRun(run: string | undefined): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Why `run` reads as prose rather than a command, or null when it looks like a
|
|
41
|
+
* command. Deliberately conservative: it flags only what a shell would reject
|
|
42
|
+
* or what no command contains, so real commands are never refused.
|
|
43
|
+
*/
|
|
44
|
+
export declare function proseInRun(run: string): string | null;
|
|
45
|
+
//# 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;AAYH;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAU9F;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,SAAK,GAAG,OAAO,CAGlD;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAMxE;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgBrD"}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/** Words that run the script named after them. */
|
|
17
|
+
const INTERPRETER = /^(?:python[0-9.]*|bash|sh|zsh|node|bun|deno|ruby|perl|tsx)$/;
|
|
18
|
+
const SCRIPT_EXT = /\.(?:py|sh|ts|mjs|js)$/;
|
|
19
|
+
/**
|
|
20
|
+
* A `run:` split into the interpreter it names (if any), its script, and the
|
|
21
|
+
* script's arguments. The script is the first word, or the second when the
|
|
22
|
+
* first is an interpreter (`python3 x.py`, `/opt/homebrew/bin/python3 ./y.py`).
|
|
23
|
+
*
|
|
24
|
+
* The script name used to be the text after the LAST slash of the whole line,
|
|
25
|
+
* so an argument became the script: `python3 tool.py check show/SLUG` planned
|
|
26
|
+
* a stub named `SLUG`, and `pxframes.py gate OUT/frames-critic.json` one named
|
|
27
|
+
* `frames-critic.json`.
|
|
28
|
+
*/
|
|
29
|
+
export function runParts(run) {
|
|
30
|
+
const words = run.trim().split(/\s+/);
|
|
31
|
+
const first = words[0] ?? "";
|
|
32
|
+
const named = words.length > 1 &&
|
|
33
|
+
!words[1].startsWith("-") && // `bash -c '...'` runs no script file
|
|
34
|
+
!SCRIPT_EXT.test(first) &&
|
|
35
|
+
(INTERPRETER.test(first.replace(/^.*\//, "")) || isProjectPath(words[1]));
|
|
36
|
+
const [script = "", ...args] = named ? words.slice(1) : words;
|
|
37
|
+
return { ...(named ? { interpreter: first } : {}), script, args };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether a script word names a file in the project (run where it is) rather
|
|
41
|
+
* than one in the topology skill's scripts/ folder (scaffolded by basename).
|
|
42
|
+
* `./x`, `/x` and any path with a folder count, except the historical
|
|
43
|
+
* `scripts/x.sh`, which has always meant the skill's own scripts folder.
|
|
44
|
+
*/
|
|
45
|
+
export function isProjectPath(script = "") {
|
|
46
|
+
if (script.startsWith(".") || script.startsWith("/"))
|
|
47
|
+
return true;
|
|
48
|
+
return script.includes("/") && !script.startsWith("scripts/");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The script file name a binding writes for `run`, or null when `run` does not
|
|
52
|
+
* name one: the basename of the script word (see runParts).
|
|
53
|
+
*/
|
|
54
|
+
export function scriptNameFromRun(run) {
|
|
55
|
+
if (!run)
|
|
56
|
+
return null;
|
|
57
|
+
// A slash standing alone is prose ("0.0 / 0.5 / 3.0 s"), never a path.
|
|
58
|
+
if (/\s\/\s/.test(run))
|
|
59
|
+
return null;
|
|
60
|
+
const name = runParts(run).script.replace(/^.*\//, "");
|
|
61
|
+
return SCRIPT_FILE.test(name) ? name : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Why `run` reads as prose rather than a command, or null when it looks like a
|
|
65
|
+
* command. Deliberately conservative: it flags only what a shell would reject
|
|
66
|
+
* or what no command contains, so real commands are never refused.
|
|
67
|
+
*/
|
|
68
|
+
export function proseInRun(run) {
|
|
69
|
+
const text = run.trim();
|
|
70
|
+
if (text === "")
|
|
71
|
+
return "it is empty";
|
|
72
|
+
if (/\r|\n/.test(text))
|
|
73
|
+
return "it spans several lines";
|
|
74
|
+
const first = text.split(/\s+/)[0];
|
|
75
|
+
if (/[:;,]$/.test(first))
|
|
76
|
+
return `its first word "${first}" ends in punctuation`;
|
|
77
|
+
if (!EXECUTABLE.test(first))
|
|
78
|
+
return `its first word "${first}" is not a command or path`;
|
|
79
|
+
// Judge only what the shell would see unquoted.
|
|
80
|
+
const bare = text.replace(/'[^']*'|"[^"]*"/g, "''");
|
|
81
|
+
if (/[^\x00-\x7F]/.test(bare))
|
|
82
|
+
return "it contains non-ASCII text outside quotes";
|
|
83
|
+
if (/\s->\s|\s=>\s|\s<=\s|\s>=\s/.test(bare))
|
|
84
|
+
return "it contains an arrow or comparison (->, >=, <=)";
|
|
85
|
+
if (/(^|[^$])\(/.test(bare))
|
|
86
|
+
return "it contains an unquoted parenthesis";
|
|
87
|
+
if (scriptNameFromRun(text) === null)
|
|
88
|
+
return "no script file name can be derived from it";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/parser/run.ts"],"names":[],"mappings":"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"}
|
|
@@ -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;AAi8FD;;;;;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,CAsG7D"}
|
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,121 @@ 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
|
+
// A prompt hook's run: is the prompt itself, so prose is what it should be.
|
|
1023
|
+
const isCommand = (hook) => hook.type !== "prompt";
|
|
1024
|
+
for (const node of ast.nodes) {
|
|
1025
|
+
if (isGate(node))
|
|
1026
|
+
check(`Gate "${node.id}"`, node.run, node.id);
|
|
1027
|
+
if (node.type === "agent") {
|
|
1028
|
+
for (const hook of (node.hooks ?? []).filter(isCommand)) {
|
|
1029
|
+
check(`Hook "${hook.name}" on agent "${node.id}"`, hook.run, node.id);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (const hook of (ast.hooks ?? []).filter(isCommand))
|
|
1034
|
+
check(`Hook "${hook.name}"`, hook.run);
|
|
1035
|
+
return results;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* V97: a `file:` is a safe project-relative path.
|
|
1039
|
+
*
|
|
1040
|
+
* `file:` marks a node's internals as hand-written at that path; scaffold
|
|
1041
|
+
* never writes it. A path outside the project ('..', absolute) or with an
|
|
1042
|
+
* unsafe name could not be honoured, so it is refused here, not at scaffold.
|
|
1043
|
+
*/
|
|
1044
|
+
function v97FileIsProjectPath(ast) {
|
|
1045
|
+
const results = [];
|
|
1046
|
+
const check = (owner, file, nodeId) => {
|
|
1047
|
+
if (file === undefined)
|
|
1048
|
+
return;
|
|
1049
|
+
const why = unsafePathReason(file.replace(/^\.\//, ""));
|
|
1050
|
+
if (!why)
|
|
1051
|
+
return;
|
|
1052
|
+
results.push({
|
|
1053
|
+
rule: "V97",
|
|
1054
|
+
level: "error",
|
|
1055
|
+
message: `${owner} "file" is not a project-relative path — ${why}. Name the file from the project root, e.g. file: ".claude/agents/reviewer.md"`,
|
|
1056
|
+
...(nodeId ? { node: nodeId, line: lookupLine(ast, nodeId) } : {}),
|
|
1057
|
+
});
|
|
1058
|
+
};
|
|
1059
|
+
for (const node of ast.nodes)
|
|
1060
|
+
check(`Node "${node.id}"`, node.file, node.id);
|
|
1061
|
+
for (const skill of ast.skills ?? [])
|
|
1062
|
+
check(`Skill "${skill.id}"`, skill.file);
|
|
1063
|
+
for (const trigger of ast.triggers ?? [])
|
|
1064
|
+
check(`Command "${trigger.name}"`, trigger.file);
|
|
1065
|
+
return results;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* V98: a block that belongs inside a container is not written beside it.
|
|
1069
|
+
*
|
|
1070
|
+
* `gate x { }` outside `gates { }` (likewise hook, tool, job, command, level)
|
|
1071
|
+
* was dropped by the parser with no message. Collected at parse time.
|
|
1072
|
+
*/
|
|
1073
|
+
function v98BlockInsideItsContainer(ast) {
|
|
1074
|
+
return ast._misplacedBlockErrors ?? [];
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* V99: an agent's `skills:` and `mcp-servers:`, and a skill's `agent:`, name
|
|
1078
|
+
* something this topology declares.
|
|
1079
|
+
*
|
|
1080
|
+
* A misspelled name compiled to a reference Claude Code could not resolve,
|
|
1081
|
+
* with no message. A warning, not an error: the name may be real outside the
|
|
1082
|
+
* topology (a skill on disk, a user-level MCP server, a built-in agent).
|
|
1083
|
+
*/
|
|
1084
|
+
const BUILT_IN_AGENTS = new Set(["general-purpose", "Explore", "Plan"]);
|
|
1085
|
+
function v99ReferencesDeclared(ast) {
|
|
1086
|
+
const results = [];
|
|
1087
|
+
const skills = new Set([ast.topology.name, ...(ast.skills ?? []).map((s) => s.id)]);
|
|
1088
|
+
const servers = new Set([...Object.keys(ast.mcpServers ?? {}), ...(ast.stores ?? []).map((s) => s.id)]);
|
|
1089
|
+
const agents = new Set(ast.nodes.filter((n) => n.type === "agent" || n.type === "group").map((n) => n.id));
|
|
1090
|
+
const warn = (message, nodeId) => results.push({ rule: "V99", level: "warning", message, ...(nodeId ? { node: nodeId, line: lookupLine(ast, nodeId) } : {}) });
|
|
1091
|
+
for (const node of ast.nodes) {
|
|
1092
|
+
if (node.type !== "agent")
|
|
1093
|
+
continue;
|
|
1094
|
+
const agent = node;
|
|
1095
|
+
for (const skill of agent.skills ?? []) {
|
|
1096
|
+
if (!skills.has(skill)) {
|
|
1097
|
+
warn(`Agent "${agent.id}" uses skill "${skill}", which this topology does not declare — scaffold writes no SKILL.md for it. If it is hand-written, declare skill ${skill} { file: ".claude/skills/${skill}/SKILL.md" }; otherwise check the name`, agent.id);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
for (const server of agent.mcpServers ?? []) {
|
|
1101
|
+
if (!servers.has(server)) {
|
|
1102
|
+
warn(`Agent "${agent.id}" uses MCP server "${server}", which mcp-servers { } does not declare — it resolves only if configured outside this project`, agent.id);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
for (const skill of ast.skills ?? []) {
|
|
1107
|
+
if (skill.agent && !agents.has(skill.agent) && !BUILT_IN_AGENTS.has(skill.agent)) {
|
|
1108
|
+
warn(`Skill "${skill.id}" runs in agent "${skill.agent}", which is not an agent in this topology or a built-in one (${[...BUILT_IN_AGENTS].join(", ")})`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return results;
|
|
1112
|
+
}
|
|
987
1113
|
function v93EdgeHandoffDeclared(ast) {
|
|
988
1114
|
const results = [];
|
|
989
1115
|
const byId = new Map(ast.nodes.map((n) => [n.id, n]));
|
|
@@ -2939,6 +3065,140 @@ function v88CustodyRefsValid(ast) {
|
|
|
2939
3065
|
* @param ast - The parsed topology AST.
|
|
2940
3066
|
* @returns An array of validation results. An empty array means no issues found.
|
|
2941
3067
|
*/
|
|
3068
|
+
/**
|
|
3069
|
+
* Engine events a `type: mod` hook may subscribe to (Claude Code function
|
|
3070
|
+
* hooks, early access, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
|
|
3071
|
+
*
|
|
3072
|
+
* PROVEN: a Mod was loaded and fired on these (Claude Code 2.1.283).
|
|
3073
|
+
* SEEN: the name is in the 2.1.283 binary but no Mod has been observed on it,
|
|
3074
|
+
* so V95 accepts it with a warning rather than an error.
|
|
3075
|
+
*/
|
|
3076
|
+
export const MOD_EVENTS_PROVEN = ["tool.call", "prompt.submit"];
|
|
3077
|
+
export const MOD_EVENTS_SEEN = [
|
|
3078
|
+
"prompt.context", "session.start", "session.end", "model.complete", "message.received",
|
|
3079
|
+
];
|
|
3080
|
+
/** Hook types the grammar defines (`spec/grammar.md` §4.16). */
|
|
3081
|
+
export const HOOK_TYPES = ["command", "prompt", "mod"];
|
|
3082
|
+
/** Every hook in the topology — global first, then per-agent — with its owner. */
|
|
3083
|
+
function allHooks(ast) {
|
|
3084
|
+
const out = ast.hooks.map((hook) => ({ hook, where: hook.name }));
|
|
3085
|
+
for (const n of ast.nodes) {
|
|
3086
|
+
if (n.type !== "agent" || !n.hooks)
|
|
3087
|
+
continue;
|
|
3088
|
+
for (const hook of n.hooks)
|
|
3089
|
+
out.push({ hook, where: `${n.id}.${hook.name}`, agent: n.id });
|
|
3090
|
+
}
|
|
3091
|
+
return out;
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* V94: a hook's `type` must be one the grammar defines.
|
|
3095
|
+
*
|
|
3096
|
+
* `type: mod` passed silently before the grammar knew it, and the claude-code
|
|
3097
|
+
* binding wrote it into settings.json as a bash command. A misspelt type
|
|
3098
|
+
* (`type: comand`) did the same.
|
|
3099
|
+
*/
|
|
3100
|
+
function v94HookTypeKnown(ast) {
|
|
3101
|
+
const results = [];
|
|
3102
|
+
for (const { hook, where, agent } of allHooks(ast)) {
|
|
3103
|
+
const type = hook.type ?? "command";
|
|
3104
|
+
if (!HOOK_TYPES.includes(type)) {
|
|
3105
|
+
results.push({
|
|
3106
|
+
rule: "V94",
|
|
3107
|
+
level: "error",
|
|
3108
|
+
message: `Hook "${where}" has type "${type}" — a hook is one of: ${HOOK_TYPES.join(", ")}. ` +
|
|
3109
|
+
`Use "mod" for a TypeScript module that runs inside the engine.`,
|
|
3110
|
+
node: where,
|
|
3111
|
+
});
|
|
3112
|
+
continue;
|
|
3113
|
+
}
|
|
3114
|
+
if (type === "mod" && agent) {
|
|
3115
|
+
results.push({
|
|
3116
|
+
rule: "V94",
|
|
3117
|
+
level: "error",
|
|
3118
|
+
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.`,
|
|
3119
|
+
node: where,
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
if (hook.flag !== undefined && type !== "mod") {
|
|
3123
|
+
results.push({
|
|
3124
|
+
rule: "V94",
|
|
3125
|
+
level: "error",
|
|
3126
|
+
message: `Hook "${where}" sets flag: but is type "${type}" — flag: is the env switch of a type: mod hook.`,
|
|
3127
|
+
node: where,
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
else if (hook.flag !== undefined && !/^[A-Z_][A-Z0-9_]*$/.test(hook.flag)) {
|
|
3131
|
+
results.push({
|
|
3132
|
+
rule: "V94",
|
|
3133
|
+
level: "error",
|
|
3134
|
+
message: `Hook "${where}" has flag "${hook.flag}" — a flag is an env variable name (e.g. MY_MOD_ON).`,
|
|
3135
|
+
node: where,
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
return results;
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* V95: a hook's `on:` must be an event its type can subscribe to.
|
|
3143
|
+
*
|
|
3144
|
+
* A mod subscribes to a dotted engine event (`tool.call`); a command or prompt
|
|
3145
|
+
* hook to a PascalCase lifecycle event (`PreToolUse`, spec §4.16: "any
|
|
3146
|
+
* identifier in PascalCase"). Mixing them is a hook that never fires. A value
|
|
3147
|
+
* with whitespace is left to V90 (a swallowed field), not reported twice.
|
|
3148
|
+
*/
|
|
3149
|
+
function v95HookEventKnown(ast) {
|
|
3150
|
+
const results = [];
|
|
3151
|
+
const proven = MOD_EVENTS_PROVEN;
|
|
3152
|
+
const seen = MOD_EVENTS_SEEN;
|
|
3153
|
+
for (const { hook, where } of allHooks(ast)) {
|
|
3154
|
+
const type = hook.type ?? "command";
|
|
3155
|
+
const on = hook.on.trim();
|
|
3156
|
+
if (/\s/.test(on))
|
|
3157
|
+
continue; // V90
|
|
3158
|
+
if (!on) {
|
|
3159
|
+
results.push({ rule: "V95", level: "error", message: `Hook "${where}" has no on: — name the event it fires on.`, node: where });
|
|
3160
|
+
continue;
|
|
3161
|
+
}
|
|
3162
|
+
if (type === "mod") {
|
|
3163
|
+
if (!hook.run) {
|
|
3164
|
+
results.push({ rule: "V95", level: "error", message: `Mod "${where}" has no run: — name its module (e.g. run: "hooks/redact.ts").`, node: where });
|
|
3165
|
+
}
|
|
3166
|
+
if (proven.includes(on))
|
|
3167
|
+
continue;
|
|
3168
|
+
if (seen.includes(on)) {
|
|
3169
|
+
results.push({
|
|
3170
|
+
rule: "V95",
|
|
3171
|
+
level: "warning",
|
|
3172
|
+
message: `Mod "${where}" subscribes to "${on}" — the engine names this event, but no Mod has been observed firing on it. Proven: ${proven.join(", ")}.`,
|
|
3173
|
+
node: where,
|
|
3174
|
+
});
|
|
3175
|
+
continue;
|
|
3176
|
+
}
|
|
3177
|
+
const hint = /^[A-Z]/.test(on)
|
|
3178
|
+
? ` "${on}" is a command-hook event; a mod subscribes to an engine event.`
|
|
3179
|
+
: "";
|
|
3180
|
+
results.push({
|
|
3181
|
+
rule: "V95",
|
|
3182
|
+
level: "error",
|
|
3183
|
+
message: `Mod "${where}" subscribes to unknown engine event "${on}".${hint} Engine events: ${[...proven, ...seen].join(", ")}.`,
|
|
3184
|
+
node: where,
|
|
3185
|
+
});
|
|
3186
|
+
continue;
|
|
3187
|
+
}
|
|
3188
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(on)) {
|
|
3189
|
+
const hint = proven.includes(on) || seen.includes(on)
|
|
3190
|
+
? ` "${on}" is an engine event — set type: mod to subscribe a module to it.`
|
|
3191
|
+
: "";
|
|
3192
|
+
results.push({
|
|
3193
|
+
rule: "V95",
|
|
3194
|
+
level: "error",
|
|
3195
|
+
message: `Hook "${where}" (type ${type}) fires on "${on}" — a ${type} hook takes a PascalCase event (e.g. PreToolUse, AgentStop).${hint}`,
|
|
3196
|
+
node: where,
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return results;
|
|
3201
|
+
}
|
|
2942
3202
|
export function validate(ast) {
|
|
2943
3203
|
return [
|
|
2944
3204
|
...v1UniqueNames(ast),
|
|
@@ -2970,6 +3230,12 @@ export function validate(ast) {
|
|
|
2970
3230
|
...v91RequiredFieldPlaceholder(ast),
|
|
2971
3231
|
...v92EdgeTwoBrackets(ast),
|
|
2972
3232
|
...v93EdgeHandoffDeclared(ast),
|
|
3233
|
+
...v94HookTypeKnown(ast),
|
|
3234
|
+
...v95HookEventKnown(ast),
|
|
3235
|
+
...v96RunIsCommand(ast),
|
|
3236
|
+
...v97FileIsProjectPath(ast),
|
|
3237
|
+
...v98BlockInsideItsContainer(ast),
|
|
3238
|
+
...v99ReferencesDeclared(ast),
|
|
2973
3239
|
...v25BounceBackAdvisory(ast),
|
|
2974
3240
|
...v26ActionKindEnum(ast),
|
|
2975
3241
|
...v27AgentPermissionsEnum(ast),
|