simt-dev 0.2.1 → 0.2.2
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 +202 -0
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/package.json +1 -1
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +19 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +15 -1
- package/src/commands/index.mjs +43 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/row.mjs +73 -2
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +288 -1
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/projection/manifest.mjs +88 -0
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +288 -20
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +37 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// simt-dev tokens - what a reader loads, counted.
|
|
2
|
+
//
|
|
3
|
+
// Review 0002 S38 put the estate's always-on context at 90 KB per turn, 72% of it a
|
|
4
|
+
// paragraph an earlier root had already loaded, and could only say so by reading seven
|
|
5
|
+
// repositories by hand. This command is that reading, and it is the figure plan 0003's
|
|
6
|
+
// later slices are measured against: the context budget (S38 P7), the kernel split
|
|
7
|
+
// (R07) and the workspace dedupe (R11) all need a number that moves when the
|
|
8
|
+
// projection moves, or they are opinions.
|
|
9
|
+
//
|
|
10
|
+
// Three decisions keep the figure honest:
|
|
11
|
+
//
|
|
12
|
+
// 1. It is a READ. No write, no git object, no lock - the same ruling `compare`
|
|
13
|
+
// follows. An agent running this to orient itself must not be able to change what
|
|
14
|
+
// the next run will measure.
|
|
15
|
+
// 2. It exits 0. A measurement is not a verdict: a repository over budget is a fact
|
|
16
|
+
// about the repository, and a command that went red for it would train people to
|
|
17
|
+
// ignore the number it exists to print. The only failure is being pointed at a
|
|
18
|
+
// directory that holds no repository to measure.
|
|
19
|
+
// 3. It counts bytes and calls the tokens an approximation every time it prints one.
|
|
20
|
+
// See src/measure/context.mjs for what that costs and why.
|
|
21
|
+
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
25
|
+
import { CLI } from '../name.mjs';
|
|
26
|
+
import { discover } from '../compare/discover.mjs';
|
|
27
|
+
import { targetRoot } from '../workspace.mjs';
|
|
28
|
+
import {
|
|
29
|
+
ALWAYS_ON_BUDGET_BYTES,
|
|
30
|
+
BYTES_PER_TOKEN,
|
|
31
|
+
annotate,
|
|
32
|
+
measureRoot,
|
|
33
|
+
mcpTools,
|
|
34
|
+
totals,
|
|
35
|
+
} from '../measure/context.mjs';
|
|
36
|
+
|
|
37
|
+
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
38
|
+
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
39
|
+
function plain(n) {
|
|
40
|
+
return String(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function files(n) {
|
|
44
|
+
return n === 1 ? '1 file' : `${plain(n)} files`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function tokensCommand(positional, flags) {
|
|
48
|
+
// `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
|
|
49
|
+
// disagree about which directory was named (ADR-014).
|
|
50
|
+
const target = await targetRoot(flags, positional);
|
|
51
|
+
const root = resolve(target.root);
|
|
52
|
+
|
|
53
|
+
const found = await discover(root);
|
|
54
|
+
if (flags.repos) {
|
|
55
|
+
if (found.error) {
|
|
56
|
+
return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
|
|
57
|
+
}
|
|
58
|
+
if (found.repos.length === 0) {
|
|
59
|
+
return fail(
|
|
60
|
+
`${root} contains no git repositories`,
|
|
61
|
+
EXIT.CONFIG,
|
|
62
|
+
`point --root at the directory holding the repositories, or at one repository without --repos`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// `discover` sorts, and the order decides which root owns a shared paragraph. A
|
|
66
|
+
// second sort here would move duplicate bytes between roots and make the workspace
|
|
67
|
+
// figure irreproducible.
|
|
68
|
+
} else if (found.shape === 'workspace') {
|
|
69
|
+
return fail(
|
|
70
|
+
`${root} holds repositories rather than being one`,
|
|
71
|
+
EXIT.CONFIG,
|
|
72
|
+
`measure one with: ${CLI} tokens --root <path>, or every repository under it with: ${CLI} tokens --repos --root ${root}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const roots = flags.repos ? found.repos : [root];
|
|
76
|
+
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const path of roots) rows.push(await measureRoot(path));
|
|
79
|
+
annotate(rows);
|
|
80
|
+
|
|
81
|
+
const summary = totals(rows);
|
|
82
|
+
const mcp = mcpTools();
|
|
83
|
+
|
|
84
|
+
if (isJson(flags)) {
|
|
85
|
+
emitJson({
|
|
86
|
+
ok: true,
|
|
87
|
+
command: 'tokens',
|
|
88
|
+
root,
|
|
89
|
+
mode: flags.repos ? 'repositories' : 'repository',
|
|
90
|
+
source: target.source,
|
|
91
|
+
budget: { bytes: ALWAYS_ON_BUDGET_BYTES, bytesPerToken: BYTES_PER_TOKEN },
|
|
92
|
+
mcp,
|
|
93
|
+
roots: rows.map(({ documents, ...row }) => row),
|
|
94
|
+
totals: summary,
|
|
95
|
+
});
|
|
96
|
+
return EXIT.OK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lines = [];
|
|
100
|
+
if (flags.repos) {
|
|
101
|
+
lines.push(`tokens ${root} ${plain(summary.roots)} repositories (${target.source})`);
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const budget = row.budget.over ? ` over budget by ${plain(row.budget.overBy)} B` : '';
|
|
104
|
+
lines.push(
|
|
105
|
+
`${row.name} generated ${plain(row.generatedBytes)} B duplicate ${plain(row.redundancy.duplicateBytes)} B unique ${plain(row.redundancy.uniqueBytes)} B${budget}`,
|
|
106
|
+
);
|
|
107
|
+
for (const finding of row.findings) lines.push(`note ${row.name}: ${finding}`);
|
|
108
|
+
}
|
|
109
|
+
const share =
|
|
110
|
+
summary.generatedBytes === 0 ? 0 : Math.round((summary.duplicateBytes / summary.generatedBytes) * 100);
|
|
111
|
+
lines.push(
|
|
112
|
+
`totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
const row = rows[0];
|
|
116
|
+
lines.push(`tokens ${row.path}`);
|
|
117
|
+
lines.push(`state ${row.adoption} surfaces ${row.surfaces.length === 0 ? '(AGENTS.md only)' : row.surfaces.join(', ')}`);
|
|
118
|
+
for (const channel of row.channels) {
|
|
119
|
+
lines.push(
|
|
120
|
+
`${channel.channel.padEnd(9)}${files(channel.files)} ${plain(channel.generatedBytes)} B generated ${plain(channel.readerBytes)} B read`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const verdict = row.budget.over ? `over by ${plain(row.budget.overBy)} B` : 'within budget';
|
|
124
|
+
lines.push(
|
|
125
|
+
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
126
|
+
);
|
|
127
|
+
lines.push(
|
|
128
|
+
`redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
|
|
129
|
+
);
|
|
130
|
+
for (const finding of row.findings) lines.push(`note ${finding}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(
|
|
133
|
+
`mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
137
|
+
return EXIT.OK;
|
|
138
|
+
}
|
package/src/compare/row.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
|
|
20
20
|
import { manifestDrift, readManifest } from '../projection/manifest.mjs';
|
|
21
21
|
import { loadRules, select } from '../projection/rules.mjs';
|
|
22
|
+
import { resolvePacks } from '../packs.mjs';
|
|
22
23
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
23
24
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
25
|
import { readHooksState, wantsShim } from './hooksstate.mjs';
|
|
@@ -34,6 +35,10 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
34
35
|
* @property {string[]} stack
|
|
35
36
|
* @property {string[]} facts
|
|
36
37
|
* @property {string[]} profiles profile ids that would be selected
|
|
38
|
+
* @property {{path: string, profiles: string[], status: string, detail: string|null}[]} packages
|
|
39
|
+
* one nested region per workspace package, sorted by path
|
|
40
|
+
* @property {{path: string, profiles: string[], status: string, detail: string|null}[]} packageProblems
|
|
41
|
+
* the nested regions that are not `current`; empty for an unadopted repository
|
|
37
42
|
* @property {string|null} pinnedSha
|
|
38
43
|
* @property {string|null} recordedSha
|
|
39
44
|
* @property {'current'|'drift'|'unmanaged'|'absent'|'malformed'} region
|
|
@@ -42,6 +47,8 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
42
47
|
* @property {boolean} hooksComplete every event installed, executable, not stale
|
|
43
48
|
* @property {string[]} gatesDeclared
|
|
44
49
|
* @property {string[]} gateProblems
|
|
50
|
+
* @property {{name: string, source: string|null, version: string|null, digest: string|null, ok: boolean}[]} packs
|
|
51
|
+
* the shared rule sets this repo declares, with the digest it has pinned for each
|
|
45
52
|
* @property {string[]} problems everything wrong, one line each; drives the exit code
|
|
46
53
|
* @property {string[]} notes worth knowing, but not wrong; does NOT drive the exit code
|
|
47
54
|
*/
|
|
@@ -55,8 +62,19 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
55
62
|
* @param {{expectedSha: string, gates: Set<string>}} context
|
|
56
63
|
* @returns {Promise<RepoRow>}
|
|
57
64
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A nested-region finding in the row's own vocabulary.
|
|
67
|
+
*
|
|
68
|
+
* `checkTargets` speaks in `file` because a finding is printed with the path it names;
|
|
69
|
+
* a row field is read by key, and a reader looking for `packages[].path` should not
|
|
70
|
+
* have to know that the finding underneath calls the same thing `file`. One mapper, so
|
|
71
|
+
* the two arrays cannot drift into different shapes.
|
|
72
|
+
*/
|
|
73
|
+
function packageEntry(f) {
|
|
74
|
+
return { path: f.file, profiles: f.profiles ?? [], status: f.status, detail: f.detail ?? null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function buildRow(path, context) { const name = path.split('/').filter(Boolean).pop() ?? path;
|
|
60
78
|
const problems = [];
|
|
61
79
|
const notes = [];
|
|
62
80
|
|
|
@@ -84,6 +102,12 @@ export async function buildRow(path, context) {
|
|
|
84
102
|
// state a rule written to four surfaces can drift into.
|
|
85
103
|
const region = findings.find((f) => f.file === PRIMARY_TARGET) ?? { status: 'absent' };
|
|
86
104
|
const ruleFindings = findings.filter((f) => f.surface !== undefined);
|
|
105
|
+
// A nested region is identified by its path, not by a field: `checkTargets` builds
|
|
106
|
+
// those findings from `checkRegion`, which knows nothing about monorepos, and the
|
|
107
|
+
// one thing this row needs from them is "which file, what state".
|
|
108
|
+
const packageFindings = findings
|
|
109
|
+
.filter((f) => f.file !== undefined && f.file.endsWith(`/${PRIMARY_TARGET}`))
|
|
110
|
+
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
87
111
|
const pointers = findings.filter((f) => f.status?.startsWith('pointer'));
|
|
88
112
|
|
|
89
113
|
// Adoption is settled BEFORE any problem is collected. Every gate below asks
|
|
@@ -105,6 +129,13 @@ export async function buildRow(path, context) {
|
|
|
105
129
|
// prose above it was still true.
|
|
106
130
|
const { selected: chosen } = select(await loadRules(path), fp.stack ?? [], fp.facts ?? []);
|
|
107
131
|
|
|
132
|
+
// The packs this repo shares with its siblings, with the digest it has pinned.
|
|
133
|
+
// A dashboard that reported `current: 14` while half their rules lived in a
|
|
134
|
+
// directory that was not there described an estate nobody had. The digest is
|
|
135
|
+
// the per-repo answer to "which bytes were these", which is what P4 asks for and
|
|
136
|
+
// what a reviewer comparing two repositories actually wants to see.
|
|
137
|
+
const { packs, findings: packFindings } = await resolvePacks(path);
|
|
138
|
+
|
|
108
139
|
const row = {
|
|
109
140
|
name,
|
|
110
141
|
path,
|
|
@@ -119,6 +150,12 @@ export async function buildRow(path, context) {
|
|
|
119
150
|
region: region.status,
|
|
120
151
|
regionDetail: region.detail ?? null,
|
|
121
152
|
surfaces: manifest.manifest?.surfaces ?? null,
|
|
153
|
+
// One entry per nested region, for the same reason `ruleFiles` is its own object:
|
|
154
|
+
// a monorepo can have a current root region and one stale package region, and a
|
|
155
|
+
// single `region` field cannot say which. Sorted by path, because `checkTargets`
|
|
156
|
+
// returns findings in the order the detector found packages and a dashboard that
|
|
157
|
+
// reorders rows between runs is a dashboard nobody diffs.
|
|
158
|
+
packages: packageFindings.map(packageEntry),
|
|
122
159
|
ruleFiles: {
|
|
123
160
|
total: ruleFindings.filter((f) => f.status !== 'orphaned').length,
|
|
124
161
|
current: ruleFindings.filter((f) => f.status === 'current').length,
|
|
@@ -130,6 +167,19 @@ export async function buildRow(path, context) {
|
|
|
130
167
|
orphaned: ruleFindings.filter((f) => f.status === 'orphaned').map((f) => f.file),
|
|
131
168
|
},
|
|
132
169
|
pointers: pointers.map((p) => ({ file: p.file, state: p.status })),
|
|
170
|
+
// A nested region that is not `current` is a problem for the same reason a stale
|
|
171
|
+
// rule file is: `check` exits 1 on it, and a row that reported the repository as
|
|
172
|
+
// current anyway is ADR-045's defect one level down. Gated on adoption, like every
|
|
173
|
+
// other gate here — an unadopted repo has no root region and therefore no nested
|
|
174
|
+
// findings to report, which is not a second defect for the same missing adoption.
|
|
175
|
+
packageProblems: adoption === 'adopted' ? packageFindings.filter((f) => f.status !== 'current').map(packageEntry) : [],
|
|
176
|
+
packs: packs.map((p) => ({
|
|
177
|
+
name: p.name,
|
|
178
|
+
source: p.source,
|
|
179
|
+
version: p.version,
|
|
180
|
+
digest: p.declared || null,
|
|
181
|
+
ok: p.ok,
|
|
182
|
+
})),
|
|
133
183
|
hooksRouted: hooks.routed,
|
|
134
184
|
hooksComplete: hooks.complete,
|
|
135
185
|
gatesDeclared: declaredGates(manifest.manifest),
|
|
@@ -163,6 +213,22 @@ export async function buildRow(path, context) {
|
|
|
163
213
|
problems.push(`${MANIFEST_FILE} ${manifest.error}`);
|
|
164
214
|
}
|
|
165
215
|
|
|
216
|
+
// Stale nested regions, named by path, with the remedy the finding already carries.
|
|
217
|
+
// A monorepo with four packages and one drifted region must not report `current`.
|
|
218
|
+
for (const f of row.packageProblems) {
|
|
219
|
+
problems.push(`${f.path}: ${f.detail ?? 'the package region is not current'}; run: ${CLI} sync --yes`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// `pack-unpinned` is a note here for the same reason it is non-blocking in
|
|
223
|
+
// `check`: the rules render, and only the record of which bytes they were is
|
|
224
|
+
// absent. A missing or mismatched pack is a problem, because that row would
|
|
225
|
+
// otherwise report a repository `current` while projecting fewer rules than the
|
|
226
|
+
// manifest declares - the defect ADR-045 was about, reached a different way.
|
|
227
|
+
for (const f of packFindings) {
|
|
228
|
+
if (f.status === 'pack-unpinned') notes.push(`${f.file}: ${f.detail}; ${f.remedy}`);
|
|
229
|
+
else problems.push(`${f.file}: ${f.detail}; ${f.remedy}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
166
232
|
// A generated field of the manifest is stale, and `check` refuses on it. This row
|
|
167
233
|
// said the repository was `current` anyway, in all fourteen adopted repositories at
|
|
168
234
|
// once, so a dashboard that reported `current: 14, problems: 0` was describing a
|
|
@@ -318,6 +384,11 @@ export function summarise(rows, extra = {}) {
|
|
|
318
384
|
malformed: count((r) => r.region === 'malformed'),
|
|
319
385
|
hooksRouted: count((r) => r.hooksRouted),
|
|
320
386
|
staleRuleFiles: rows.reduce((n, r) => n + (r.ruleFiles?.stale?.length ?? 0), 0),
|
|
387
|
+
// Counted here and not derived from `withProblems`: one monorepo with three drifted
|
|
388
|
+
// packages is one repository with three findings, and a reader asking "how much is
|
|
389
|
+
// stale" wants 3, not 1. `withProblems` still counts the repository once.
|
|
390
|
+
stalePackages: rows.reduce((n, r) => n + (r.packageProblems?.length ?? 0), 0),
|
|
391
|
+
packageRegions: rows.reduce((n, r) => n + (r.packages?.length ?? 0), 0),
|
|
321
392
|
withProblems: count((r) => r.problems.length > 0),
|
|
322
393
|
withOwnHooks: count((r) => (r.foreignHooks ?? []).length > 0),
|
|
323
394
|
shas: [...shas.entries()].sort((a, b) => (a[0] === '(none)' ? 1 : b[0] === '(none)' ? -1 : b[1] - a[1])),
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Agent Skills conformance, checked against the specification.
|
|
2
|
+
//
|
|
3
|
+
// Every rule here was read from agentskills.io rather than recalled, for the reason
|
|
4
|
+
// ADR-067 records: a lint that checks the wrong rule is worse than no lint, because it
|
|
5
|
+
// reports a clean file that a client will reject.
|
|
6
|
+
//
|
|
7
|
+
// The distinction the file draws is the one the specification draws. A "must" is an error
|
|
8
|
+
// and stops the exit code; a "should" or a recommendation is a warning and does not. An
|
|
9
|
+
// error the reader cannot act on is a papercut that trains people to skip the gate, so
|
|
10
|
+
// every message names the rule it broke and the fix.
|
|
11
|
+
|
|
12
|
+
/** The one specification this knows, and the name `--spec` accepts. */
|
|
13
|
+
export const SPEC = 'agentskills';
|
|
14
|
+
|
|
15
|
+
export const SPEC_SOURCE = 'agentskills.io/specification';
|
|
16
|
+
|
|
17
|
+
/** Fields the specification defines. Anything else in the frontmatter is unknown. */
|
|
18
|
+
const SPEC_FIELDS = ['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'];
|
|
19
|
+
|
|
20
|
+
/** Tokens, not bytes, because the specification counts tokens and every number here is
|
|
21
|
+
* the same four-bytes-per-token estimate the context meter uses. */
|
|
22
|
+
const BODY_TOKEN_BUDGET = 5000;
|
|
23
|
+
const BODY_LINE_BUDGET = 500;
|
|
24
|
+
const BYTES_PER_TOKEN = 4;
|
|
25
|
+
|
|
26
|
+
/** The `name` rules, as one predicate with the rule it enforces. */
|
|
27
|
+
const NAME_RULES = {
|
|
28
|
+
max: 64,
|
|
29
|
+
// Lowercase alphanumerics and hyphens only. Unicode lowercase is deliberately NOT
|
|
30
|
+
// allowed even though the specification says "unicode lowercase alphanumeric": a
|
|
31
|
+
// skill directory travels between machines and shells, and the subset every client
|
|
32
|
+
// accepts is the ASCII one. Reported as the ASCII rule because that is the one a
|
|
33
|
+
// reader can fix without reading a Unicode table.
|
|
34
|
+
pattern: /^[a-z0-9-]+$/,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first naming rule `name` breaks, or null when it breaks none.
|
|
39
|
+
*
|
|
40
|
+
* Exported because two commands need the same answer: the lint, which reports it, and
|
|
41
|
+
* `skills install`, which refuses to create a directory a client could never address by
|
|
42
|
+
* the name inside it. Two copies of these rules would be two sets, and the one nobody
|
|
43
|
+
* linted would be the one that let an unaddressable skill through.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @returns {string|null} the rule, phrased as a defect
|
|
47
|
+
*/
|
|
48
|
+
export function nameProblems(name) {
|
|
49
|
+
if (name.length === 0) return 'is empty; the specification requires 1 to 64 characters';
|
|
50
|
+
if (name.length > NAME_RULES.max) {
|
|
51
|
+
return `is ${name.length} characters; the specification allows at most ${NAME_RULES.max}`;
|
|
52
|
+
}
|
|
53
|
+
if (!NAME_RULES.pattern.test(name)) return `is not lowercase letters, digits and hyphens`;
|
|
54
|
+
if (name.startsWith('-') || name.endsWith('-')) return `starts or ends with a hyphen`;
|
|
55
|
+
if (name.includes('--')) return `contains consecutive hyphens`;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check one skill against the specification.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} input
|
|
63
|
+
* @param {string} input.directoryName the directory the skill lives in
|
|
64
|
+
* @param {string} [input.fileName] the file the frontmatter came from
|
|
65
|
+
* @param {string} input.text the whole SKILL.md
|
|
66
|
+
* @param {string} input.at how the reader names this skill in a finding
|
|
67
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
68
|
+
*/
|
|
69
|
+
export function checkSkill({ directoryName, fileName = 'SKILL.md', text, at }) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const fail = (detail) => errors.push(`${at}: ${detail}`);
|
|
73
|
+
const warn = (detail) => warnings.push(`${at}: ${detail}`);
|
|
74
|
+
|
|
75
|
+
if (fileName !== 'SKILL.md') {
|
|
76
|
+
// The layout is part of the specification, not a naming preference: a client looks
|
|
77
|
+
// for that file by name. ADR-009's flat `skills/<name>.md` form is the case here,
|
|
78
|
+
// and it is reported rather than accepted.
|
|
79
|
+
fail(`the specification requires the file to be named SKILL.md; this skill is a single ${fileName}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { meta, body, present } = readFrontmatter(text);
|
|
83
|
+
|
|
84
|
+
// --- name -------------------------------------------------------------------
|
|
85
|
+
const name = meta.name ?? '';
|
|
86
|
+
if (name.length === 0) {
|
|
87
|
+
fail('frontmatter has no `name`, which the specification requires');
|
|
88
|
+
} else {
|
|
89
|
+
const broken = nameProblems(name);
|
|
90
|
+
if (broken !== null) fail(`name "${name}" ${broken}`);
|
|
91
|
+
// The rule the review did not list and the one that breaks installs: a directory
|
|
92
|
+
// and a name that disagree cannot both be addressed, and the client that resolves
|
|
93
|
+
// by name never finds the directory it was given.
|
|
94
|
+
if (directoryName !== undefined && name !== directoryName) {
|
|
95
|
+
fail(`name "${name}" does not match its directory "${directoryName}"; the specification requires them to be equal`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- description ------------------------------------------------------------
|
|
100
|
+
const description = meta.description ?? '';
|
|
101
|
+
if (description.length === 0) {
|
|
102
|
+
fail('frontmatter has no `description`, which the specification requires');
|
|
103
|
+
} else if (description.length > 1024) {
|
|
104
|
+
fail(`description is ${description.length} characters; the specification allows at most 1024`);
|
|
105
|
+
} else if (description.length < 30) {
|
|
106
|
+
// A recommendation, not a rule: the specification asks for what the skill does and
|
|
107
|
+
// when to use it, and a client matching on keywords finds nothing in three words.
|
|
108
|
+
warn(`description is ${description.length} characters; it should say what the skill does AND when to use it`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- compatibility ----------------------------------------------------------
|
|
112
|
+
const compatibility = meta.compatibility;
|
|
113
|
+
if (compatibility !== undefined && String(compatibility).length > 500) {
|
|
114
|
+
fail(`compatibility is ${String(compatibility).length} characters; the specification allows at most 500`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- metadata ---------------------------------------------------------------
|
|
118
|
+
// Stated as a map from string keys to string values, but not as a hard requirement,
|
|
119
|
+
// so a value of another type is a warning. A client that reads it strictly will drop
|
|
120
|
+
// the field, and the reader deserves to know before a client does.
|
|
121
|
+
for (const [key, value] of Object.entries(meta.metadata ?? {})) {
|
|
122
|
+
if (typeof value !== 'string') {
|
|
123
|
+
warn(`metadata.${key} is ${typeof value}, not a string; the specification describes a map of string keys to string values`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- unknown fields ---------------------------------------------------------
|
|
128
|
+
for (const key of present) {
|
|
129
|
+
if (!SPEC_FIELDS.includes(key)) {
|
|
130
|
+
warn(`frontmatter carries "${key}", which the specification does not define`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- body -------------------------------------------------------------------
|
|
135
|
+
const bytes = Buffer.byteLength(text);
|
|
136
|
+
const tokens = Math.ceil(bytes / BYTES_PER_TOKEN);
|
|
137
|
+
if (tokens > BODY_TOKEN_BUDGET) {
|
|
138
|
+
warn(`body is ~${tokens} tokens (${bytes} bytes); the specification recommends under ${BODY_TOKEN_BUDGET}, with detail in reference files`);
|
|
139
|
+
}
|
|
140
|
+
const lines = body.split('\n').length;
|
|
141
|
+
if (lines > BODY_LINE_BUDGET) {
|
|
142
|
+
warn(`body is ${lines} lines; the specification recommends keeping SKILL.md under ${BODY_LINE_BUDGET}`);
|
|
143
|
+
}
|
|
144
|
+
// The three progressive-disclosure numbers, reported as one line so a reader sees
|
|
145
|
+
// the cost before the fix rather than one warning at a time.
|
|
146
|
+
if (tokens > BODY_TOKEN_BUDGET) {
|
|
147
|
+
const perHundred = Math.round(tokens / (lines / 100));
|
|
148
|
+
warn(`~${perHundred} tokens per 100 lines: the agent loads all of this once the skill activates, and reference files load only when asked`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { errors, warnings };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Frontmatter as flat scalars, folded block scalars, plus a nested `metadata` map.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately its own reader rather than the two that live in the projection: those
|
|
158
|
+
* strip the block and keep the values this file never looks at, and one of them would
|
|
159
|
+
* have to grow a block-scalar branch for a second caller.
|
|
160
|
+
*
|
|
161
|
+
* The block scalar is here because it is not optional: `description: >-` followed by
|
|
162
|
+
* indented lines is the spelling most real skills use for a long description, and a
|
|
163
|
+
* reader that took `>-` as the value reported a 200-character description as two
|
|
164
|
+
* characters. That is the whole failure this file exists to prevent, one level down - a
|
|
165
|
+
* lint that is confidently wrong about a conforming file.
|
|
166
|
+
*/
|
|
167
|
+
function readFrontmatter(text) {
|
|
168
|
+
const meta = {};
|
|
169
|
+
const present = [];
|
|
170
|
+
const body = text.replace(/^/, '');
|
|
171
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(body);
|
|
172
|
+
if (!match) return { meta, present, body };
|
|
173
|
+
const lines = match[1].split(/\r?\n/);
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
176
|
+
const line = lines[i];
|
|
177
|
+
if (/^\s/.test(line)) continue; // Consumed by the block it belongs to, or metadata.
|
|
178
|
+
const field = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
|
|
179
|
+
if (!field) continue;
|
|
180
|
+
const [, key, raw] = field;
|
|
181
|
+
present.push(key);
|
|
182
|
+
|
|
183
|
+
const block = /^([|>])([-+]?)\s*$/.exec(raw.trim());
|
|
184
|
+
if (block !== null) {
|
|
185
|
+
const folded = block[1] === '>';
|
|
186
|
+
const collected = [];
|
|
187
|
+
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) {
|
|
188
|
+
const next = lines[i + 1];
|
|
189
|
+
if (next.trim() === '' && !/^\s/.test(next)) break;
|
|
190
|
+
collected.push(next.trim());
|
|
191
|
+
i += 1;
|
|
192
|
+
if (i + 1 >= lines.length) break;
|
|
193
|
+
if (!/^\s/.test(lines[i + 1]) && lines[i + 1].trim() !== '') break;
|
|
194
|
+
}
|
|
195
|
+
// A folded scalar joins its lines with spaces; a literal one keeps the newlines.
|
|
196
|
+
// Either way the blank lines a paragraph break introduced become a single space,
|
|
197
|
+
// because the count that matters here is characters, not layout.
|
|
198
|
+
meta[key] = collected.join(folded ? ' ' : '\n').replace(/\s+/g, folded ? ' ' : '\n').trim();
|
|
199
|
+
if (key === 'metadata') meta.metadata = {};
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (raw.trim() === '') {
|
|
204
|
+
meta[key] = '';
|
|
205
|
+
if (key === 'metadata') {
|
|
206
|
+
meta.metadata = {};
|
|
207
|
+
// An indented `key: value` block belongs to it.
|
|
208
|
+
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
|
|
209
|
+
const nested = /^\s+([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(lines[i + 1]);
|
|
210
|
+
if (nested && key === 'metadata') meta.metadata[nested[1]] = scalar(nested[2]);
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
meta[key] = scalar(raw);
|
|
217
|
+
}
|
|
218
|
+
return { meta, present, body: body.slice(match[0].length) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Strip the quoting and the trailing comment a scalar may carry. */
|
|
222
|
+
function scalar(raw) {
|
|
223
|
+
const value = raw.trim().replace(/\s+#.*$/, '').trim();
|
|
224
|
+
if (value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
|
|
225
|
+
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Walking the registry to the files on disk, so the conformance checker sees text.
|
|
2
|
+
//
|
|
3
|
+
// `graph lint` already resolves every skill to a path and measures it; this module does
|
|
4
|
+
// the same walk for a different reason - it needs the bytes, not the count - and it is
|
|
5
|
+
// separate rather than folded into that pass because one of the two questions may run
|
|
6
|
+
// without the other.
|
|
7
|
+
|
|
8
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { checkSkill } from './spec.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check every skill the registry names against the Agent Skills specification.
|
|
15
|
+
*
|
|
16
|
+
* Only the canonical source is checked, because a skill with two bodies would give two
|
|
17
|
+
* answers to one question and the reader would have to know which is which. A skill
|
|
18
|
+
* whose body cannot be read is a warning and not an error: the registry pass already
|
|
19
|
+
* reports a path that does not exist as an error, and saying it twice with two exit
|
|
20
|
+
* paths is the "two commands, two answers" defect this repository exists to end.
|
|
21
|
+
*
|
|
22
|
+
* @param {{skills: object[]}} registry
|
|
23
|
+
* @param {string} workspaceRoot
|
|
24
|
+
* @returns {Promise<{errors: string[], warnings: string[], checked: number}>}
|
|
25
|
+
*/
|
|
26
|
+
export async function lintSpec(registry, workspaceRoot) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
const warnings = [];
|
|
29
|
+
let checked = 0;
|
|
30
|
+
|
|
31
|
+
for (const skill of registry.skills ?? []) {
|
|
32
|
+
const source = (skill.sources ?? []).find((s) => s.role === 'canonical');
|
|
33
|
+
if (!source?.repo || !source?.path) continue;
|
|
34
|
+
const at = `${skill.name}: ${source.repo}/${source.path}`;
|
|
35
|
+
const full = join(workspaceRoot, source.repo, source.path);
|
|
36
|
+
|
|
37
|
+
let info;
|
|
38
|
+
try {
|
|
39
|
+
info = await stat(full);
|
|
40
|
+
} catch {
|
|
41
|
+
warnings.push(`${at}: the conformance pass could not read it; the registry pass above reports the path`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A skill directory holds its instructions in SKILL.md. A single file is the flat
|
|
46
|
+
// form ADR-009 says will be wrapped: it is still checked, because its frontmatter
|
|
47
|
+
// breaks the same rules, and the file name is reported as one of them.
|
|
48
|
+
const file = info.isDirectory() ? join(full, 'SKILL.md') : full;
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(file, 'utf8');
|
|
52
|
+
} catch {
|
|
53
|
+
warnings.push(`${at}: no SKILL.md in the directory, so there is nothing to check`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const report = checkSkill({
|
|
58
|
+
directoryName: info.isDirectory() ? basename(full) : undefined,
|
|
59
|
+
fileName: basename(file),
|
|
60
|
+
text,
|
|
61
|
+
at,
|
|
62
|
+
});
|
|
63
|
+
errors.push(...report.errors);
|
|
64
|
+
warnings.push(...report.warnings);
|
|
65
|
+
checked += 1;
|
|
66
|
+
}
|
|
67
|
+
return { errors, warnings, checked };
|
|
68
|
+
}
|
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
// its reason, so "passed" can never be mistaken for "only some of it passed".
|
|
20
20
|
|
|
21
21
|
import { execFile } from 'node:child_process';
|
|
22
|
-
import { readFile } from 'node:fs/promises';
|
|
23
|
-
import { basename, join } from 'node:path';
|
|
22
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
23
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
24
24
|
import { promisify } from 'node:util';
|
|
25
25
|
|
|
26
26
|
import { GATES } from './gates.mjs';
|
|
@@ -85,6 +85,12 @@ export async function runGates(event, { root, manifest }) {
|
|
|
85
85
|
const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
|
|
86
86
|
const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
|
|
87
87
|
const commands = manifest?.gates?.commands;
|
|
88
|
+
// `gates.cwd` is a separate map rather than a second shape for `gates.commands`.
|
|
89
|
+
// A monorepo needs the gate to run inside one package, and the reader declaring it
|
|
90
|
+
// already knows the gate by name — putting the directory beside the command would
|
|
91
|
+
// mean re-reading the command string to find out where anything runs, and a manifest
|
|
92
|
+
// written by an older release would have to be rewritten to gain the capability.
|
|
93
|
+
const cwds = manifest?.gates?.cwd ?? {};
|
|
88
94
|
const results = [];
|
|
89
95
|
// What the format gate rewrote, so restage touches those files and nothing else.
|
|
90
96
|
let formatted = [];
|
|
@@ -147,7 +153,33 @@ export async function runGates(event, { root, manifest }) {
|
|
|
147
153
|
formatted = targets;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
// The gate's directory, if it declares one. Resolved against the repo root and
|
|
157
|
+
// checked for containment: a `cwd` that escaped the repository would run a command
|
|
158
|
+
// against files this commit cannot see, and the gate would be green about them.
|
|
159
|
+
// Containment is checked on the RESOLVED path, not the string, because `..` and a
|
|
160
|
+
// symlink both get past a prefix test on the text.
|
|
161
|
+
let cwd = root;
|
|
162
|
+
const declaredCwd = cwds[name];
|
|
163
|
+
if (declaredCwd !== undefined) {
|
|
164
|
+
const resolved = resolve(root, declaredCwd);
|
|
165
|
+
const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
|
|
166
|
+
const exists = inside && (await stat(resolved).catch(() => null))?.isDirectory();
|
|
167
|
+
if (!exists) {
|
|
168
|
+
results.push({
|
|
169
|
+
name,
|
|
170
|
+
status: 'failed',
|
|
171
|
+
detail: inside
|
|
172
|
+
? `gates.cwd["${name}"] is ${declaredCwd}, which is not a directory in this repository`
|
|
173
|
+
: `gates.cwd["${name}"] is ${declaredCwd}, which is outside the repository`,
|
|
174
|
+
remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
|
|
175
|
+
adr: gate.adr,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
cwd = resolved;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const outcome = await runGate(resolution, cwd);
|
|
151
183
|
results.push({
|
|
152
184
|
name,
|
|
153
185
|
status: outcome.code === 0 ? 'passed' : 'failed',
|