simt-dev 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  3. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  4. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  5. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  6. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  7. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  8. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  9. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  10. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  11. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  12. package/package.json +1 -1
  13. package/rules/core.md +1 -0
  14. package/rules/stacks/android.md +2 -0
  15. package/rules/stacks/cloudflare-workers.md +2 -0
  16. package/rules/stacks/connectiq.md +1 -0
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/dart.md +2 -0
  19. package/rules/stacks/deno.md +2 -0
  20. package/rules/stacks/dotnet.md +2 -0
  21. package/rules/stacks/elixir.md +2 -0
  22. package/rules/stacks/go.md +2 -0
  23. package/rules/stacks/jvm.md +2 -0
  24. package/rules/stacks/node.md +2 -0
  25. package/rules/stacks/python.md +2 -0
  26. package/rules/stacks/react-native.md +2 -0
  27. package/rules/stacks/react-web.md +2 -0
  28. package/rules/stacks/ruby.md +2 -0
  29. package/rules/stacks/rust.md +2 -0
  30. package/rules/stacks/swift.md +2 -0
  31. package/rules/stacks/typescript.md +2 -0
  32. package/rules/stacks/uv.md +2 -0
  33. package/src/argv.mjs +19 -0
  34. package/src/commands/compare.mjs +15 -0
  35. package/src/commands/graph.mjs +45 -21
  36. package/src/commands/harness.mjs +150 -0
  37. package/src/commands/help.mjs +15 -1
  38. package/src/commands/index.mjs +43 -25
  39. package/src/commands/init.mjs +12 -8
  40. package/src/commands/mcp.mjs +20 -5
  41. package/src/commands/rules.mjs +42 -57
  42. package/src/commands/skills.mjs +67 -92
  43. package/src/commands/skillsinstall.mjs +17 -2
  44. package/src/commands/sync.mjs +62 -6
  45. package/src/commands/tokens.mjs +138 -0
  46. package/src/compare/row.mjs +73 -2
  47. package/src/graph/spec.mjs +227 -0
  48. package/src/graph/speccheck.mjs +68 -0
  49. package/src/hooks/dispatcher.mjs +35 -3
  50. package/src/mcp/schema.mjs +139 -0
  51. package/src/mcp/server.mjs +197 -20
  52. package/src/mcp/tools.mjs +288 -1
  53. package/src/measure/context.mjs +371 -0
  54. package/src/monorepo.mjs +339 -0
  55. package/src/output/index.mjs +78 -2
  56. package/src/packs.mjs +182 -0
  57. package/src/projection/manifest.mjs +88 -0
  58. package/src/projection/project.mjs +140 -9
  59. package/src/projection/rules.mjs +288 -20
  60. package/src/projection/surfaces.mjs +133 -15
  61. package/src/projection/targets.mjs +37 -0
@@ -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
  }
@@ -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
 
@@ -16,8 +16,8 @@
16
16
  // three `.mdc` rules would let a reader believe a repo is fully current when
17
17
  // only the file they happened to open is.
18
18
 
19
- import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises';
20
- import { dirname, join } from 'node:path';
19
+ import { mkdir, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises';
20
+ import { dirname, join, resolve, sep } from 'node:path';
21
21
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
22
22
  import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
23
23
  import { fingerprint } from '../resolve/fingerprint.mjs';
@@ -27,6 +27,7 @@ import { surfacesLine } from '../projection/surfaces.mjs';
27
27
  import { CLI } from '../name.mjs';
28
28
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
29
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
+ import { resolvePacks } from '../packs.mjs';
30
31
 
31
32
  /** Findings `sync` reports and does not fail on.
32
33
  *
@@ -48,7 +49,7 @@ import { assertVersionPin, packageVersion } from '../package-version.mjs';
48
49
  * people learn to ignore. A *foreign* one — a real file or a real directory where the
49
50
  * pointer belongs — is reported loudly with what is in the way, because that one is a
50
51
  * decision somebody has to make. */
51
- export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
52
+ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
52
53
 
53
54
  /**
54
55
  * Findings `check` reports and does not fail on.
@@ -68,7 +69,20 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
68
69
  *
69
70
  * `orphaned` stays non-blocking in both. Nothing can satisfy it, in either command.
70
71
  */
71
- export const CHECK_NON_BLOCKING = new Set(['orphaned']);
72
+ // `pack-unpinned` is here and `pack-digest-mismatch` is not, which is the whole
73
+ // judgement. An unpinned pack still renders its rules correctly; what is missing
74
+ // is the record of which bytes were declared, and failing a gate over a field the
75
+ // tool does not require would fail every repository that vendored a pack and
76
+ // pinned it later. A pack whose bytes do not match its pin renders a region
77
+ // nobody can account for, so that one blocks.
78
+ //
79
+ // `budget` is here for the opposite reason, and it is a ruling rather than a patch
80
+ // (ADR-072). An over-budget finding says the projection is bigger than this repository
81
+ // said it would be, and the remedy is a decision about which rules to narrow — not a
82
+ // missing file. Nothing is cut: the projection is still in full mode, so a gate that
83
+ // failed here would be failing a repository for exceeding a ceiling it set itself,
84
+ // with no way to fix it except lowering the number it wrote down.
85
+ export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget']);
72
86
 
73
87
  export async function syncCommand(positional, flags) {
74
88
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -399,8 +413,43 @@ async function formatterIgnoresManifest(root) {
399
413
  .some((line) => line !== '' && !line.startsWith('#') && (line === MANIFEST_FILE || line === MANIFEST_LEGACY));
400
414
  }
401
415
 
402
- export async function checkCommand(positional, flags) {
403
- const root = flags.root ?? positional[0] ?? process.cwd();
416
+ /**
417
+ * `gates.cwd` entries that do not resolve to a directory inside the repository.
418
+ *
419
+ * Reported here, not only where the gate runs: the dispatcher fails closed on the same
420
+ * condition, so a reader finds out on their next commit — after writing a commit, which
421
+ * is the wrong order. The finding names the gate and the path, and says which of the two
422
+ * things is wrong, because "no such directory" and "outside the repository" are repaired
423
+ * by different edits.
424
+ *
425
+ * @returns {Promise<{file: string, status: string, gate: string, detail: string, remedy: string}[]>}
426
+ */
427
+ async function gateCwdFindings(root, manifest) {
428
+ const cwds = manifest?.gates?.cwd;
429
+ if (!cwds || typeof cwds !== 'object' || Array.isArray(cwds)) return [];
430
+ const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
431
+ const findings = [];
432
+ for (const name of Object.keys(cwds).sort()) {
433
+ if (!declared.includes(name)) continue;
434
+ const dir = String(cwds[name]);
435
+ const resolved = resolve(root, dir);
436
+ const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
437
+ const isDir = inside && ((await stat(resolved).catch(() => null))?.isDirectory() ?? false);
438
+ if (isDir) continue;
439
+ findings.push({
440
+ file: MANIFEST_FILE,
441
+ status: inside ? 'gate-cwd-missing' : 'gate-cwd-outside',
442
+ gate: name,
443
+ detail: inside
444
+ ? `gates.cwd["${name}"] is ${dir}, which is not a directory in this repository`
445
+ : `gates.cwd["${name}"] is ${dir}, which resolves outside the repository`,
446
+ remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
447
+ });
448
+ }
449
+ return findings;
450
+ }
451
+
452
+ export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
404
453
  const findings = await checkTargets(root, flags);
405
454
  const rules = await loadRules(root);
406
455
 
@@ -431,6 +480,13 @@ export async function checkCommand(positional, flags) {
431
480
  findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
432
481
  }
433
482
  findings.push(...unrunnableGates(manifest, await readCapability(root)));
483
+ findings.push(...(await gateCwdFindings(root, manifest)));
484
+ // A pack the repository declares but cannot produce is reported here rather
485
+ // than when a rule happens to be missing, because that is the failure mode
486
+ // this guards: the region still renders, it renders SHORT, and `check` would
487
+ // pass. A reader whose rules were quietly halved is worse off than one whose
488
+ // gate is red, so this is a blocking finding, not a note.
489
+ findings.push(...(await resolvePacks(root)).findings);
434
490
  // A manifest a formatter has rewritten is not wrong, and not editable-away by
435
491
  // hand either: the next `init` puts simt-dev's own bytes back and the formatter
436
492
  // takes them away again. Reporting it here is what stops that being a surprise