simt-dev 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -20,7 +20,7 @@ import { promisify } from 'node:util';
20
20
  import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises';
21
21
  import { dirname, join } from 'node:path';
22
22
 
23
- import { EXIT, emit, fail, isJson, note } from '../output/index.mjs';
23
+ import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
24
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
25
25
  import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
26
26
  import { MANIFEST, buildManifest, manifestPath, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
@@ -219,7 +219,7 @@ export async function initCommand(positional, flags) {
219
219
  payload.ok = false;
220
220
  payload.blockedBy = 'surface-problem';
221
221
  for (const p of surfaceProblems) note(`PROBLEM ${p}`);
222
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
222
+ if (isJson(flags)) emitJson(payload);
223
223
  return fail(
224
224
  `${surfaceProblems.length} surface(s) cannot be resolved (${surfaceProblems[0]})`,
225
225
  EXIT.FAIL,
@@ -234,7 +234,7 @@ export async function initCommand(positional, flags) {
234
234
  payload.ok = false;
235
235
  payload.blockedBy = 'mdc-problem';
236
236
  for (const p of mdcProblems) note(`PROBLEM ${p}`);
237
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
237
+ if (isJson(flags)) emitJson(payload);
238
238
  return fail(
239
239
  `${mdcProblems.length} canonical .mdc rule(s) cannot be projected (${mdcProblems[0]})`,
240
240
  EXIT.FAIL,
@@ -249,7 +249,7 @@ export async function initCommand(positional, flags) {
249
249
  payload.ok = false;
250
250
  payload.blockedBy = 'conflict';
251
251
  if (isJson(flags)) {
252
- emit(`${JSON.stringify(payload, null, 2)}\n`);
252
+ emitJson(payload);
253
253
  } else {
254
254
  emit(render(payload, manifest, { npm, version }));
255
255
  note('');
@@ -271,7 +271,7 @@ export async function initCommand(positional, flags) {
271
271
  if (malformed.length > 0) {
272
272
  payload.ok = false;
273
273
  payload.blockedBy = 'malformed-region';
274
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
274
+ if (isJson(flags)) emitJson(payload);
275
275
  else emit(render(payload, manifest, { npm, version }));
276
276
  return fail(
277
277
  `${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
@@ -282,7 +282,7 @@ export async function initCommand(positional, flags) {
282
282
 
283
283
  // Step 4/5: report everything, then make changes only when told to.
284
284
  if (isJson(flags)) {
285
- if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
285
+ if (!apply) emitJson(payload);
286
286
  } else {
287
287
  emit(render(payload, manifest, { npm, version }));
288
288
  }
@@ -443,7 +443,7 @@ export async function initCommand(positional, flags) {
443
443
  }
444
444
  payload.written = true;
445
445
 
446
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
446
+ if (isJson(flags)) emitJson(payload);
447
447
 
448
448
  note('');
449
449
  note(`next ${CLI} sync --yes, then ${CLI} check in CI. Commit when you are happy with the diff.`);
@@ -608,8 +608,12 @@ function render(payload, manifest, { npm = true, version } = {}) {
608
608
  // on it would understate the edit the reader is approving. A baseline is the
609
609
  // opposite case to AGENTS.md in the one way that matters here — it is placed FIRST,
610
610
  // so the repo's own settings below it keep winning (ADR-025).
611
+ // A nested `apps/web/AGENTS.md` is the same kind of target as the root's, so the
612
+ // test is the suffix and not the exact path: a monorepo's package region holds
613
+ // hand-written content too, and describing it as a generated rule file would be
614
+ // the wrong contract for a reader who has prose above the markers.
611
615
  const caption =
612
- target.file === PRIMARY_TARGET
616
+ target.file.endsWith(PRIMARY_TARGET)
613
617
  ? 'hand-written content outside the markers is never modified'
614
618
  : target.kind === 'baseline'
615
619
  ? 'a region placed first, so your own settings below it still win (ADR-025)'
@@ -13,9 +13,9 @@ import { readFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { dirname, join } from 'node:path';
15
15
 
16
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
16
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
17
17
  import { TOOLS } from '../mcp/tools.mjs';
18
- import { PROTOCOL_VERSION, serve } from '../mcp/server.mjs';
18
+ import { PROTOCOL_VERSION, SUPPORTED_VERSIONS, serve } from '../mcp/server.mjs';
19
19
  import { CLI } from '../name.mjs';
20
20
 
21
21
  export async function mcpCommand(positional, flags) {
@@ -23,12 +23,27 @@ export async function mcpCommand(positional, flags) {
23
23
 
24
24
  if (flags.list === true) {
25
25
  if (isJson(flags)) {
26
- emit(`${JSON.stringify({ ok: true, protocolVersion: PROTOCOL_VERSION, version, tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }, null, 2)}\n`);
26
+ emitJson({
27
+ ok: true,
28
+ protocolVersion: PROTOCOL_VERSION,
29
+ protocolVersions: SUPPORTED_VERSIONS,
30
+ version,
31
+ // The output schema and the read-only annotation travel with the tool here too:
32
+ // a reader listing the surface should see the same contract a call returns,
33
+ // rather than discovering the shape by making one.
34
+ tools: TOOLS.map(({ name, description, inputSchema, outputSchema, annotations }) => ({
35
+ name,
36
+ description,
37
+ inputSchema,
38
+ ...(outputSchema ? { outputSchema } : {}),
39
+ annotations,
40
+ })),
41
+ });
27
42
  return EXIT.OK;
28
43
  }
29
44
  emit(
30
45
  [
31
- `simt-dev ${version} - MCP surface over stdio (protocol ${PROTOCOL_VERSION})`,
46
+ `simt-dev ${version} - MCP surface over stdio (protocols ${SUPPORTED_VERSIONS.join(', ')})`,
32
47
  '',
33
48
  `${TOOLS.length} read-only tools:`,
34
49
  ...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
@@ -44,7 +59,7 @@ export async function mcpCommand(positional, flags) {
44
59
  }
45
60
 
46
61
  try {
47
- return await serve(TOOLS, { version });
62
+ return await serve(TOOLS, { packageVersion: version });
48
63
  } catch (error) {
49
64
  return fail(`mcp transport failed: ${error.message}`, EXIT.FAIL, `run: ${CLI} mcp --list to check the surface loads`);
50
65
  }
@@ -0,0 +1,99 @@
1
+ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
2
+ import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
3
+ import { CLI } from '../name.mjs';
4
+
5
+ // Preview unless --yes, and --dry-run wins even then. A tracker row marked done
6
+ // for a commit that was never written is a claim the next reader will believe.
7
+
8
+ export async function planCommand(sub, args, flags) {
9
+ const root = flags.root ?? process.cwd();
10
+ if (sub === 'status') return runStatus(root, flags);
11
+ if (sub === 'next') return runNext(root, args[0] ?? null, flags);
12
+ if (sub === 'done') {
13
+ if (!args[0]) return fail('plan done needs a slice id', EXIT.USAGE, `run: ${CLI} plan next`);
14
+ return runDone(root, args[0], args[1] ?? null, flags);
15
+ }
16
+ if (!sub) {
17
+ return fail('plan needs a subcommand', EXIT.USAGE, `run: ${CLI} plan status | ${CLI} plan next | ${CLI} plan done <id>`);
18
+ }
19
+ return fail(`unknown plan subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} plan status`);
20
+ }
21
+
22
+ async function runStatus(root, flags) {
23
+ const result = await planStatus(root);
24
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
25
+ if (isJson(flags)) {
26
+ emitJson({ ok: true, command: 'plan status', ...result });
27
+ return EXIT.OK;
28
+ }
29
+ const lines = [`repo ${root}`, `template ${result.template}`, `dirs ${result.directories.join(', ')}`];
30
+ if (result.documents.length === 0) lines.push(`(no tracker in ${result.directories.join(' or ')})`);
31
+ for (const doc of result.documents) {
32
+ lines.push(`${doc.name} slices=${doc.slices} ${formatCounts(doc.counts, result.stateOrder)}`);
33
+ }
34
+ lines.push('', `total ${formatCounts(result.totals, result.stateOrder) || '-'}`);
35
+ lines.push(`ready ${result.ready}`);
36
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
37
+ return EXIT.OK;
38
+ }
39
+
40
+ async function runNext(root, id, flags) {
41
+ const result = await planNext(root, id);
42
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
43
+ if (isJson(flags)) {
44
+ emitJson({ ok: true, command: 'plan next', ...result });
45
+ return EXIT.OK;
46
+ }
47
+ if (!result.next) {
48
+ const lines = [`repo ${root}`, result.reason];
49
+ if (result.blocked?.length) {
50
+ lines.push('', 'blocked:');
51
+ for (const row of result.blocked) {
52
+ const unknown = row.unknownDependencies.length ? ` unknown: ${row.unknownDependencies.join(', ')}` : '';
53
+ lines.push(` ${row.id} ${row.doc} depends on: ${row.dependsOn || '(none)'}${unknown}`);
54
+ }
55
+ }
56
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
57
+ return EXIT.OK;
58
+ }
59
+ const next = result.next;
60
+ const head = [
61
+ `repo ${root}`,
62
+ `slice ${next.id} ${next.doc}`,
63
+ `state ${next.state}`,
64
+ `depends ${next.dependsOn || '(none)'}`,
65
+ `bytes ${next.sectionBytes}`,
66
+ '',
67
+ next.section || '(no section for this id)',
68
+ ];
69
+ emit(head.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
70
+ return EXIT.OK;
71
+ }
72
+
73
+ async function runDone(root, id, sha, flags) {
74
+ const mode = writeMode(flags);
75
+ const result = await planDone(root, id, {
76
+ sha,
77
+ test: flags.test ?? null,
78
+ state: flags.state ?? null,
79
+ dryRun: !mode.apply,
80
+ });
81
+ if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
82
+ if (isJson(flags)) {
83
+ emitJson({ ok: true, command: 'plan done', ...result });
84
+ return EXIT.OK;
85
+ }
86
+ const lines = [`repo ${root}`];
87
+ if (result.dryRun) {
88
+ lines.push(`dry-run ${result.id} ${result.doc} line ${result.line}`);
89
+ lines.push(result.wouldChange ? `would ${result.from} -> ${result.to}` : `state ${result.from}`);
90
+ if (result.remedy) lines.push(`apply ${result.remedy}`);
91
+ } else {
92
+ lines.push(`changed ${result.changed ? 'yes' : 'no'} ${result.id} ${result.doc} line ${result.line}`);
93
+ lines.push(result.from === result.to ? `state ${result.to}` : `state ${result.from} -> ${result.to}`);
94
+ }
95
+ if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
96
+ else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
97
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
98
+ return EXIT.OK;
99
+ }
@@ -14,7 +14,7 @@
14
14
  // one, because "this rule has no decision behind it" is exactly the fact a
15
15
  // reviewer needs and exactly the fact an empty output would hide.
16
16
 
17
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
17
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
18
18
  import { loadRules, select } from '../projection/project.mjs';
19
19
  import { loadMdcRules } from '../projection/mdc.mjs';
20
20
  import { fingerprint } from '../resolve/fingerprint.mjs';
@@ -92,30 +92,25 @@ export async function listCommand(args, flags) {
92
92
  const selection = root ? await selectionFor(rules, root) : null;
93
93
 
94
94
  if (isJson(flags)) {
95
- emit(
96
- `${JSON.stringify(
97
- {
98
- ok: broken.length === 0,
99
- command: 'rules list',
100
- root,
101
- rules: sorted(rules).map((r) => ({
102
- id: r.id,
103
- title: r.title,
104
- origin: r.origin,
105
- source: r.source,
106
- applies: r.applies,
107
- fallback: r.fallback,
108
- bytes: r.bytes,
109
- error: r.error,
110
- adrs: citations(r.body),
111
- selected: selection ? selection.selected.has(r.id) : null,
112
- })),
113
- notes: selection?.notes ?? [],
114
- },
115
- null,
116
- 2,
117
- )}\n`,
118
- { maxBytes: flags.maxBytes ?? 0 },
95
+ emitJson(
96
+ {
97
+ ok: broken.length === 0,
98
+ command: 'rules list',
99
+ root,
100
+ rules: sorted(rules).map((r) => ({
101
+ id: r.id,
102
+ title: r.title,
103
+ origin: r.origin,
104
+ source: r.source,
105
+ applies: r.applies,
106
+ fallback: r.fallback,
107
+ bytes: r.bytes,
108
+ error: r.error,
109
+ adrs: citations(r.body),
110
+ selected: selection ? selection.selected.has(r.id) : null,
111
+ })),
112
+ notes: selection?.notes ?? [],
113
+ },
119
114
  );
120
115
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
121
116
  }
@@ -188,7 +183,7 @@ export async function showCommand(args, flags) {
188
183
 
189
184
  const adrs = citations(rule.body);
190
185
  if (isJson(flags)) {
191
- emit(`${JSON.stringify({ ok: !rule.error, ...rule, adrs }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
186
+ emitJson({ ok: !rule.error, ...rule, adrs }, { maxBytes: flags.maxBytes ?? 0 });
192
187
  return rule.error ? EXIT.FAIL : EXIT.OK;
193
188
  }
194
189
 
@@ -238,21 +233,16 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
238
233
  const index = byId(decisions);
239
234
 
240
235
  if (isJson(flags)) {
241
- emit(
242
- `${JSON.stringify(
243
- {
244
- ok: true,
245
- command: 'rules why',
246
- rule: rule.id,
247
- source: rule.source,
248
- origin: rule.origin,
249
- adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
250
- uncited: cited.length === 0,
251
- },
252
- null,
253
- 2,
254
- )}\n`,
255
- { maxBytes: flags.maxBytes ?? 0 },
236
+ emitJson(
237
+ {
238
+ ok: true,
239
+ command: 'rules why',
240
+ rule: rule.id,
241
+ source: rule.source,
242
+ origin: rule.origin,
243
+ adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
244
+ uncited: cited.length === 0,
245
+ },
256
246
  );
257
247
  return EXIT.OK;
258
248
  }
@@ -313,22 +303,17 @@ async function adrDirection(adr, rules, decisions, flags) {
313
303
  // effective ruling is carried alongside rather than instead: the record asked about
314
304
  // is still named, and what is in force is named separately.
315
305
  const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
316
- emit(
317
- `${JSON.stringify(
318
- {
319
- ok: true,
320
- command: 'rules why',
321
- adr: d,
322
- supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
323
- inForce: replacement
324
- ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
325
- : { id: d.id, status: d.status, summary: d.summary },
326
- rules: restates.map((r) => r.id),
327
- },
328
- null,
329
- 2,
330
- )}\n`,
331
- { maxBytes: flags.maxBytes ?? 0 },
306
+ emitJson(
307
+ {
308
+ ok: true,
309
+ command: 'rules why',
310
+ adr: d,
311
+ supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
312
+ inForce: replacement
313
+ ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
314
+ : { id: d.id, status: d.status, summary: d.summary },
315
+ rules: restates.map((r) => r.id),
316
+ },
332
317
  );
333
318
  return EXIT.OK;
334
319
  }
@@ -97,24 +97,19 @@ export async function indexCommand(args, flags) {
97
97
  const workspace = await workspaceLine(flags);
98
98
 
99
99
  if (isJson(flags)) {
100
- emit(
101
- `${JSON.stringify(
102
- {
103
- ok: true,
104
- workspace,
105
- skills: registry.skills.map((s) => ({
106
- name: s.name,
107
- summary: s.summary,
108
- capabilities: s.capabilities,
109
- stack: s.stack,
110
- bytes: s.bytes,
111
- deferred: Boolean(s.deferred),
112
- })),
113
- },
114
- null,
115
- 2,
116
- )}\n`,
117
- { 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
+ },
118
113
  );
119
114
  return EXIT.OK;
120
115
  }
@@ -168,20 +163,15 @@ async function emitAll(registry, repoStack, flags, warning) {
168
163
  const inline = usable.filter((s) => !s.deferred);
169
164
 
170
165
  if (isJson(flags)) {
171
- emit(
172
- `${JSON.stringify(
173
- {
174
- ok: true,
175
- all: true,
176
- repoStack,
177
- inline: inline.map((s) => s.name),
178
- deferred: deferred.map((s) => s.name),
179
- ...(warning ? { warning } : {}),
180
- },
181
- null,
182
- 2,
183
- )}\n`,
184
- { 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
+ },
185
175
  );
186
176
  return EXIT.OK;
187
177
  }
@@ -217,7 +207,10 @@ export async function findCommand(args, flags) {
217
207
  .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
218
208
 
219
209
  if (isJson(flags)) {
220
- 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
+ );
221
214
  return EXIT.OK;
222
215
  }
223
216
 
@@ -275,24 +268,19 @@ export async function briefCommand(args, flags) {
275
268
  // is what `warning` carries. Failing here would tell a caller the tool
276
269
  // broke when in fact it is telling them their query was.
277
270
  if (flags.all) return emitAll(registry, repoStack, flags, warning);
278
- emit(
279
- `${JSON.stringify(
280
- {
281
- ok: true,
282
- repo: { root, stack: repoStack },
283
- workspace,
284
- task: null,
285
- budget,
286
- resolved: [],
287
- deferred: [],
288
- excluded: [],
289
- warning,
290
- },
291
- null,
292
- 2,
293
- )}\n`,
294
- { maxBytes: flags.maxBytes ?? 0 },
295
- );
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
+ );
296
284
  return EXIT.OK;
297
285
  }
298
286
  emit(
@@ -333,22 +321,17 @@ export async function briefCommand(args, flags) {
333
321
  const lines = [];
334
322
 
335
323
  if (isJson(flags)) {
336
- emit(
337
- `${JSON.stringify(
338
- {
339
- ok: true,
340
- repo: { root, stack: repoStack },
341
- workspace,
342
- task: flags.task ?? null,
343
- budget,
344
- resolved: picked.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes, why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)] })),
345
- deferred: deferred.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes })),
346
- excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
347
- },
348
- null,
349
- 2,
350
- )}\n`,
351
- { 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
+ },
352
335
  );
353
336
  return EXIT.OK;
354
337
  }
@@ -470,10 +453,7 @@ export async function showCommand(args, flags) {
470
453
  const body = await sourcePath(skill, workspaceRoot, root);
471
454
  const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
472
455
  if (isJson(flags)) {
473
- emit(
474
- `${JSON.stringify({ ok: true, ...skill, workspace, body, bodyFrom }, null, 2)}\n`,
475
- { maxBytes: flags.maxBytes ?? 0 },
476
- );
456
+ emitJson({ ok: true, ...skill, workspace, body, bodyFrom }, { maxBytes: flags.maxBytes ?? 0 });
477
457
  return EXIT.OK;
478
458
  }
479
459
  emit(
@@ -640,27 +620,22 @@ export async function evalCommand(positional, flags) {
640
620
  const okRun = unknown.length === 0 && score.clean;
641
621
 
642
622
  if (isJson(flags)) {
643
- emit(
644
- `${JSON.stringify(
645
- {
646
- ok: okRun,
647
- command: 'skills eval',
648
- corpus: corpusPath,
649
- registry: path,
650
- cases: score.cases.map((c) => ({
651
- name: c.name,
652
- selected: c.selected,
653
- missing: c.missing,
654
- unexpected: c.unexpected,
655
- })),
656
- unknownSkills: unknown,
657
- precision: score.precision,
658
- recall: score.recall,
659
- },
660
- null,
661
- 2,
662
- )}\n`,
663
- { maxBytes: flags.maxBytes ?? 0 },
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
+ },
664
639
  );
665
640
  return okRun ? EXIT.OK : EXIT.FAIL;
666
641
  }
@@ -24,7 +24,7 @@ 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';
27
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
28
28
  import { loadRegistry, measureSkill } from '../graph/load.mjs';
29
29
  import { fingerprint } from '../resolve/fingerprint.mjs';
30
30
  import { readCapability } from '../hooks/capability.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
@@ -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