simt-dev 0.2.0 → 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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -17,11 +17,12 @@
17
17
  // reordered its columns would make every comparison between two runs unreadable.
18
18
 
19
19
  import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
20
- import { readManifest } from '../projection/manifest.mjs';
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
- import { readHooksState } from './hooksstate.mjs';
25
+ import { readHooksState, wantsShim } from './hooksstate.mjs';
25
26
  import { CLI } from '../name.mjs';
26
27
  import { MANIFEST_FILE } from '../name.mjs';
27
28
 
@@ -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,15 +62,37 @@ 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
 
63
- const [manifest, fp, hooks, findings] = await Promise.all([
64
- readManifest(path),
81
+ // The manifest first, and on its own. The predicate below reads it, and a
82
+ // destructured binding in the same `Promise.all` array is still in its temporal dead
83
+ // zone when the array is evaluated - so this is the same defect ADR-045 records in
84
+ // this file one level up: a gate reading a field the line above has not set yet.
85
+ const manifest = await readManifest(path);
86
+
87
+ const [fp, hooks, findings] = await Promise.all([
65
88
  fingerprint(path),
66
- readHooksState(path),
89
+ // The same predicate `hooks install` writes by. Without it this row defaulted to
90
+ // "a shim is wanted for every event", so a repository declaring only `prePush`
91
+ // gates was reported `hooksComplete: false` for the pre-commit shim it has no
92
+ // reason to write - and no number of runs of `hooks install` could clear it,
93
+ // because the command and the dashboard were answering different questions.
94
+ // Four estate repositories were in that state.
95
+ readHooksState(path, { declaresGate: (e) => wantsShim(manifest.manifest, manifest.exists, e) }),
67
96
  checkTargets(path),
68
97
  ]);
69
98
 
@@ -73,6 +102,12 @@ export async function buildRow(path, context) {
73
102
  // state a rule written to four surfaces can drift into.
74
103
  const region = findings.find((f) => f.file === PRIMARY_TARGET) ?? { status: 'absent' };
75
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));
76
111
  const pointers = findings.filter((f) => f.status?.startsWith('pointer'));
77
112
 
78
113
  // Adoption is settled BEFORE any problem is collected. Every gate below asks
@@ -94,6 +129,13 @@ export async function buildRow(path, context) {
94
129
  // prose above it was still true.
95
130
  const { selected: chosen } = select(await loadRules(path), fp.stack ?? [], fp.facts ?? []);
96
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
+
97
139
  const row = {
98
140
  name,
99
141
  path,
@@ -108,6 +150,12 @@ export async function buildRow(path, context) {
108
150
  region: region.status,
109
151
  regionDetail: region.detail ?? null,
110
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),
111
159
  ruleFiles: {
112
160
  total: ruleFindings.filter((f) => f.status !== 'orphaned').length,
113
161
  current: ruleFindings.filter((f) => f.status === 'current').length,
@@ -119,6 +167,19 @@ export async function buildRow(path, context) {
119
167
  orphaned: ruleFindings.filter((f) => f.status === 'orphaned').map((f) => f.file),
120
168
  },
121
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
+ })),
122
183
  hooksRouted: hooks.routed,
123
184
  hooksComplete: hooks.complete,
124
185
  gatesDeclared: declaredGates(manifest.manifest),
@@ -152,6 +213,40 @@ export async function buildRow(path, context) {
152
213
  problems.push(`${MANIFEST_FILE} ${manifest.error}`);
153
214
  }
154
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
+
232
+ // A generated field of the manifest is stale, and `check` refuses on it. This row
233
+ // said the repository was `current` anyway, in all fourteen adopted repositories at
234
+ // once, so a dashboard that reported `current: 14, problems: 0` was describing a
235
+ // green estate on which every gate exited 1.
236
+ //
237
+ // ADR-045 went the other way: `compare` counted a defect it had been hiding. This is
238
+ // the same defect from the far side — a refusal the dashboard does not count — and
239
+ // the acceptance criterion in the review that owns this ("compare reports current:
240
+ // 14") was satisfiable while `check` exited 1 in every row it covered.
241
+ //
242
+ // `drift.remedy` verbatim, for both statuses. The remedy is the repair command, and
243
+ // two commands answering one question with different instructions is ADR-050's
244
+ // subject; if `check` changes its mind, this row changes with it for free.
245
+ if (adoption === 'adopted') {
246
+ const drift = await manifestDrift(path, row.stack, context.expectedSha);
247
+ if (drift) problems.push(`${drift.file}: ${drift.detail}; ${drift.remedy}`);
248
+ }
249
+
155
250
  // --- region --------------------------------------------------------------
156
251
  // Only meaningful for an adopted repo. An unmanaged repo has no manifest, so
157
252
  // its `absent` region is a consequence of not having adopted, not a second
@@ -289,6 +384,11 @@ export function summarise(rows, extra = {}) {
289
384
  malformed: count((r) => r.region === 'malformed'),
290
385
  hooksRouted: count((r) => r.hooksRouted),
291
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),
292
392
  withProblems: count((r) => r.problems.length > 0),
293
393
  withOwnHooks: count((r) => (r.foreignHooks ?? []).length > 0),
294
394
  shas: [...shas.entries()].sort((a, b) => (a[0] === '(none)' ? 1 : b[0] === '(none)' ? -1 : b[1] - a[1])),
@@ -180,7 +180,7 @@ export function citations(text) {
180
180
  *
181
181
  * @returns {Promise<{ok: boolean, decisions: object[], errors: string[]}>}
182
182
  */
183
- export async function loadDecisions() {
183
+ export async function loadDecisions({ all = false } = {}) {
184
184
  let names;
185
185
  try {
186
186
  names = (await readdir(DECISIONS_DIR)).filter((n) => /^ADR-\d{3}-.*\.md$/.test(n)).sort();
@@ -240,7 +240,7 @@ export async function loadDecisions() {
240
240
  (m) => `ADR-${m[1]}`,
241
241
  ),
242
242
  partial,
243
- summary: summarise(text, partial),
243
+ summary: summarise(text, partial, { all }),
244
244
  bytes: Buffer.byteLength(text),
245
245
  });
246
246
  }
@@ -283,9 +283,12 @@ export function byId(decisions) {
283
283
  * @param {{ruling: number|null, kind: string, by: string|null}[]} partial the record's
284
284
  * partial supersessions, so a ruling that is no longer in force can say so where it is
285
285
  * printed rather than only in the status line above it
286
- * @returns {{ruled: string[], rulingNumbers: (number|null)[], rejected: string[], writtenAgainst: string[]}}
286
+ * @param {{all?: boolean}} [options] `all` keeps every ruling instead of the first two.
287
+ * The cap exists for the common case, where a rule cites one record and two rulings
288
+ * answer "why is this here"; the reader who needs the rest asks.
289
+ * @returns {{ruled: string[], rulingNumbers: (number|null)[], rulingCount: number, rejected: string[], writtenAgainst: string[]}}
287
290
  */
288
- function summarise(text, partial = []) {
291
+ function summarise(text, partial = [], { all = false } = {}) {
289
292
  // A section ends at the next `## ` — but not at one inside a fenced code block.
290
293
  // ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
291
294
  // so the old lookahead ended the section at the first line of the ruling itself. It
@@ -418,9 +421,21 @@ const section = (name) => {
418
421
  const ruled = units(section('Ruling') || section('Rulings'));
419
422
  const withLead = ruled.filter((p, i) => !p.text.endsWith(':') || i === ruled.length - 1);
420
423
 
421
- const printed = withLead.slice(0, 2);
424
+ // Two by default, and every one of them on request. Fifty-five of the sixty records
425
+ // carry more than two rulings, so the default is an excerpt for almost every record —
426
+ // which is fine as a budget and wrong as a silence, and the count below says so.
427
+ //
428
+ // The full set is kept rather than discarded, because the command that names where the
429
+ // rest of it is has to be able to produce it. A remedy that cannot be followed is the
430
+ // defect this whole command exists to prevent, and the first version of this fix
431
+ // printed "the rest: simt rules show ADR-019", which exits 2: `rules show` takes a rule
432
+ // id. So `--all` is the command the excerpt points at, and this is what it reads.
433
+ const printed = all ? withLead : withLead.slice(0, 2);
422
434
  return {
423
435
  ruled: printed.map((p) => mark(sentence(redactCommand(p.text)), p.number)),
436
+ // How many rulings the record carries. Parallel to `ruled`: "two of eight" and "two"
437
+ // are different answers to the same question and only one of them is true.
438
+ rulingCount: withLead.length,
424
439
  // The number each printed ruling declares, parallel to `ruled`. It is the join
425
440
  // between a supersession and the text it applies to, so it is exported rather than
426
441
  // kept inside: a family that stopped numbering its rulings — `**One.**` losing the
@@ -493,7 +508,7 @@ export function redactEstate(text) {
493
508
  */
494
509
  const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
495
510
 
496
- export function redactCommand(text) {
511
+ function redactCommand(text) {
497
512
  return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
498
513
  }
499
514
 
@@ -15,15 +15,95 @@
15
15
 
16
16
  import { readdir, readFile, stat } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { dirname, join, resolve } from 'node:path';
19
- import { fileURLToPath } from 'node:url';
18
+ import { join } from 'node:path';
20
19
 
21
20
  import { KNOWN_TAGS } from '../resolve/fingerprint.mjs';
22
21
  import { matchesGlob, syntheticPathFor, tokenize } from '../resolve/resolve.mjs';
23
- import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
22
+ import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
24
23
 
25
- const HERE = dirname(fileURLToPath(import.meta.url));
26
- export const REGISTRY_PATH = resolve(HERE, '..', '..', 'graph', 'skills.graph.json');
24
+ /**
25
+ * The registry is workspace data (ADR-061): it describes which skill bodies a particular
26
+ * workspace holds, where they are, how big they are, and which tags separate the variants.
27
+ * It therefore lives in a MEMBER of the workspace, not in the workspace root - the root is
28
+ * the directory that CONTAINS the member repositories, and it holds no `graph/` of its own.
29
+ * An explicitly named path always wins over everything below.
30
+ */
31
+ function defaultRegistryPath() {
32
+ return join(PACKAGE_ROOT, 'graph', 'skills.graph.json');
33
+ }
34
+
35
+ /**
36
+ * The registry to read when the reader names none, in order (ADR-061 ruling 3):
37
+ *
38
+ * 1. The package root, when it holds one. In this checkout that is how the tool finds its
39
+ * own registry; in an installed package `graph` is not in the published `files`, so it
40
+ * never does, and the reader falls through to 2 instead of being handed this workspace's
41
+ * catalogue.
42
+ * 2. A member of the resolved workspace, when exactly one holds one: a tool installed from
43
+ * node_modules or a formula resolves its workspace by configuration, and the member that
44
+ * holds the registry is usually the tool's own checkout. More than one holding it is
45
+ * ambiguity and the reader must pick; none is an honest absence, reported with the
46
+ * workspace that was looked in.
47
+ *
48
+ * Every step is file existence plus the configured workspace, so the answer can be
49
+ * explained to whoever asks (determinism). `errors` empty means a path was found.
50
+ *
51
+ * @returns {Promise<{where: string|null, errors: string[], workspace: {root: string|null, source: string, detail: string}|null}>}
52
+ */
53
+ async function defaultRegistry(flags) {
54
+ const own = defaultRegistryPath();
55
+ if (existsSync(own)) return { where: own, errors: [], workspace: null };
56
+
57
+ const ws = await workspace(flags);
58
+ const info = { root: ws.root, source: ws.source, detail: ws.detail };
59
+ const absent = `no skill registry at ${own}; create one there, or name it explicitly`;
60
+ if (ws.error) {
61
+ return { where: own, errors: [`${ws.detail}: ${ws.error}`, absent], workspace: info };
62
+ }
63
+ if (ws.root === null) {
64
+ return { where: own, errors: [absent], workspace: info };
65
+ }
66
+ if (!existsSync(ws.root)) {
67
+ return {
68
+ where: own,
69
+ errors: [`no skill registry: the configured workspace ${ws.root} (${ws.detail}) does not exist`],
70
+ workspace: info,
71
+ };
72
+ }
73
+
74
+ const members = [];
75
+ let entries;
76
+ try {
77
+ entries = await readdir(ws.root, { withFileTypes: true });
78
+ } catch {
79
+ entries = null;
80
+ }
81
+ for (const entry of entries ?? []) {
82
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
83
+ if (existsSync(join(ws.root, entry.name, 'graph', 'skills.graph.json'))) members.push(entry.name);
84
+ }
85
+
86
+ if (members.length === 1) {
87
+ return { where: join(ws.root, members[0], 'graph', 'skills.graph.json'), errors: [], workspace: info };
88
+ }
89
+ if (members.length > 1) {
90
+ return {
91
+ where: null,
92
+ errors: [
93
+ `ambiguous registry: ${members.length} members of workspace ${ws.root} each hold ` +
94
+ `graph/skills.graph.json (${members.join(', ')}); name one explicitly`,
95
+ ],
96
+ workspace: info,
97
+ };
98
+ }
99
+ return {
100
+ where: own,
101
+ errors: [
102
+ `no skill registry in workspace ${ws.root} (${ws.detail}); create one in a member, or name it explicitly`,
103
+ ],
104
+ workspace: info,
105
+ };
106
+ }
27
107
 
28
108
  /**
29
109
  * Where sibling repos live. Sources are declared as `repo/path`, so resolving one
@@ -44,11 +124,6 @@ export function workspace(flags = {}) {
44
124
  return workspacePromise;
45
125
  }
46
126
 
47
- /** Test seam: forget the memoised workspace so the next call re-resolves. */
48
- export function resetWorkspace() {
49
- workspacePromise = null;
50
- }
51
-
52
127
  const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'deprecated']);
53
128
 
54
129
  /**
@@ -63,15 +138,47 @@ const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'depre
63
138
  * @returns {Promise<{ok: boolean, registry: object|null, errors: string[], warnings: string[],
64
139
  * verified: boolean, workspace: {root: string|null, source: string, detail: string}>}
65
140
  */
66
- export async function loadRegistry(path = REGISTRY_PATH, flags = {}) {
141
+ export async function loadRegistry(path = null, flags = {}) {
142
+ // An explicitly named path wins over the default (ADR-061). When none is given,
143
+ // `defaultRegistry` looks in the package root and then in the workspace's members; its
144
+ // errors are facts about ABSENCE - no registry here, or more than one and the reader must
145
+ // pick - and are reported as such. A named path that is missing is a different fact: the
146
+ // reader pointed at a file that is not there, and it names that path.
147
+ let where = path;
148
+ let resolveErrors = null;
149
+ let resolvedWorkspace = null;
150
+ if (path === null) {
151
+ const found = await defaultRegistry(flags);
152
+ where = found.where;
153
+ resolveErrors = found.errors;
154
+ resolvedWorkspace = found.workspace;
155
+ }
156
+
157
+ if (resolveErrors !== null && resolveErrors.length > 0) {
158
+ return {
159
+ ok: false,
160
+ registry: null,
161
+ path: where,
162
+ errors: resolveErrors,
163
+ warnings: [],
164
+ verified: false,
165
+ workspace: resolvedWorkspace ?? { root: null, source: 'none', detail: 'not resolved' },
166
+ };
167
+ }
168
+
67
169
  let registry;
68
170
  try {
69
- registry = JSON.parse(await readFile(path, 'utf8'));
171
+ registry = JSON.parse(await readFile(where, 'utf8'));
70
172
  } catch (error) {
173
+ const message =
174
+ path === null && error.code === 'ENOENT'
175
+ ? `no skill registry at ${where}; create one there, or name it explicitly`
176
+ : `${where}: unreadable or invalid JSON (${error.message})`;
71
177
  return {
72
178
  ok: false,
73
179
  registry: null,
74
- errors: [`${path}: unreadable or invalid JSON (${error.message})`],
180
+ path: where,
181
+ errors: [message],
75
182
  warnings: [],
76
183
  verified: false,
77
184
  workspace: { root: null, source: 'none', detail: 'not resolved' },
@@ -127,6 +234,7 @@ export async function loadRegistry(path = REGISTRY_PATH, flags = {}) {
127
234
  return {
128
235
  ok: errors.length === 0,
129
236
  registry,
237
+ path: where,
130
238
  errors,
131
239
  warnings,
132
240
  verified,
@@ -465,8 +573,11 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
465
573
  continue;
466
574
  }
467
575
  if (typeof source.bytes === 'number' && source.bytes !== measured.bytes) {
576
+ // The delta is computed here, not in `measureSkill`: that function measures
577
+ // one path and knows nothing about the declaration it is being compared with.
578
+ const delta = measured.bytes - source.bytes;
468
579
  errors.push(
469
- `${at}: declared ${source.bytes} bytes, on disk ${measured.bytes} (${measured.delta > 0 ? '+' : ''}${measured.delta})`,
580
+ `${at}: declared ${source.bytes} bytes, on disk ${measured.bytes} (${delta > 0 ? '+' : ''}${delta})`,
470
581
  );
471
582
  }
472
583
  if (!source.bytes) warnings.push(`${at}: no declared bytes to compare against`);
@@ -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
+ }