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
@@ -3,7 +3,7 @@
3
3
  // Implementation sequence steps 4 and 5.
4
4
  //
5
5
  // Output design (plan 0002 section 6): one fact per line, fixed field order, no
6
- // wrapping, no colour when piped, bounded by --max-bytes with an explicit
6
+ // wrapping, no ANSI on any stream, bounded by --max-bytes with an explicit
7
7
  // TRUNCATED line. The NOT SELECTED block is as important as the resolved block -
8
8
  // the agent can see what exists and why it was excluded, and override in one
9
9
  // token instead of guessing. Suppressing it is how a discovery tool becomes a
@@ -14,9 +14,10 @@ import { join } from 'node:path';
14
14
  import { existsSync } from 'node:fs';
15
15
 
16
16
  import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
17
- import { loadRegistry, REGISTRY_PATH } from '../graph/load.mjs';
17
+ import { loadRegistry } from '../graph/load.mjs';
18
18
  import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
19
- import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
19
+ import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
20
+ import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
20
21
  import { fingerprint } from '../resolve/fingerprint.mjs';
21
22
  import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
22
23
  import { nearMatches } from '../resolve/near.mjs';
@@ -48,6 +49,22 @@ export function parsePathList(value) {
48
49
  .filter(Boolean);
49
50
  }
50
51
 
52
+ /**
53
+ * Read a list flag in both the forms this tool ships.
54
+ *
55
+ * `--skill` accepts `--skill a --skill b` and `--skill a,b`, and arrives here as an
56
+ * array in the first case and a string in the second. `String(['a','b'])` happens to
57
+ * produce `'a,b'`, so the old one-line reader would have kept working by accident — and
58
+ * would have started throwing the moment a skill name contained the character the
59
+ * join inserts. Both shapes are handled rather than one being relied upon, because
60
+ * relying on it is the same class of bug as the one being fixed here.
61
+ */
62
+ export function listFlag(value) {
63
+ return (Array.isArray(value) ? value : [value])
64
+ .flatMap((v) => parsePathList(v))
65
+ .filter(Boolean);
66
+ }
67
+
51
68
  /** Compact stack label: the tags that actually discriminate, not all of them. */
52
69
  function stackLabel(tags = []) {
53
70
  const DISCRIMINATING = [
@@ -75,29 +92,24 @@ const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
75
92
  // --- tier 0: index ------------------------------------------------------------
76
93
 
77
94
  export async function indexCommand(args, flags) {
78
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
79
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
95
+ const { registry, errors, ok } = await loadRegistry(null, flags);
96
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
80
97
  const workspace = await workspaceLine(flags);
81
98
 
82
99
  if (isJson(flags)) {
83
- emit(
84
- `${JSON.stringify(
85
- {
86
- ok: true,
87
- workspace,
88
- skills: registry.skills.map((s) => ({
89
- name: s.name,
90
- summary: s.summary,
91
- capabilities: s.capabilities,
92
- stack: s.stack,
93
- bytes: s.bytes,
94
- deferred: Boolean(s.deferred),
95
- })),
96
- },
97
- null,
98
- 2,
99
- )}\n`,
100
- { maxBytes: flags.maxBytes ?? 0 },
100
+ emitJson(
101
+ {
102
+ ok: true,
103
+ workspace,
104
+ skills: registry.skills.map((s) => ({
105
+ name: s.name,
106
+ summary: s.summary,
107
+ capabilities: s.capabilities,
108
+ stack: s.stack,
109
+ bytes: s.bytes,
110
+ deferred: Boolean(s.deferred),
111
+ })),
112
+ },
101
113
  );
102
114
  return EXIT.OK;
103
115
  }
@@ -151,20 +163,15 @@ async function emitAll(registry, repoStack, flags, warning) {
151
163
  const inline = usable.filter((s) => !s.deferred);
152
164
 
153
165
  if (isJson(flags)) {
154
- emit(
155
- `${JSON.stringify(
156
- {
157
- ok: true,
158
- all: true,
159
- repoStack,
160
- inline: inline.map((s) => s.name),
161
- deferred: deferred.map((s) => s.name),
162
- ...(warning ? { warning } : {}),
163
- },
164
- null,
165
- 2,
166
- )}\n`,
167
- { maxBytes: flags.maxBytes ?? 0 },
166
+ emitJson(
167
+ {
168
+ ok: true,
169
+ all: true,
170
+ repoStack,
171
+ inline: inline.map((s) => s.name),
172
+ deferred: deferred.map((s) => s.name),
173
+ ...(warning ? { warning } : {}),
174
+ },
168
175
  );
169
176
  return EXIT.OK;
170
177
  }
@@ -189,8 +196,8 @@ export async function findCommand(args, flags) {
189
196
  const query = args.join(' ').trim();
190
197
  if (!query) return fail('find needs a query', EXIT.USAGE, `usage: ${CLI} skills find <query>`);
191
198
 
192
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
193
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
199
+ const { registry, errors, ok } = await loadRegistry(null, flags);
200
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
194
201
 
195
202
  const tokens = tokenize(query);
196
203
  const workspace = await workspaceLine(flags);
@@ -200,7 +207,10 @@ export async function findCommand(args, flags) {
200
207
  .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
201
208
 
202
209
  if (isJson(flags)) {
203
- emit(`${JSON.stringify({ ok: true, query, workspace, results: results.map((r) => ({ name: r.skill.name, score: r.score, reasons: r.reasons })) }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
210
+ emitJson(
211
+ { ok: true, query, workspace, results: results.map((r) => ({ name: r.skill.name, score: r.score, reasons: r.reasons })) },
212
+ { maxBytes: flags.maxBytes ?? 0 },
213
+ );
204
214
  return EXIT.OK;
205
215
  }
206
216
 
@@ -227,8 +237,8 @@ export async function findCommand(args, flags) {
227
237
  // --- brief: tier 1 ------------------------------------------------------------
228
238
 
229
239
  export async function briefCommand(args, flags) {
230
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
231
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
240
+ const { registry, errors, ok } = await loadRegistry(null, flags);
241
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
232
242
 
233
243
  const root = flags.root ?? process.cwd();
234
244
  const repo = await fingerprint(root);
@@ -237,7 +247,7 @@ export async function briefCommand(args, flags) {
237
247
  const repoStack = repo.signals;
238
248
  const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
239
249
  const taskTokens = tokenize(flags.task ?? '');
240
- const forced = parsePathList(flags.skill);
250
+ const forced = listFlag(flags.skill);
241
251
  const budget = flags.budget ?? DEFAULT_BUDGET;
242
252
 
243
253
  // A brief with no task and no paths resolves on stack alone, which produces a
@@ -258,24 +268,19 @@ export async function briefCommand(args, flags) {
258
268
  // is what `warning` carries. Failing here would tell a caller the tool
259
269
  // broke when in fact it is telling them their query was.
260
270
  if (flags.all) return emitAll(registry, repoStack, flags, warning);
261
- emit(
262
- `${JSON.stringify(
263
- {
264
- ok: true,
265
- repo: { root, stack: repoStack },
266
- workspace,
267
- task: null,
268
- budget,
269
- resolved: [],
270
- deferred: [],
271
- excluded: [],
272
- warning,
273
- },
274
- null,
275
- 2,
276
- )}\n`,
277
- { maxBytes: flags.maxBytes ?? 0 },
278
- );
271
+ emitJson(
272
+ {
273
+ ok: true,
274
+ repo: { root, stack: repoStack },
275
+ workspace,
276
+ task: null,
277
+ budget,
278
+ resolved: [],
279
+ deferred: [],
280
+ excluded: [],
281
+ warning,
282
+ },
283
+ );
279
284
  return EXIT.OK;
280
285
  }
281
286
  emit(
@@ -316,22 +321,17 @@ export async function briefCommand(args, flags) {
316
321
  const lines = [];
317
322
 
318
323
  if (isJson(flags)) {
319
- emit(
320
- `${JSON.stringify(
321
- {
322
- ok: true,
323
- repo: { root, stack: repoStack },
324
- workspace,
325
- task: flags.task ?? null,
326
- budget,
327
- resolved: picked.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes, why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)] })),
328
- deferred: deferred.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes })),
329
- excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
330
- },
331
- null,
332
- 2,
333
- )}\n`,
334
- { maxBytes: flags.maxBytes ?? 0 },
324
+ emitJson(
325
+ {
326
+ ok: true,
327
+ repo: { root, stack: repoStack },
328
+ workspace,
329
+ task: flags.task ?? null,
330
+ budget,
331
+ resolved: picked.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes, why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)] })),
332
+ deferred: deferred.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes })),
333
+ excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
334
+ },
335
335
  );
336
336
  return EXIT.OK;
337
337
  }
@@ -432,8 +432,8 @@ export async function showCommand(args, flags) {
432
432
  const name = args[0];
433
433
  if (!name) return fail('show needs a skill name', EXIT.USAGE, `usage: ${CLI} skills show <name>`);
434
434
 
435
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
436
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
435
+ const { registry, errors, ok } = await loadRegistry(null, flags);
436
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
437
437
 
438
438
  const skill = registry.skills.find((s) => s.name === name);
439
439
  if (!skill) {
@@ -453,10 +453,7 @@ export async function showCommand(args, flags) {
453
453
  const body = await sourcePath(skill, workspaceRoot, root);
454
454
  const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
455
455
  if (isJson(flags)) {
456
- emit(
457
- `${JSON.stringify({ ok: true, ...skill, workspace, body, bodyFrom }, null, 2)}\n`,
458
- { maxBytes: flags.maxBytes ?? 0 },
459
- );
456
+ emitJson({ ok: true, ...skill, workspace, body, bodyFrom }, { maxBytes: flags.maxBytes ?? 0 });
460
457
  return EXIT.OK;
461
458
  }
462
459
  emit(
@@ -490,8 +487,8 @@ export async function whyCommand(args, flags) {
490
487
  const name = args[0];
491
488
  if (!name) return fail('why needs a skill name', EXIT.USAGE, `usage: ${CLI} skills why <name>`);
492
489
 
493
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
494
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
490
+ const { registry, errors, ok } = await loadRegistry(null, flags);
491
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
495
492
 
496
493
  const skill = registry.skills.find((s) => s.name === name);
497
494
  if (!skill) {
@@ -565,6 +562,112 @@ export async function whyCommand(args, flags) {
565
562
  return EXIT.OK;
566
563
  }
567
564
 
565
+ // --- eval ---------------------------------------------------------------------
566
+
567
+ /**
568
+ * The gold corpus when the reader names none. A checkout holds one at this fixed path;
569
+ * `test/` is not in the published files, so an installed copy does not. That absence is
570
+ * a fact about where this copy runs, reported as such (ADR-061's tiered resolution is
571
+ * the same shape: explicit path, then the package's own file, then an honest absence).
572
+ */
573
+ const DEFAULT_CORPUS = join(PACKAGE_ROOT, 'test', 'fixtures', 'eval-corpus.json');
574
+
575
+ /**
576
+ * `simt skills eval [corpus]` - measure the resolver against the gold corpus.
577
+ *
578
+ * Plan 0002 section 9: "Reports precision and recall and fails on regression." The
579
+ * corpus states, per case, which skills must come back (`select`) and which must not
580
+ * (`reject`); a case that names no selection is exhaustive, and anything it returns is
581
+ * a miss. The exit code rests on `clean`, because the two ratios can both read 1.0
582
+ * while an exhaustive case returns a skill neither list named (see src/resolve/eval.mjs).
583
+ */
584
+ export async function evalCommand(positional, flags) {
585
+ const corpusPath = positional[0] ?? (existsSync(DEFAULT_CORPUS) ? DEFAULT_CORPUS : null);
586
+
587
+ if (corpusPath === null) {
588
+ return fail(
589
+ `no eval corpus: ${DEFAULT_CORPUS} is not on this machine (test data does not ship in the package)`,
590
+ EXIT.FAIL,
591
+ `run: ${CLI} skills eval <path-to-corpus> (a file of cases with select and reject lists)`,
592
+ );
593
+ }
594
+
595
+ let corpus;
596
+ try {
597
+ corpus = JSON.parse(await readFile(corpusPath, 'utf8'));
598
+ } catch (error) {
599
+ return fail(
600
+ `${corpusPath}: unreadable or invalid JSON (${error.message})`,
601
+ EXIT.FAIL,
602
+ `run: ${CLI} skills eval <path-to-corpus>`,
603
+ );
604
+ }
605
+ if (!Array.isArray(corpus.cases) || corpus.cases.length === 0) {
606
+ return fail(
607
+ `${corpusPath}: no cases (a corpus is {"cases": [{"name", "repo", "task", "select", "reject"}]})`,
608
+ EXIT.FAIL,
609
+ `run: ${CLI} skills eval <path-to-corpus>`,
610
+ );
611
+ }
612
+
613
+ // The corpus is measured against the registry it cites, so it needs the same
614
+ // resolution and the same failure as every other skills command.
615
+ const { registry, errors, ok, path } = await loadRegistry(null, flags);
616
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
617
+
618
+ const unknown = unknownSkills(registry, corpus);
619
+ const score = scoreCorpus(registry, corpus);
620
+ const okRun = unknown.length === 0 && score.clean;
621
+
622
+ if (isJson(flags)) {
623
+ emitJson(
624
+ {
625
+ ok: okRun,
626
+ command: 'skills eval',
627
+ corpus: corpusPath,
628
+ registry: path,
629
+ cases: score.cases.map((c) => ({
630
+ name: c.name,
631
+ selected: c.selected,
632
+ missing: c.missing,
633
+ unexpected: c.unexpected,
634
+ })),
635
+ unknownSkills: unknown,
636
+ precision: score.precision,
637
+ recall: score.recall,
638
+ },
639
+ );
640
+ return okRun ? EXIT.OK : EXIT.FAIL;
641
+ }
642
+
643
+ const failed = score.cases.filter((c) => c.missing.length > 0 || c.unexpected.length > 0);
644
+ const lines = [
645
+ `corpus ${corpusPath} (${score.cases.length} cases)`,
646
+ `registry ${path}`,
647
+ `${score.cases.length - failed.length} pass, ${failed.length} fail`,
648
+ `precision ${score.precision.toFixed(3)} recall ${score.recall.toFixed(3)}`,
649
+ '',
650
+ ];
651
+
652
+ for (const c of score.cases) {
653
+ if (c.missing.length === 0 && c.unexpected.length === 0) {
654
+ lines.push(`PASS ${c.name}`);
655
+ continue;
656
+ }
657
+ lines.push(`FAIL ${c.name}`);
658
+ for (const name of c.missing) lines.push(` missed ${name}`);
659
+ for (const name of c.unexpected) lines.push(` unexpected ${name}`);
660
+ }
661
+
662
+ if (unknown.length > 0) {
663
+ lines.push('', `FAIL corpus cites skills the registry does not declare: ${unknown.join(', ')}`);
664
+ }
665
+
666
+ lines.push('', okRun ? 'OK the resolver meets the gold corpus' : 'REGRESSION: the resolver no longer meets the gold corpus');
667
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
668
+ return okRun ? EXIT.OK : EXIT.FAIL;
669
+ }
670
+
568
671
  // --- bodies -------------------------------------------------------------------
569
672
 
570
673
  /**
@@ -581,7 +684,7 @@ export async function whyCommand(args, flags) {
581
684
  * location for a declared source, matched on bytes rather than on hope (ADR-015).
582
685
  * The canonical source is still tried first, so a workspace body always wins.
583
686
  */
584
- export async function sourcePath(skill, workspaceRoot, root) {
687
+ async function sourcePath(skill, workspaceRoot, root) {
585
688
  const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
586
689
 
587
690
  if (canonical) {
@@ -24,8 +24,8 @@ import { cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
24
24
  import { existsSync } from 'node:fs';
25
25
  import { dirname, join } from 'node:path';
26
26
 
27
- import { EXIT, emit, fail, isJson, note, writeMode } from '../output/index.mjs';
28
- import { loadRegistry, REGISTRY_PATH, measureSkill } from '../graph/load.mjs';
27
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
28
+ import { loadRegistry, measureSkill } from '../graph/load.mjs';
29
29
  import { fingerprint } from '../resolve/fingerprint.mjs';
30
30
  import { readCapability } from '../hooks/capability.mjs';
31
31
  import { MANIFEST, buildManifest, readManifest, writeManifest } from '../projection/manifest.mjs';
@@ -35,6 +35,7 @@ import { nearMatches } from '../resolve/near.mjs';
35
35
  import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
36
36
  import { CLI } from '../name.mjs';
37
37
  import { MANIFEST_FILE } from '../name.mjs';
38
+ import { nameProblems } from '../graph/spec.mjs';
38
39
 
39
40
  /**
40
41
  * Where skills live in this repo. The estate already has a convention - a
@@ -59,8 +60,8 @@ export async function installCommand(args, flags) {
59
60
  );
60
61
  }
61
62
 
62
- const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
63
- if (!ok) return fail(errors[0], EXIT.REGISTRY);
63
+ const { registry, errors, ok } = await loadRegistry(null, flags);
64
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
64
65
 
65
66
  const skill = registry.skills.find((s) => s.name === name);
66
67
  if (!skill) {
@@ -112,6 +113,20 @@ export async function installCommand(args, flags) {
112
113
  }
113
114
 
114
115
  const dir = skillsDir(root, await dirEntries(root));
116
+ // The Agent Skills specification requires `name` to equal the directory it lives in,
117
+ // and the directory is named after the skill. So a name that breaks the naming rules
118
+ // cannot be installed at all: the client resolves by name, finds a directory the name
119
+ // does not match, and never reads the body this command just copied. Refused here,
120
+ // before anything is written, with the rule broken - because the alternative is a
121
+ // successful install that no client can load.
122
+ const broken = nameProblems(name);
123
+ if (broken !== null) {
124
+ return fail(
125
+ `${name}: ${broken}`,
126
+ EXIT.REGISTRY,
127
+ `rename it in ${skill.source ?? 'the registry'} to lowercase letters, digits and single hyphens, then install again`,
128
+ );
129
+ }
115
130
  const dest = join(root, dir, name);
116
131
  const inplace = await samePath(src, dest);
117
132
  const action = inplace ? 'in-place' : await compare(dest, src, measured.flat, name);
@@ -147,7 +162,7 @@ export async function installCommand(args, flags) {
147
162
  // combination a script writes. The payload is now emitted once, at the end,
148
163
  // after the write it describes.
149
164
  const report = () => {
150
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
165
+ if (isJson(flags)) emitJson(payload);
151
166
  };
152
167
  const mode = writeMode(flags);
153
168