simt-dev 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  3. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  4. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  5. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  6. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  7. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  8. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  9. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  10. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  11. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  12. package/package.json +1 -1
  13. package/rules/core.md +1 -0
  14. package/rules/stacks/android.md +2 -0
  15. package/rules/stacks/cloudflare-workers.md +2 -0
  16. package/rules/stacks/connectiq.md +1 -0
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/dart.md +2 -0
  19. package/rules/stacks/deno.md +2 -0
  20. package/rules/stacks/dotnet.md +2 -0
  21. package/rules/stacks/elixir.md +2 -0
  22. package/rules/stacks/go.md +2 -0
  23. package/rules/stacks/jvm.md +2 -0
  24. package/rules/stacks/node.md +2 -0
  25. package/rules/stacks/python.md +2 -0
  26. package/rules/stacks/react-native.md +2 -0
  27. package/rules/stacks/react-web.md +2 -0
  28. package/rules/stacks/ruby.md +2 -0
  29. package/rules/stacks/rust.md +2 -0
  30. package/rules/stacks/swift.md +2 -0
  31. package/rules/stacks/typescript.md +2 -0
  32. package/rules/stacks/uv.md +2 -0
  33. package/src/argv.mjs +19 -0
  34. package/src/commands/compare.mjs +15 -0
  35. package/src/commands/graph.mjs +45 -21
  36. package/src/commands/harness.mjs +150 -0
  37. package/src/commands/help.mjs +15 -1
  38. package/src/commands/index.mjs +43 -25
  39. package/src/commands/init.mjs +12 -8
  40. package/src/commands/mcp.mjs +20 -5
  41. package/src/commands/rules.mjs +42 -57
  42. package/src/commands/skills.mjs +67 -92
  43. package/src/commands/skillsinstall.mjs +17 -2
  44. package/src/commands/sync.mjs +62 -6
  45. package/src/commands/tokens.mjs +138 -0
  46. package/src/compare/row.mjs +73 -2
  47. package/src/graph/spec.mjs +227 -0
  48. package/src/graph/speccheck.mjs +68 -0
  49. package/src/hooks/dispatcher.mjs +35 -3
  50. package/src/mcp/schema.mjs +139 -0
  51. package/src/mcp/server.mjs +197 -20
  52. package/src/mcp/tools.mjs +288 -1
  53. package/src/measure/context.mjs +371 -0
  54. package/src/monorepo.mjs +339 -0
  55. package/src/output/index.mjs +78 -2
  56. package/src/packs.mjs +182 -0
  57. package/src/projection/manifest.mjs +88 -0
  58. package/src/projection/project.mjs +140 -9
  59. package/src/projection/rules.mjs +288 -20
  60. package/src/projection/surfaces.mjs +133 -15
  61. package/src/projection/targets.mjs +37 -0
@@ -0,0 +1,139 @@
1
+ // A JSON Schema checker, for the subset these schemas use.
2
+ //
3
+ // Written here rather than taken from npm because a tool that validates a payload has
4
+ // to keep working on a machine where nothing was installed (ADR-012), and because the
5
+ // schemas it checks are in this file: a dependency would be a second place for the same
6
+ // rules to live, and the one thing a validator must not be is quietly out of date.
7
+ //
8
+ // The subset is deliberately small and deliberately loud. A keyword this file does not
9
+ // understand is reported as an error rather than skipped, because a keyword silently
10
+ // ignored turns a schema into decoration - the check passes, the payload does not match,
11
+ // and nobody can say which of the two was wrong. `assertValid` is the only entry point
12
+ // the tests use, and it names the failing path.
13
+ //
14
+ // Supported: type (including union arrays), properties, required, items, enum,
15
+ // additionalProperties (boolean or a schema), anyOf.
16
+
17
+ /**
18
+ * Check a value against a schema.
19
+ *
20
+ * @param {unknown} value
21
+ * @param {object} schema
22
+ * @param {string} [path] JSON pointer-ish location, for the message
23
+ * @returns {string|null} the first failure, or null when it validates
24
+ */
25
+ export function check(value, schema, path = '$') {
26
+ if (schema === true || schema === undefined) return null;
27
+ if (schema === false) return `${path}: no value is allowed here`;
28
+ if (typeof schema !== 'object' || schema === null) return `${path}: schema must be an object`;
29
+
30
+ for (const keyword of Object.keys(schema)) {
31
+ if (!SUPPORTED.has(keyword)) {
32
+ // Loud on purpose: see the header. A keyword nobody implements is a promise the
33
+ // schema does not keep, and this is the only place that can notice.
34
+ throw new Error(`schema at ${path} uses "${keyword}", which this checker does not implement`);
35
+ }
36
+ }
37
+
38
+ if (schema.type !== undefined && !typeMatches(value, schema.type)) {
39
+ return `${path}: expected ${describe(schema.type)}, got ${describeValue(value)}`;
40
+ }
41
+ if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => same(allowed, value))) {
42
+ return `${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`;
43
+ }
44
+ if (Array.isArray(schema.anyOf)) {
45
+ const matched = schema.anyOf.some((option) => check(value, option, path) === null);
46
+ if (!matched) return `${path}: matched none of the ${schema.anyOf.length} allowed shapes`;
47
+ }
48
+
49
+ if (schema.type === 'object' || schema.properties !== undefined || schema.required !== undefined) {
50
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
51
+ for (const key of schema.required ?? []) {
52
+ if (!(key in value)) return `${path}: missing required property "${key}"`;
53
+ }
54
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
55
+ if (!(key in value)) continue;
56
+ const failure = check(value[key], sub, `${path}.${key}`);
57
+ if (failure !== null) return failure;
58
+ }
59
+ if (schema.additionalProperties !== undefined) {
60
+ const known = new Set(Object.keys(schema.properties ?? {}));
61
+ for (const [key, sub] of Object.entries(value)) {
62
+ if (known.has(key)) continue;
63
+ if (schema.additionalProperties === false) return `${path}: unexpected property "${key}"`;
64
+ if (schema.additionalProperties !== true) {
65
+ const failure = check(sub, schema.additionalProperties, `${path}.${key}`);
66
+ if (failure !== null) return failure;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ if (schema.items !== undefined && Array.isArray(value)) {
73
+ for (const [index, item] of value.entries()) {
74
+ const failure = check(item, schema.items, `${path}[${index}]`);
75
+ if (failure !== null) return failure;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Throws with the first failure, named. The shape a test wants. */
82
+ export function assertValid(value, schema, what = 'value') {
83
+ const failure = check(value, schema);
84
+ if (failure !== null) throw new Error(`${what} does not match its schema: ${failure}`);
85
+ return true;
86
+ }
87
+
88
+ const SUPPORTED = new Set([
89
+ 'type',
90
+ 'properties',
91
+ 'required',
92
+ 'items',
93
+ 'enum',
94
+ 'additionalProperties',
95
+ 'anyOf',
96
+ 'description',
97
+ '$schema',
98
+ ]);
99
+
100
+ function typeMatches(value, type) {
101
+ const types = Array.isArray(type) ? type : [type];
102
+ return types.some((t) => singleTypeMatches(value, t));
103
+ }
104
+
105
+ function singleTypeMatches(value, type) {
106
+ switch (type) {
107
+ case 'object':
108
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
109
+ case 'array':
110
+ return Array.isArray(value);
111
+ case 'string':
112
+ return typeof value === 'string';
113
+ case 'number':
114
+ return typeof value === 'number' && Number.isFinite(value);
115
+ case 'integer':
116
+ return Number.isInteger(value);
117
+ case 'boolean':
118
+ return typeof value === 'boolean';
119
+ case 'null':
120
+ return value === null;
121
+ default:
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function describe(type) {
127
+ return Array.isArray(type) ? type.join('|') : type;
128
+ }
129
+
130
+ /** What a value IS, for the failure message. Distinct from `describe(type)`. */
131
+ function describeValue(value) {
132
+ if (value === null) return 'null';
133
+ if (Array.isArray(value)) return 'array';
134
+ return typeof value;
135
+ }
136
+
137
+ function same(a, b) {
138
+ return JSON.stringify(a) === JSON.stringify(b);
139
+ }
@@ -22,7 +22,24 @@
22
22
  import { createInterface } from 'node:readline';
23
23
  import { CLI } from '../name.mjs';
24
24
 
25
- export const PROTOCOL_VERSION = '2025-06-18';
25
+ /**
26
+ * Every protocol revision this server speaks, newest first.
27
+ *
28
+ * The list is a claim about revisions that were read, not about ones that exist: each
29
+ * entry names a published revision and the behaviour this file implements for it, and
30
+ * ADR-068 records what changed. A revision not in this list is answered with
31
+ * `UnsupportedProtocolVersionError` rather than an optimistic guess, because the failure
32
+ * mode of guessing is a client that believes a feature is on and watches it misbehave.
33
+ */
34
+ export const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18'];
35
+
36
+ /**
37
+ * The revision this server answers with when the client has not asked for one.
38
+ *
39
+ * Named the way every caller has always named it, and it is the newest supported
40
+ * revision rather than the only one: a client that asks for an older one gets it.
41
+ */
42
+ export const PROTOCOL_VERSION = SUPPORTED_VERSIONS[0];
26
43
 
27
44
  /** JSON-RPC codes. -32700/-32600/-32601 are the spec's parse/request/method set. */
28
45
  export const RPC = {
@@ -30,16 +47,60 @@ export const RPC = {
30
47
  REQUEST: -32600,
31
48
  METHOD: -32601,
32
49
  INTERNAL: -32603,
50
+ /**
51
+ * `UnsupportedProtocolVersionError`, renumbered to -32022 in 2026-07-28. The spec
52
+ * reserves -32000..-32019 for implementations and -32020..-32099 for itself, so the
53
+ * older -32004 would now be an implementation-defined code in the wrong range.
54
+ */
55
+ UNSUPPORTED_VERSION: -32022,
56
+ /** Resource not found: -32002 in every revision up to 2025-11-25, -32602 after. */
57
+ RESOURCE_NOT_FOUND_LEGACY: -32002,
58
+ RESOURCE_NOT_FOUND: -32602,
59
+ };
60
+
61
+ /** The `_meta` keys 2026-07-28 puts on a request and in a result. */
62
+ export const META = {
63
+ PROTOCOL_VERSION: 'io.modelcontextprotocol/protocolVersion',
64
+ CLIENT_INFO: 'io.modelcontextprotocol/clientInfo',
65
+ CLIENT_CAPABILITIES: 'io.modelcontextprotocol/clientCapabilities',
66
+ SERVER_INFO: 'io.modelcontextprotocol/serverInfo',
33
67
  };
34
68
 
69
+ /** Revisions that removed `ping`, per the 2026-07-28 changelog (SEP-2575). */
70
+ const PING_REMOVED_IN = '2026-07-28';
71
+
35
72
  const SERVER_INFO = { name: 'simt-dev', title: 'SIMT developer and agent tooling' };
36
73
 
74
+ /**
75
+ * One hour, and the reason it is a number rather than an omission.
76
+ *
77
+ * `ttlMs` and `cacheScope` are required on list results from 2026-07-28, and the value
78
+ * is a fact about this server rather than a courtesy: the tool list changes when the
79
+ * release changes and not otherwise, so an hour is long enough to spare a client the
80
+ * round trip and short enough that a client started from a stale one re-reads it within
81
+ * a working day. `private`, because the list names the reader's own repository and no
82
+ * intermediary may hold it.
83
+ */
84
+ const LIST_CACHE = { ttlMs: 3600000, cacheScope: 'private' };
85
+
37
86
  /**
38
87
  * Handle one parsed request. Pure with respect to the process: returns the response
39
88
  * object or null (for a notification), and never writes anywhere. Testable without a
40
89
  * pipe, which is the only way to test a line protocol honestly.
90
+ *
91
+ * `state` is the negotiated revision, and it is the only thing this function mutates.
92
+ * 2026-07-28 removed the handshake and made every request carry its own version, so a
93
+ * session is not part of the protocol any more - but a client on an older revision
94
+ * negotiates once and then sends bare requests, and answering those at the newest
95
+ * revision would hand it fields it did not ask for. So the version is read from the
96
+ * request when there is one and remembered only for the clients that need it.
97
+ *
98
+ * @param {object|null} request a parsed JSON-RPC message
99
+ * @param {object[]} tools
100
+ * @param {{packageVersion?: string, state?: {version: string|null}}} options
101
+ * @returns {Promise<object|null>}
41
102
  */
42
- export async function handle(request, tools, { version } = {}) {
103
+ export async function handle(request, tools, { packageVersion, state } = {}) {
43
104
  if (request === null || typeof request !== 'object' || Array.isArray(request)) {
44
105
  return error(null, RPC.REQUEST, 'request must be a JSON object');
45
106
  }
@@ -54,25 +115,68 @@ export async function handle(request, tools, { version } = {}) {
54
115
  }
55
116
  if (isNotification) return null;
56
117
 
118
+ const asked = params?._meta?.[META.PROTOCOL_VERSION];
119
+ if (typeof asked === 'string' && !SUPPORTED_VERSIONS.includes(asked)) {
120
+ // The one place a client checks whether it got what it asked for, so this is where
121
+ // an unsupported version has to fail. The message names the revisions that do work,
122
+ // because "unsupported" alone leaves the client to guess between the two answers a
123
+ // server can give.
124
+ return error(
125
+ id,
126
+ RPC.UNSUPPORTED_VERSION,
127
+ `protocol version ${asked} is not supported; this server speaks ${SUPPORTED_VERSIONS.join(', ')}`,
128
+ );
129
+ }
130
+ const negotiated =
131
+ typeof asked === 'string' ? asked : method === 'initialize' ? negotiate(params?.protocolVersion) : state?.version;
132
+ // A caller that passes no `state` gets one request with no memory: `serve` threads one
133
+ // through the stream, and a test that calls this directly usually wants exactly that.
134
+ const session = state ?? { version: null };
135
+ if (method === 'initialize') session.version = negotiated;
136
+ const v = negotiated ?? PROTOCOL_VERSION;
137
+
138
+ const reply = (value) => result(id, decorate(value, v, { packageVersion, tools }));
139
+
57
140
  switch (method) {
58
141
  case 'initialize':
59
- return result(id, {
60
- protocolVersion: negotiate(params?.protocolVersion),
61
- capabilities: { tools: { listChanged: false } },
62
- serverInfo: { ...SERVER_INFO, version: version ?? '0.0.0' },
63
- instructions:
64
- `Read-only. Every answer is JSON. \`${CLI} skills brief\` is the one to call first: it returns the pre-assembled set for a task, plus what it deliberately left out and why.`,
142
+ return reply({
143
+ protocolVersion: v,
144
+ capabilities: capabilities(tools),
145
+ // `serverInfo` stays in the body here even on 2026-07-28: a client that has just
146
+ // called a method the newest revision removed is by definition an older client,
147
+ // and the field is where it looks.
148
+ serverInfo: serverInfo(packageVersion),
149
+ instructions: INSTRUCTIONS,
65
150
  });
66
151
 
67
152
  case 'notifications/initialized':
68
153
  case 'initialized':
69
154
  return null;
70
155
 
156
+ // MUST, from 2026-07-28 on. Cheap, stateless, and the stdio way for a client that
157
+ // supports both shapes to find out which one it is talking to before it commits.
158
+ case 'server/discover':
159
+ return reply({
160
+ supportedVersions: [...SUPPORTED_VERSIONS],
161
+ capabilities: capabilities(tools),
162
+ instructions: INSTRUCTIONS,
163
+ ...(cacheable(v) ? LIST_CACHE : {}),
164
+ });
165
+
71
166
  case 'ping':
72
- return result(id, {});
167
+ // Removed in 2026-07-28, and kept before it: a client that speaks an older
168
+ // revision and probes liveness gets an answer, and one that speaks the newest
169
+ // gets told the method is gone rather than a success it would read as support.
170
+ if (atLeast(v, PING_REMOVED_IN)) {
171
+ return error(id, RPC.METHOD, `ping was removed in ${PING_REMOVED_IN}; this session negotiated ${v}`);
172
+ }
173
+ return reply({});
73
174
 
74
175
  case 'tools/list':
75
- return result(id, { tools: tools.map(describe) });
176
+ return reply({
177
+ tools: tools.map((t) => describe(t, v)),
178
+ ...(cacheable(v) ? LIST_CACHE : {}),
179
+ });
76
180
 
77
181
  case 'tools/call': {
78
182
  const name = params?.name;
@@ -85,12 +189,21 @@ export async function handle(request, tools, { version } = {}) {
85
189
  const args = params?.arguments ?? {};
86
190
  try {
87
191
  const payload = await tool.run(args);
88
- return result(id, { content: [contentOf(payload)], isError: false });
89
- } catch (failure) {
90
- return result(id, {
91
- content: [contentOf({ error: failure.message, remedy: failure.remedy ?? null })],
92
- isError: true,
192
+ // Both blocks, always. `structuredContent` is what a client validates against
193
+ // `outputSchema` and what it can index; the text block is what a client that
194
+ // predates structured output reads. Emitting one without the other leaves half
195
+ // the clients guessing, which is the state this release was filed for.
196
+ return reply({
197
+ content: [contentOf(payload)],
198
+ structuredContent: payload,
199
+ isError: false,
93
200
  });
201
+ } catch (failure) {
202
+ // An error is a result too, and it carries the same two shapes: the remedy is
203
+ // the part a model needs to act on, and it has to survive in whichever block the
204
+ // client reads.
205
+ const payload = { error: failure.message, remedy: failure.remedy ?? null };
206
+ return reply({ content: [contentOf(payload)], structuredContent: payload, isError: true });
94
207
  }
95
208
  }
96
209
 
@@ -99,6 +212,22 @@ export async function handle(request, tools, { version } = {}) {
99
212
  }
100
213
  }
101
214
 
215
+ const INSTRUCTIONS =
216
+ `Read-only. Every answer is JSON. \`${CLI} skills brief\` is the one to call first: it returns the pre-assembled set for a task, plus what it deliberately left out and why.`;
217
+
218
+ function serverInfo(packageVersion) {
219
+ return { ...SERVER_INFO, version: packageVersion ?? '0.0.0' };
220
+ }
221
+
222
+ function capabilities(tools) {
223
+ return {
224
+ tools: { listChanged: false },
225
+ // Declared only when there is something to read. A capability with no method behind
226
+ // it is a promise a client will keep waiting on.
227
+ ...(tools.resources ? { resources: { listChanged: false, subscribe: false } } : {}),
228
+ };
229
+ }
230
+
102
231
  /**
103
232
  * Echo the client's protocol version when it is one this server implements.
104
233
  *
@@ -107,16 +236,58 @@ export async function handle(request, tools, { version } = {}) {
107
236
  * fails loudly at the one place the client checks.
108
237
  */
109
238
  function negotiate(requested) {
110
- const SUPPORTED = new Set([PROTOCOL_VERSION]);
111
- return typeof requested === 'string' && SUPPORTED.has(requested) ? requested : PROTOCOL_VERSION;
239
+ return typeof requested === 'string' && SUPPORTED_VERSIONS.includes(requested)
240
+ ? requested
241
+ : PROTOCOL_VERSION;
112
242
  }
113
243
 
114
- function describe(tool) {
244
+ /** Revision ordering. These are dates, so lexicographic is chronological - stated here
245
+ * because "sort a version string" is the kind of helper that gets rewritten by someone
246
+ * who thinks it is a number, and this one is right. */
247
+ function atLeast(version, floor) {
248
+ return version >= floor;
249
+ }
250
+
251
+ /** 2026-07-28 requires `ttlMs` and `cacheScope` on list results; earlier ones ignore them. */
252
+ function cacheable(version) {
253
+ return atLeast(version, '2026-07-28');
254
+ }
255
+
256
+ /**
257
+ * What the newest revision adds to every result.
258
+ *
259
+ * `resultType` is required from 2026-07-28 and is absent before it — clients on older
260
+ * revisions treat it as an unknown field, and clients MUST read a missing one as
261
+ * `complete`, so emitting it unconditionally is the compatible half and omitting it on
262
+ * the new revision is not. `_meta.serverInfo` is a SHOULD, and it is how a stateless
263
+ * client learns who it is talking to now that `initialize` is gone.
264
+ */
265
+ function decorate(value, version, { packageVersion }) {
266
+ if (!atLeast(version, '2026-07-28')) return value;
115
267
  return {
268
+ resultType: 'complete',
269
+ ...value,
270
+ _meta: { [META.SERVER_INFO]: serverInfo(packageVersion) },
271
+ };
272
+ }
273
+
274
+ function describe(tool, version) {
275
+ const entry = {
116
276
  name: tool.name,
117
277
  description: tool.description,
118
278
  inputSchema: tool.inputSchema,
279
+ // Every tool here is a reader: none writes, and none writes twice. Saying so lets a
280
+ // client cache the answer and, where it offers, let the model call without a
281
+ // confirmation step. The declaration is per tool rather than global because the next
282
+ // tool this file gains may not be one.
283
+ annotations: { readOnlyHint: true, idempotentHint: true, ...(tool.annotations ?? {}) },
119
284
  };
285
+ // `outputSchema` exists in all three revisions, and a tool that declares its input
286
+ // shape but not its output shape leaves the client guessing what a successful answer
287
+ // looks like - which is the same defect as a returned value nothing can be checked
288
+ // against.
289
+ if (tool.outputSchema) entry.outputSchema = tool.outputSchema;
290
+ return entry;
120
291
  }
121
292
 
122
293
  /** Content is one text block. Bounded, and truncation is announced in the text itself -
@@ -146,15 +317,21 @@ function error(id, code, message) {
146
317
  * Lines are processed strictly in order. A server that interleaves responses would let a
147
318
  * client match the wrong answer to the wrong question, and the tools here are already
148
319
  * sequential and cheap - parallelising them would buy nothing and cost correctness.
320
+ *
321
+ * One `state` for the whole stream, holding the revision a client negotiated through
322
+ * `initialize`. That is the residue of a handshake the 2026-07-28 revision removed, kept
323
+ * only because a client on an older revision sends bare requests afterwards and expects
324
+ * the answers it agreed to.
149
325
  */
150
- export async function serve(tools, { input = process.stdin, output = process.stdout, version, onError } = {}) {
326
+ export async function serve(tools, { input = process.stdin, output = process.stdout, packageVersion, onError } = {}) {
151
327
  const rl = createInterface({ input, crlfDelay: Infinity });
328
+ const state = { version: null };
152
329
  for await (const line of rl) {
153
330
  if (line.trim() === '') continue;
154
331
 
155
332
  let response;
156
333
  try {
157
- response = await handle(JSON.parse(line), tools, { version });
334
+ response = await handle(JSON.parse(line), tools, { packageVersion, state });
158
335
  } catch (parseFailure) {
159
336
  response = error(null, RPC.PARSE, `invalid JSON: ${parseFailure.message}`);
160
337
  }