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.
Files changed (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  3. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  4. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  5. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  6. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  7. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  8. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  9. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  10. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  11. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  12. package/package.json +1 -1
  13. package/rules/core.md +1 -0
  14. package/rules/stacks/android.md +2 -0
  15. package/rules/stacks/cloudflare-workers.md +2 -0
  16. package/rules/stacks/connectiq.md +1 -0
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/dart.md +2 -0
  19. package/rules/stacks/deno.md +2 -0
  20. package/rules/stacks/dotnet.md +2 -0
  21. package/rules/stacks/elixir.md +2 -0
  22. package/rules/stacks/go.md +2 -0
  23. package/rules/stacks/jvm.md +2 -0
  24. package/rules/stacks/node.md +2 -0
  25. package/rules/stacks/python.md +2 -0
  26. package/rules/stacks/react-native.md +2 -0
  27. package/rules/stacks/react-web.md +2 -0
  28. package/rules/stacks/ruby.md +2 -0
  29. package/rules/stacks/rust.md +2 -0
  30. package/rules/stacks/swift.md +2 -0
  31. package/rules/stacks/typescript.md +2 -0
  32. package/rules/stacks/uv.md +2 -0
  33. package/src/argv.mjs +19 -0
  34. package/src/commands/compare.mjs +15 -0
  35. package/src/commands/graph.mjs +45 -21
  36. package/src/commands/harness.mjs +150 -0
  37. package/src/commands/help.mjs +15 -1
  38. package/src/commands/index.mjs +43 -25
  39. package/src/commands/init.mjs +12 -8
  40. package/src/commands/mcp.mjs +20 -5
  41. package/src/commands/rules.mjs +42 -57
  42. package/src/commands/skills.mjs +67 -92
  43. package/src/commands/skillsinstall.mjs +17 -2
  44. package/src/commands/sync.mjs +62 -6
  45. package/src/commands/tokens.mjs +138 -0
  46. package/src/compare/row.mjs +73 -2
  47. package/src/graph/spec.mjs +227 -0
  48. package/src/graph/speccheck.mjs +68 -0
  49. package/src/hooks/dispatcher.mjs +35 -3
  50. package/src/mcp/schema.mjs +139 -0
  51. package/src/mcp/server.mjs +197 -20
  52. package/src/mcp/tools.mjs +288 -1
  53. package/src/measure/context.mjs +371 -0
  54. package/src/monorepo.mjs +339 -0
  55. package/src/output/index.mjs +78 -2
  56. package/src/packs.mjs +182 -0
  57. package/src/projection/manifest.mjs +88 -0
  58. package/src/projection/project.mjs +140 -9
  59. package/src/projection/rules.mjs +288 -20
  60. package/src/projection/surfaces.mjs +133 -15
  61. 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
+ }
@@ -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
- export async function buildRow(path, context) {
59
- const name = path.split('/').filter(Boolean).pop() ?? path;
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
+ }
@@ -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
- const outcome = await runGate(resolution, root);
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',