@sabaiway/agent-workflow-kit 1.2.0 → 1.4.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/CHANGELOG.md +47 -1
- package/README.md +15 -5
- package/SKILL.md +86 -42
- package/bin/install.mjs +59 -8
- package/bin/install.test.mjs +66 -0
- package/capability.json +21 -0
- package/migrations/1.1.0-communication-language.md +5 -5
- package/migrations/README.md +2 -1
- package/package.json +8 -5
- package/references/contracts.md +62 -0
- package/references/scripts/archive-changelog.mjs +1 -4
- package/references/templates/AGENTS.md +2 -2
- package/tools/delegation.mjs +109 -0
- package/tools/delegation.test.mjs +115 -0
- package/tools/inject-methodology.mjs +111 -0
- package/tools/inject-methodology.test.mjs +124 -0
- package/tools/manifest/fixtures/bad-available/SKILL.md +7 -0
- package/tools/manifest/fixtures/bad-available/capability.json +10 -0
- package/tools/manifest/fixtures/detect-array/SKILL.md +7 -0
- package/tools/manifest/fixtures/detect-array/capability.json +10 -0
- package/tools/manifest/fixtures/malformed-json/capability.json +1 -0
- package/tools/manifest/fixtures/metadata-version/SKILL.md +10 -0
- package/tools/manifest/fixtures/metadata-version/capability.json +9 -0
- package/tools/manifest/fixtures/missing-key/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-key/capability.json +8 -0
- package/tools/manifest/fixtures/missing-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-source/capability.json +11 -0
- package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +10 -0
- package/tools/manifest/fixtures/nested-version-decoy/capability.json +9 -0
- package/tools/manifest/fixtures/null-root/capability.json +1 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +2 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +11 -0
- package/tools/manifest/fixtures/stub/capability.json +10 -0
- package/tools/manifest/fixtures/traversal-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/traversal-source/capability.json +11 -0
- package/tools/manifest/fixtures/unknown-schema/capability.json +9 -0
- package/tools/manifest/fixtures/valid/SKILL.md +10 -0
- package/tools/manifest/fixtures/valid/bin/run.sh +3 -0
- package/tools/manifest/fixtures/valid/capability.json +18 -0
- package/tools/manifest/fixtures/version-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/version-mismatch/capability.json +9 -0
- package/tools/manifest/fixtures/win-absolute-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/win-absolute-source/capability.json +11 -0
- package/tools/manifest/schema.md +67 -0
- package/tools/manifest/validate.mjs +264 -0
- package/tools/manifest/validate.test.mjs +73 -0
- package/tools/methodology-slot.md +1 -0
- package/tools/release-scan.mjs +103 -0
- package/tools/release-scan.test.mjs +41 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nested-version-decoy
|
|
3
|
+
metadata:
|
|
4
|
+
version: '1.0.0'
|
|
5
|
+
nested:
|
|
6
|
+
version: '9.9.9'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# nested-version-decoy fixture — the DIRECT metadata.version (1.0.0) wins; the deeper
|
|
10
|
+
# metadata.nested.version (9.9.9) must be ignored. capability.json declares 1.0.0 → VALID.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
null
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"family": "agent-workflow",
|
|
3
|
+
"schema": 1,
|
|
4
|
+
"name": "fixture-valid",
|
|
5
|
+
"kind": "execution-backend",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"provides": ["review", "execute"],
|
|
8
|
+
"roles": {
|
|
9
|
+
"review": { "cmd": "fixture-review", "source": "bin/run.sh", "modes": ["plan", "code"], "output": "advisory" },
|
|
10
|
+
"execute": { "cmd": "fixture-exec", "source": "bin/run.sh" }
|
|
11
|
+
},
|
|
12
|
+
"detect": {
|
|
13
|
+
"installed": { "env": "FIXTURE_DIR", "default": "~/.claude/skills/fixture-valid", "file": "SKILL.md" },
|
|
14
|
+
"deployed": { "file": "docs/ai/.memory-version" }
|
|
15
|
+
},
|
|
16
|
+
"cost": "subscription",
|
|
17
|
+
"quota": { "kind": "subscription", "finite": true }
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# `capability.json` — family manifest schema (schema 1)
|
|
2
|
+
|
|
3
|
+
Owned and shipped by the kit (the composition root). Every `agent-workflow` family member
|
|
4
|
+
ships a `capability.json` at its skill root. Detection is **declarative** (path/env fields, not
|
|
5
|
+
embedded shell — Windows-safe); the installer resolves path fields in Node (tilde + env
|
|
6
|
+
expansion), never via the shell. The validator is [`validate.mjs`](./validate.mjs).
|
|
7
|
+
|
|
8
|
+
## Fields
|
|
9
|
+
|
|
10
|
+
| Key | Type | Required | Notes |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| `family` | string | yes | must be `"agent-workflow"` |
|
|
13
|
+
| `schema` | number | yes | `1`. An unknown number → **unsupported** (not invalid). |
|
|
14
|
+
| `name` | string | yes | the skill name |
|
|
15
|
+
| `kind` | string | yes | one of `memory-substrate`, `methodology-engine`, `execution-backend`, `composition-root` |
|
|
16
|
+
| `version` | string | yes | must equal the authoritative version (below), unless `available:false` |
|
|
17
|
+
| `provides` | string[] | yes | subset of the **role vocabulary** |
|
|
18
|
+
| `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
|
|
19
|
+
| `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
|
|
20
|
+
| `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
|
|
21
|
+
| `cost` / `quota` / `provenance` | misc | no | informational |
|
|
22
|
+
| `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
|
|
23
|
+
|
|
24
|
+
## Role vocabulary
|
|
25
|
+
|
|
26
|
+
`context | plan | execute | review | probe | synthesize`. Every entry of `provides` and every
|
|
27
|
+
key of `roles` must come from this set, and **`provides` ⊇ `Object.keys(roles)`** (you may
|
|
28
|
+
advertise a capability without a callable role, but never the reverse).
|
|
29
|
+
|
|
30
|
+
### Roles
|
|
31
|
+
|
|
32
|
+
Each `roles.<role>` is an object:
|
|
33
|
+
|
|
34
|
+
- `cmd` (string, required) — the **PATH name** of the wrapper (e.g. `codex-review`). Not a repo
|
|
35
|
+
path; not validated for existence.
|
|
36
|
+
- `source` (string, required) — the **in-skill script** backing `cmd` (e.g. `bin/codex-review.sh`).
|
|
37
|
+
Repo-relative within the skill; **must exist**.
|
|
38
|
+
- `template` (string, optional) — an in-skill prompt/template path (e.g.
|
|
39
|
+
`references/review-prompt.md`); repo-relative, **must exist**.
|
|
40
|
+
- `modes`, `output` (optional) — e.g. `["plan","code"]`, `"advisory"`.
|
|
41
|
+
|
|
42
|
+
## Path-field rules (Windows-safe, traversal-safe)
|
|
43
|
+
|
|
44
|
+
- **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
|
|
45
|
+
in any field.
|
|
46
|
+
- `source`, `template`, `detect.installed.file` — repo-relative **within the skill**; must exist
|
|
47
|
+
(skipped for `available:false` stubs).
|
|
48
|
+
- `detect.installed.default` — may be **home-relative** (`~/…`); resolved by the installer.
|
|
49
|
+
- `detect.deployed.file` — **project-relative** (e.g. `docs/ai/.memory-version`); format-checked
|
|
50
|
+
only (it lives in the target project, not the skill).
|
|
51
|
+
- `detect.installed.env` — an **env var name**, not a path.
|
|
52
|
+
|
|
53
|
+
## Authoritative version
|
|
54
|
+
|
|
55
|
+
`version` is matched against `package.json` `version` where one exists, else the skill's
|
|
56
|
+
`SKILL.md` frontmatter `metadata.version` — so a bridge with no `package.json` cannot drift from
|
|
57
|
+
its `SKILL.md`. Skipped only for `available:false` stubs.
|
|
58
|
+
|
|
59
|
+
## Result classes
|
|
60
|
+
|
|
61
|
+
- **valid** — schema understood, all checks pass.
|
|
62
|
+
- **unsupported** — `schema` is a number this validator does not understand (forward-compat).
|
|
63
|
+
- **invalid** — schema understood but a check failed.
|
|
64
|
+
|
|
65
|
+
Runtime callers (the kit's memory detector) treat **unsupported and invalid alike — do not act**,
|
|
66
|
+
fall back to the bundled copy. Authoring/CI runs `validate.mjs --strict` and exits non-zero on
|
|
67
|
+
**unsupported or invalid**.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Family-wide capability.json validator — OWNED AND SHIPPED BY THE KIT (the composition
|
|
3
|
+
// root legitimately knows the whole family). Pure Node, JSON.parse, dependency-free.
|
|
4
|
+
//
|
|
5
|
+
// Shipped in the kit's tarball + installer PAYLOAD so an *installed* kit can run it as the
|
|
6
|
+
// memory detector (SKILL.md §"delegate-else-fallback"); root CI invokes this SAME file over
|
|
7
|
+
// every real capability.json. It lives in the kit, never in memory (preserving memory's
|
|
8
|
+
// "knows nobody"): a DAG, no cycle.
|
|
9
|
+
//
|
|
10
|
+
// Three result classes:
|
|
11
|
+
// valid — well-formed, schema understood, every check passes.
|
|
12
|
+
// unsupported — schema number this validator does not understand (forward-compat).
|
|
13
|
+
// invalid — schema understood but a check failed (malformed, missing, mismatched).
|
|
14
|
+
// Runtime callers (the kit detector) treat unsupported AND invalid alike: DO NOT act, fall
|
|
15
|
+
// back. Authoring/CI runs with `--strict` and exits non-zero on unsupported OR invalid.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node validate.mjs <skill-dir>... # report, always exit 0 (informational)
|
|
19
|
+
// node validate.mjs --strict <skill-dir>... # exit 1 if any dir is not "valid"
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { resolve, join, isAbsolute } from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
export const FAMILY = 'agent-workflow';
|
|
26
|
+
export const SUPPORTED_SCHEMA = 1;
|
|
27
|
+
export const KINDS = new Set([
|
|
28
|
+
'memory-substrate',
|
|
29
|
+
'methodology-engine',
|
|
30
|
+
'execution-backend',
|
|
31
|
+
'composition-root',
|
|
32
|
+
]);
|
|
33
|
+
export const ROLE_VOCAB = new Set(['context', 'plan', 'execute', 'review', 'probe', 'synthesize']);
|
|
34
|
+
|
|
35
|
+
export const VALID = 'valid';
|
|
36
|
+
export const UNSUPPORTED = 'unsupported';
|
|
37
|
+
export const INVALID = 'invalid';
|
|
38
|
+
|
|
39
|
+
const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
|
|
40
|
+
const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
|
|
41
|
+
|
|
42
|
+
// Cross-platform "absolute-like": POSIX root (/x), Windows drive (C:\ or C:/), UNC (\\host),
|
|
43
|
+
// or a leading backslash. node:path.isAbsolute() alone is platform-dependent, so a Windows path
|
|
44
|
+
// would slip through unchecked on a POSIX CI runner — Windows-safety requires rejecting both.
|
|
45
|
+
const isAbsoluteLike = (p) => isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || /^[\\/]{2}/.test(p) || p.startsWith('\\');
|
|
46
|
+
|
|
47
|
+
const collectStrings = (val, acc = []) => {
|
|
48
|
+
if (typeof val === 'string') acc.push(val);
|
|
49
|
+
else if (Array.isArray(val)) val.forEach((v) => collectStrings(v, acc));
|
|
50
|
+
else if (val && typeof val === 'object') Object.values(val).forEach((v) => collectStrings(v, acc));
|
|
51
|
+
return acc;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
55
|
+
// Parse the `version:` that is a DIRECT child of the `metadata:` block — not the first `version:`
|
|
56
|
+
// anywhere in the frontmatter (so an unrelated top-level `version:` is ignored) and not a deeper
|
|
57
|
+
// nested `version:` (so `metadata: { foo: { version } }` is ignored). The direct-child indent is
|
|
58
|
+
// fixed by the first child line of the block.
|
|
59
|
+
const readSkillVersion = (text) => {
|
|
60
|
+
const fm = text.match(FRONTMATTER_RE);
|
|
61
|
+
if (!fm) return null;
|
|
62
|
+
const lines = fm[1].split('\n');
|
|
63
|
+
const metaIdx = lines.findIndex((line) => /^metadata:\s*$/.test(line));
|
|
64
|
+
if (metaIdx === -1) return null;
|
|
65
|
+
const block = [];
|
|
66
|
+
for (const line of lines.slice(metaIdx + 1)) {
|
|
67
|
+
if (line.trim() === '') continue; // tolerate blank lines inside the block
|
|
68
|
+
if (/^\S/.test(line)) break; // dedent → left the metadata block
|
|
69
|
+
block.push(line);
|
|
70
|
+
}
|
|
71
|
+
if (block.length === 0) return null;
|
|
72
|
+
const childIndent = block[0].match(/^(\s+)/)[1];
|
|
73
|
+
const directChild = new RegExp(`^${childIndent}version:\\s*['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*$`);
|
|
74
|
+
const match = block.map((line) => line.match(directChild)).find(Boolean);
|
|
75
|
+
return match ? match[1] : null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Authoritative version source: package.json where one exists, else SKILL.md
|
|
79
|
+
// frontmatter metadata.version. So a bridge (no package.json) can't drift from its SKILL.md.
|
|
80
|
+
const readAuthoritativeVersion = (skillDir) => {
|
|
81
|
+
const pkgPath = join(skillDir, 'package.json');
|
|
82
|
+
if (existsSync(pkgPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
85
|
+
return { version: pkg.version ?? null, from: 'package.json' };
|
|
86
|
+
} catch {
|
|
87
|
+
return { version: null, from: 'package.json (unparseable)' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const skillPath = join(skillDir, 'SKILL.md');
|
|
91
|
+
if (existsSync(skillPath)) {
|
|
92
|
+
return { version: readSkillVersion(readFileSync(skillPath, 'utf8')), from: 'SKILL.md metadata.version' };
|
|
93
|
+
}
|
|
94
|
+
return { version: null, from: 'no package.json or SKILL.md' };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const validateManifest = (skillDir) => {
|
|
98
|
+
const manifestPath = join(skillDir, 'capability.json');
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = readFileSync(manifestPath, 'utf8');
|
|
102
|
+
} catch {
|
|
103
|
+
return { result: INVALID, errors: [`capability.json not found in ${skillDir}`] };
|
|
104
|
+
}
|
|
105
|
+
let manifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = JSON.parse(raw);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { result: INVALID, errors: [`malformed JSON: ${err.message}`] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Valid JSON that is not an object (null, array, string, number) would crash field access —
|
|
113
|
+
// classify as invalid so runtime callers fall back instead of throwing.
|
|
114
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
115
|
+
return { result: INVALID, errors: ['top-level capability.json must be a JSON object'] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Schema gate first — an unknown schema is *unsupported*, distinct from *invalid*.
|
|
119
|
+
if (typeof manifest.schema !== 'number') {
|
|
120
|
+
return { result: INVALID, name: manifest.name, errors: ['`schema` missing or not a number'] };
|
|
121
|
+
}
|
|
122
|
+
if (manifest.schema !== SUPPORTED_SCHEMA) {
|
|
123
|
+
return {
|
|
124
|
+
result: UNSUPPORTED,
|
|
125
|
+
name: manifest.name,
|
|
126
|
+
errors: [`unsupported schema ${manifest.schema} (this validator understands ${SUPPORTED_SCHEMA})`],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const errors = [];
|
|
131
|
+
const requireString = (key) => {
|
|
132
|
+
if (typeof manifest[key] !== 'string' || !manifest[key]) errors.push(`\`${key}\` must be a non-empty string`);
|
|
133
|
+
};
|
|
134
|
+
requireString('family');
|
|
135
|
+
requireString('name');
|
|
136
|
+
requireString('kind');
|
|
137
|
+
requireString('version');
|
|
138
|
+
if (manifest.family !== FAMILY) errors.push(`\`family\` must be "${FAMILY}"`);
|
|
139
|
+
if (typeof manifest.kind === 'string' && !KINDS.has(manifest.kind)) errors.push(`unknown \`kind\` "${manifest.kind}"`);
|
|
140
|
+
if (manifest.available != null && typeof manifest.available !== 'boolean') errors.push('`available`, if present, must be a boolean');
|
|
141
|
+
if (!Array.isArray(manifest.provides)) errors.push('`provides` must be an array');
|
|
142
|
+
const rolesOk = manifest.roles != null && typeof manifest.roles === 'object' && !Array.isArray(manifest.roles);
|
|
143
|
+
if (!rolesOk) errors.push('`roles` must be an object');
|
|
144
|
+
|
|
145
|
+
const provides = Array.isArray(manifest.provides) ? manifest.provides : [];
|
|
146
|
+
for (const p of provides) if (!ROLE_VOCAB.has(p)) errors.push(`\`provides\` lists unknown role "${p}"`);
|
|
147
|
+
const roles = rolesOk ? manifest.roles : {};
|
|
148
|
+
for (const key of Object.keys(roles)) {
|
|
149
|
+
if (!ROLE_VOCAB.has(key)) errors.push(`role "${key}" is not in the role vocabulary`);
|
|
150
|
+
if (!provides.includes(key)) errors.push(`role "${key}" is missing from \`provides\` (provides ⊇ Object.keys(roles))`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const s of collectStrings(manifest)) {
|
|
154
|
+
if (isUnresolved(s)) errors.push(`unresolved placeholder in "${s}"`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const isStub = manifest.available === false;
|
|
158
|
+
|
|
159
|
+
// In-skill path: repo-relative, no absolute, no "~", no "..", and (unless a stub) must exist.
|
|
160
|
+
const checkInSkillPath = (label, value, mustExist) => {
|
|
161
|
+
if (typeof value !== 'string' || !value) {
|
|
162
|
+
errors.push(`${label} must be a non-empty string`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let bad = false;
|
|
166
|
+
if (isAbsoluteLike(value)) {
|
|
167
|
+
errors.push(`${label} must not be an absolute path ("${value}")`);
|
|
168
|
+
bad = true;
|
|
169
|
+
}
|
|
170
|
+
if (value.startsWith('~')) {
|
|
171
|
+
errors.push(`${label} must be repo-relative within the skill, not home-relative ("${value}")`);
|
|
172
|
+
bad = true;
|
|
173
|
+
}
|
|
174
|
+
if (hasTraversal(value)) {
|
|
175
|
+
errors.push(`${label} must not contain ".." traversal ("${value}")`);
|
|
176
|
+
bad = true;
|
|
177
|
+
}
|
|
178
|
+
if (mustExist && !bad && !existsSync(join(skillDir, value))) {
|
|
179
|
+
errors.push(`${label} not found in the skill dir: "${value}"`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const detect = manifest.detect;
|
|
184
|
+
if (detect != null) {
|
|
185
|
+
if (typeof detect !== 'object' || Array.isArray(detect)) {
|
|
186
|
+
errors.push('`detect` must be an object');
|
|
187
|
+
} else {
|
|
188
|
+
const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
|
|
189
|
+
const inst = detect.installed;
|
|
190
|
+
if (inst != null && !isPlainObject(inst)) {
|
|
191
|
+
errors.push('`detect.installed` must be an object');
|
|
192
|
+
} else if (inst != null) {
|
|
193
|
+
if (inst.env != null && typeof inst.env !== 'string') errors.push('`detect.installed.env` must be a string (env var name)');
|
|
194
|
+
if (inst.default != null) {
|
|
195
|
+
if (typeof inst.default !== 'string') errors.push('`detect.installed.default` must be a string');
|
|
196
|
+
else {
|
|
197
|
+
// `default` may be home-relative (~/…); reject only true absolute/traversal forms.
|
|
198
|
+
if (isAbsoluteLike(inst.default)) errors.push('`detect.installed.default` must not be an absolute path');
|
|
199
|
+
if (hasTraversal(inst.default)) errors.push('`detect.installed.default` must not contain ".."');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (inst.file != null) checkInSkillPath('`detect.installed.file`', inst.file, !isStub);
|
|
203
|
+
}
|
|
204
|
+
const dep = detect.deployed;
|
|
205
|
+
if (dep != null && !isPlainObject(dep)) {
|
|
206
|
+
errors.push('`detect.deployed` must be an object');
|
|
207
|
+
} else if (dep != null && dep.file != null) {
|
|
208
|
+
if (typeof dep.file !== 'string') errors.push('`detect.deployed.file` must be a string');
|
|
209
|
+
else {
|
|
210
|
+
if (isAbsoluteLike(dep.file)) errors.push('`detect.deployed.file` must not be an absolute path');
|
|
211
|
+
if (dep.file.startsWith('~')) errors.push('`detect.deployed.file` must be project-relative, not home-relative');
|
|
212
|
+
if (hasTraversal(dep.file)) errors.push('`detect.deployed.file` must not contain ".."');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const [key, role] of Object.entries(roles)) {
|
|
219
|
+
if (role == null || typeof role !== 'object' || Array.isArray(role)) {
|
|
220
|
+
errors.push(`role "${key}" must be an object`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (typeof role.cmd !== 'string' || !role.cmd) errors.push(`role "${key}".cmd must be a non-empty string (the PATH name)`);
|
|
224
|
+
if (role.source == null) errors.push(`role "${key}".source is required (the in-skill script; cmd is the PATH name, source is validated)`);
|
|
225
|
+
else checkInSkillPath(`role "${key}".source`, role.source, !isStub);
|
|
226
|
+
if (role.template != null) checkInSkillPath(`role "${key}".template`, role.template, !isStub);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!isStub) {
|
|
230
|
+
const auth = readAuthoritativeVersion(skillDir);
|
|
231
|
+
if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
|
|
232
|
+
else if (typeof manifest.version === 'string' && manifest.version !== auth.version) {
|
|
233
|
+
errors.push(`\`version\` "${manifest.version}" != ${auth.from} "${auth.version}"`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
result: errors.length ? INVALID : VALID,
|
|
239
|
+
name: manifest.name,
|
|
240
|
+
kind: manifest.kind,
|
|
241
|
+
available: manifest.available !== false, // false only when explicitly declared a stub
|
|
242
|
+
errors,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const main = (argv) => {
|
|
247
|
+
const strict = argv.includes('--strict');
|
|
248
|
+
const dirs = argv.filter((a) => a !== '--strict');
|
|
249
|
+
if (dirs.length === 0) {
|
|
250
|
+
console.error('usage: validate.mjs [--strict] <skill-dir>...');
|
|
251
|
+
process.exit(2);
|
|
252
|
+
}
|
|
253
|
+
let notValid = 0;
|
|
254
|
+
for (const dir of dirs) {
|
|
255
|
+
const report = validateManifest(resolve(dir));
|
|
256
|
+
console.log(`[${report.result.toUpperCase()}] ${dir}${report.name ? ` (${report.name})` : ''}`);
|
|
257
|
+
for (const err of report.errors) console.log(` - ${err}`);
|
|
258
|
+
if (report.result !== VALID) notValid += 1;
|
|
259
|
+
}
|
|
260
|
+
if (strict && notValid > 0) process.exit(1);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
264
|
+
if (isDirectRun) main(process.argv.slice(2));
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { validateManifest, VALID, UNSUPPORTED, INVALID } from './validate.mjs';
|
|
6
|
+
|
|
7
|
+
const FIX = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
8
|
+
const at = (name) => join(FIX, name);
|
|
9
|
+
|
|
10
|
+
describe('validateManifest — result classes', () => {
|
|
11
|
+
it('valid fixture → valid', () => {
|
|
12
|
+
const r = validateManifest(at('valid'));
|
|
13
|
+
assert.equal(r.result, VALID, r.errors.join('; '));
|
|
14
|
+
assert.deepEqual(r.errors, []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('available:false stub → valid (version + fs existence checks skipped)', () => {
|
|
18
|
+
const r = validateManifest(at('stub'));
|
|
19
|
+
assert.equal(r.result, VALID, r.errors.join('; '));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('unknown schema → unsupported (distinct from invalid)', () => {
|
|
23
|
+
const r = validateManifest(at('unknown-schema'));
|
|
24
|
+
assert.equal(r.result, UNSUPPORTED);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('a non-object root (JSON null) → invalid, not a crash', () => {
|
|
28
|
+
const r = validateManifest(at('null-root'));
|
|
29
|
+
assert.equal(r.result, INVALID);
|
|
30
|
+
assert.ok(r.errors.some((e) => /must be a JSON object/.test(e)));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('reads metadata.version, not a stray top-level version: → valid', () => {
|
|
34
|
+
const r = validateManifest(at('metadata-version'));
|
|
35
|
+
assert.equal(r.result, VALID, r.errors.join('; '));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads the DIRECT metadata.version, ignoring a deeper nested version: → valid', () => {
|
|
39
|
+
const r = validateManifest(at('nested-version-decoy'));
|
|
40
|
+
assert.equal(r.result, VALID, r.errors.join('; '));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('validateManifest — negative fixtures MUST fail (strict)', () => {
|
|
45
|
+
const mustFail = [
|
|
46
|
+
['malformed-json', /malformed JSON/],
|
|
47
|
+
['missing-key', /`name` must be a non-empty string/],
|
|
48
|
+
['provides-roles-mismatch', /missing from `provides`/],
|
|
49
|
+
['version-mismatch', /`version` "2\.0\.0" != /],
|
|
50
|
+
['missing-source', /not found in the skill dir/],
|
|
51
|
+
['detect-array', /`detect\.installed` must be an object/],
|
|
52
|
+
['win-absolute-source', /must not be an absolute path/],
|
|
53
|
+
['traversal-source', /must not contain "\.\." traversal/],
|
|
54
|
+
['bad-available', /`available`, if present, must be a boolean/],
|
|
55
|
+
];
|
|
56
|
+
for (const [name, pattern] of mustFail) {
|
|
57
|
+
it(`${name} → invalid`, () => {
|
|
58
|
+
const r = validateManifest(at(name));
|
|
59
|
+
assert.equal(r.result, INVALID);
|
|
60
|
+
assert.ok(
|
|
61
|
+
r.errors.some((e) => pattern.test(e)),
|
|
62
|
+
`expected an error matching ${pattern} in: ${r.errors.join(' | ')}`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('validateManifest — path-field hardening', () => {
|
|
69
|
+
it('valid fixture allows a home-relative detect.installed.default (~) but rejects nothing else', () => {
|
|
70
|
+
const r = validateManifest(at('valid'));
|
|
71
|
+
assert.ok(!r.errors.some((e) => /default/.test(e)));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project's **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5.
|