simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -0,0 +1,188 @@
1
+ // The federation client: one stdio MCP conversation, spoken directly (plan 0003 R26).
2
+ //
3
+ // simt-dev carries no runtime dependency, and an external code-graph server speaks MCP over
4
+ // stdio, which is newline-delimited JSON-RPC. That is a shape small enough to speak without
5
+ // a client library, so this file is the whole client: start the server, initialize, ask one
6
+ // tool for one text answer, and return that answer or the reason there is none.
7
+ //
8
+ // Two rules make it safe to put inside a brief:
9
+ //
10
+ // * It never throws. A command that is not installed, a server that exits, one that
11
+ // answers with a protocol error, one that never answers at all — every one of them
12
+ // comes back as `{ error }` for the caller to render as a sentence. A brief that died
13
+ // because an optional server misbehaved would be a brief nobody keeps installed.
14
+ // * It always cleans up, and it always ends. The child is killed on the first answer, on
15
+ // an error and on the timeout, and the timeout is a real bound on a server that has
16
+ // forgotten how to read. Federation adds a wait with a ceiling, never an open one.
17
+ //
18
+ // The conversation is exactly three messages deep, which is the minimum MCP allows:
19
+ // `initialize`, the `notifications/initialized` acknowledgement, then one `tools/call`.
20
+
21
+ import { spawn } from 'node:child_process';
22
+
23
+ /**
24
+ * The protocol revision this client opens with.
25
+ *
26
+ * Not this tool's newest — an external server predates it far more often than not — but the
27
+ * oldest revision every MCP server of the current generation is required to accept. A
28
+ * server that answers with a revision of its own is answering, and the reply is not
29
+ * second-guessed: this is a client for asking, not for negotiating.
30
+ */
31
+ const PROTOCOL_VERSION = '2025-06-18';
32
+
33
+ /** The longest a single server may take before the client stops waiting, in milliseconds. */
34
+ export const DEFAULT_TIMEOUT_MS = 5000;
35
+
36
+ const CLIENT_INFO = { name: 'simt-dev', version: '1' };
37
+
38
+ /** The first non-blank text part of a result, or null when the result carries none. */
39
+ function textOf(result) {
40
+ if (result === null || typeof result !== 'object') return null;
41
+ const content = Array.isArray(result.content) ? result.content : [];
42
+ for (const part of content) {
43
+ if (part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
44
+ if (part.text.trim().length > 0) return part.text;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** One sentence for a JSON-RPC error object. */
51
+ function reasonOf(error) {
52
+ if (error === null || typeof error !== 'object') return 'the server returned an error';
53
+ if (typeof error.message === 'string' && error.message.length > 0) return error.message;
54
+ if (error.code !== undefined) return `the server answered with error code ${error.code}`;
55
+ return 'the server returned an error';
56
+ }
57
+
58
+ /**
59
+ * Ask one server, over stdio, for one text answer to one question.
60
+ *
61
+ * @param {object} request
62
+ * @param {string} request.command the executable to start
63
+ * @param {string[]} [request.args] its arguments
64
+ * @param {string} [request.cwd] the directory to start it in
65
+ * @param {string} request.tool the MCP tool to call
66
+ * @param {string} [request.argument] the argument the question is placed in
67
+ * @param {string} request.query the question
68
+ * @param {number} [request.timeoutMs] how long to wait, in milliseconds
69
+ * @returns `{ text }` on an answer, `{ error }` on anything else — never a rejection
70
+ */
71
+ export async function callTool({
72
+ command,
73
+ args = [],
74
+ cwd = undefined,
75
+ tool,
76
+ argument = 'query',
77
+ query,
78
+ timeoutMs = DEFAULT_TIMEOUT_MS,
79
+ }) {
80
+ return new Promise((resolve) => {
81
+ let child;
82
+ try {
83
+ child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd });
84
+ } catch (error) {
85
+ resolve({ error: `could not start \`${command}\` (${error.message})` });
86
+ return;
87
+ }
88
+
89
+ let settled = false;
90
+ let buffer = '';
91
+ let nextId = 1;
92
+ const pending = new Map();
93
+
94
+ const finish = (payload) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ try {
99
+ child.stdin.end();
100
+ } catch {
101
+ // The pipe may already be gone; nothing to close in that case.
102
+ }
103
+ try {
104
+ child.kill('SIGKILL');
105
+ } catch {
106
+ // A process that has already exited cannot be killed again.
107
+ }
108
+ resolve(payload);
109
+ };
110
+
111
+ const timer = setTimeout(() => finish({ error: `no answer within ${timeoutMs} ms` }), timeoutMs);
112
+
113
+ child.on('error', (error) => finish({ error: `could not start \`${command}\` (${error.message})` }));
114
+ child.on('exit', (code) => {
115
+ if (!settled) finish({ error: `the server exited with code ${code} before answering` });
116
+ });
117
+ // stderr is not this client's to interpret, but it has to be read: an unread pipe fills
118
+ // and a server blocked on its own diagnostics never reaches the answer.
119
+ child.stderr.on('data', () => {});
120
+ child.stdin.on('error', () => {});
121
+
122
+ child.stdout.on('data', (chunk) => {
123
+ buffer += chunk.toString('utf8');
124
+ let index = buffer.indexOf('\n');
125
+ while (index !== -1) {
126
+ const line = buffer.slice(0, index).trim();
127
+ buffer = buffer.slice(index + 1);
128
+ if (line.length > 0) {
129
+ let message;
130
+ try {
131
+ message = JSON.parse(line);
132
+ } catch {
133
+ message = null;
134
+ }
135
+ const id = message !== null && typeof message === 'object' ? message.id : undefined;
136
+ const waiter = id !== undefined ? pending.get(id) : undefined;
137
+ if (waiter !== undefined) {
138
+ pending.delete(id);
139
+ waiter(message);
140
+ }
141
+ }
142
+ index = buffer.indexOf('\n');
143
+ }
144
+ });
145
+
146
+ const request = (method, params) =>
147
+ new Promise((done) => {
148
+ const id = nextId;
149
+ nextId += 1;
150
+ pending.set(id, done);
151
+ try {
152
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
153
+ } catch (error) {
154
+ pending.delete(id);
155
+ finish({ error: `could not talk to \`${command}\` (${error.message})` });
156
+ }
157
+ });
158
+
159
+ const notify = (method, params) => {
160
+ try {
161
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
162
+ } catch {
163
+ // A notification that cannot be written is a server that is already gone, and the
164
+ // exit handler reports that with a better sentence than this one could.
165
+ }
166
+ };
167
+
168
+ (async () => {
169
+ await request('initialize', {
170
+ protocolVersion: PROTOCOL_VERSION,
171
+ capabilities: {},
172
+ clientInfo: CLIENT_INFO,
173
+ });
174
+ notify('notifications/initialized', {});
175
+ const answer = await request('tools/call', { name: tool, arguments: { [argument]: query } });
176
+ if (answer.error !== undefined) {
177
+ finish({ error: reasonOf(answer.error) });
178
+ return;
179
+ }
180
+ const text = textOf(answer.result);
181
+ if (text === null) {
182
+ finish({ error: `\`${tool}\` returned no text` });
183
+ return;
184
+ }
185
+ finish({ text });
186
+ })().catch((error) => finish({ error: error.message }));
187
+ });
188
+ }
@@ -0,0 +1,134 @@
1
+ // Federation configuration: the servers a repository names, and nothing else (plan 0003 R26).
2
+ //
3
+ // A brief is deterministic by default. Federation is the one opt-in exception, and the
4
+ // opt-in is a `federation` block in the repository's own manifest. Nothing here probes a
5
+ // harness config, scans for a server that might exist, or opens a socket the manifest did
6
+ // not ask for — a repository that declared nothing pays nothing and reads nothing. Where
7
+ // a harness would keep the same names is R42's several slices away, and it is a different
8
+ // question: this one is "what did the repository commit", not "what did the editor
9
+ // configure".
10
+ //
11
+ // The trust model is the one `gates.commands` already has. Those are commands a repository
12
+ // declares and this tool runs; a federation server is the same declaration one layer up.
13
+ // The manifest is the file a person reviews before they commit the change, so a command in
14
+ // it is a command the repository meant to run.
15
+ //
16
+ // The block is deliberately small, because every field in it is a promise the packet has
17
+ // to keep:
18
+ //
19
+ // "federation": {
20
+ // "servers": [
21
+ // {
22
+ // "name": "serena", // required, unique; the name the packet cites
23
+ // "command": "uvx", // required; started with stdio pipes
24
+ // "args": ["serena", "start-mcp-server"],
25
+ // "tool": "find_symbol", // optional; the MCP tool to call, default "query"
26
+ // "argument": "query", // optional; where the question goes, default "query"
27
+ // "languages": ["python"], // optional; the coverage the packet records
28
+ // "timeoutMs": 4000 // optional; the wait, default 5000
29
+ // }
30
+ // ]
31
+ // }
32
+ //
33
+ // An entry that breaks the shape is not a crash and not a silent skip. It becomes a finding
34
+ // with a sentence in it, the brief turns that into a caution, and every other entry still
35
+ // runs. A malformed block costs the reader one line, which is exactly what they need to fix
36
+ // it — and a silent skip is the failure that line exists to prevent.
37
+
38
+ import { readManifest } from '../projection/manifest.mjs';
39
+ import { DEFAULT_TIMEOUT_MS } from './client.mjs';
40
+
41
+ /** The tool a server is asked for when it does not name one. */
42
+ const DEFAULT_TOOL = 'query';
43
+
44
+ /** The argument the question is placed in when a server does not name one. */
45
+ const DEFAULT_ARGUMENT = 'query';
46
+
47
+ /** A list of strings, or null when the value is anything else. */
48
+ function strings(value) {
49
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string') ? [...value] : null;
50
+ }
51
+
52
+ /** A non-empty trimmed string, or the empty string. */
53
+ function word(value) {
54
+ return typeof value === 'string' ? value.trim() : '';
55
+ }
56
+
57
+ /** One declared server, or the sentence explaining why it is not one. */
58
+ function serverOf(entry, seen) {
59
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
60
+ return { finding: 'a federation server must be an object' };
61
+ }
62
+ const name = word(entry.name);
63
+ if (name.length === 0) return { finding: 'a federation server needs a `name`' };
64
+ // The name is claimed before the rest is checked, so a rejected entry still reserves it:
65
+ // two entries called `serena` are a mistake whether or not the first one is also
66
+ // malformed, and a duplicate that only surfaced once both were valid would be the harder
67
+ // mistake to see.
68
+ if (seen.has(name)) return { finding: `two federation servers are named \`${name}\`` };
69
+ seen.add(name);
70
+
71
+ const command = word(entry.command);
72
+ if (command.length === 0) return { finding: `the \`${name}\` server needs a \`command\`` };
73
+
74
+ const args = entry.args === undefined ? [] : strings(entry.args);
75
+ if (args === null) return { finding: `the \`${name}\` server's \`args\` must be a list of strings` };
76
+ const languages = entry.languages === undefined ? [] : strings(entry.languages);
77
+ if (languages === null) return { finding: `the \`${name}\` server's \`languages\` must be a list of strings` };
78
+
79
+ const tool = entry.tool === undefined ? DEFAULT_TOOL : word(entry.tool);
80
+ if (tool.length === 0) return { finding: `the \`${name}\` server's \`tool\` must be a tool name` };
81
+ const argument = entry.argument === undefined ? DEFAULT_ARGUMENT : word(entry.argument);
82
+ if (argument.length === 0) return { finding: `the \`${name}\` server's \`argument\` must be an argument name` };
83
+
84
+ const timeoutMs = entry.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : entry.timeoutMs;
85
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
86
+ return { finding: `the \`${name}\` server's \`timeoutMs\` must be a positive whole number` };
87
+ }
88
+
89
+ return { server: { name, command, args, tool, argument, languages, timeoutMs } };
90
+ }
91
+
92
+ /**
93
+ * The servers a manifest declares, and the reason each rejected one was rejected.
94
+ *
95
+ * Pure: the manifest comes in, the decision comes out, and the same manifest always gives
96
+ * the same two lists. The order is the manifest's, because a packet's items are ordered and
97
+ * a federation answer is a packet item.
98
+ */
99
+ export function federationServers(manifest) {
100
+ const block = manifest?.federation;
101
+ if (block === undefined || block === null) return { servers: [], findings: [] };
102
+ if (typeof block !== 'object' || Array.isArray(block)) {
103
+ return { servers: [], findings: ['`federation` must be an object'] };
104
+ }
105
+ const declared = block.servers;
106
+ if (declared === undefined || declared === null) return { servers: [], findings: [] };
107
+ if (!Array.isArray(declared)) return { servers: [], findings: ['`federation.servers` must be a list'] };
108
+
109
+ const servers = [];
110
+ const findings = [];
111
+ const seen = new Set();
112
+ for (const entry of declared) {
113
+ const parsed = serverOf(entry, seen);
114
+ if (parsed.finding !== undefined) {
115
+ findings.push(parsed.finding);
116
+ continue;
117
+ }
118
+ servers.push(parsed.server);
119
+ }
120
+ return { servers, findings };
121
+ }
122
+
123
+ /**
124
+ * The same two lists, read from the repository's manifest.
125
+ *
126
+ * A repository with no manifest, an unreadable one or none at all declares no servers and
127
+ * reports no finding: federation is silent unless it was asked for, and "you did not
128
+ * configure this" is not a fault a reader needs told.
129
+ */
130
+ export async function readFederation(root) {
131
+ const { manifest, exists, error } = await readManifest(root);
132
+ if (!exists || error !== null || manifest === null) return { servers: [], findings: [] };
133
+ return federationServers(manifest);
134
+ }
@@ -0,0 +1,48 @@
1
+ // The federation query: one structural question per task, one answer per server (0003 R26).
2
+ //
3
+ // The slice is narrow on purpose. When a repository names external code-graph servers, the
4
+ // brief delegates a single structural question to each and cites where the answer came from,
5
+ // and the `federation_query` tool asks one on demand. Neither is a general proxy: a question
6
+ // goes to the tool and argument the manifest declared, so a server exposes no tool here that
7
+ // the repository did not name. It is not a second index, so there is no coverage bookkeeping
8
+ // to keep in sync. And it is not a gate, so a server that is down produces a sentence and
9
+ // never a failure.
10
+ //
11
+ // The servers are asked in the order the manifest declares them, one at a time. Parallel
12
+ // would be faster and would make the packet's item order a race, and a packet whose order
13
+ // depends on which server answered first is the one property this whole module exists
14
+ // beside.
15
+
16
+ import { callTool } from './client.mjs';
17
+
18
+ /**
19
+ * Ask each declared server the same question.
20
+ *
21
+ * @param {object} request
22
+ * @param {string} request.root the repository the servers are started in
23
+ * @param {string} request.query the structural question, already non-empty
24
+ * @param {object[]} request.servers the servers `readFederation` returned
25
+ * @returns `{ answers, findings }` — one `{ server, tool, query, text }` per server that
26
+ * answered, one sentence per server that did not
27
+ */
28
+ export async function consult({ root, query, servers }) {
29
+ const answers = [];
30
+ const findings = [];
31
+ for (const server of servers) {
32
+ const result = await callTool({
33
+ command: server.command,
34
+ args: server.args,
35
+ cwd: root,
36
+ tool: server.tool,
37
+ argument: server.argument,
38
+ query,
39
+ timeoutMs: server.timeoutMs,
40
+ });
41
+ if (result.error !== undefined) {
42
+ findings.push(`the \`${server.name}\` server did not answer (${result.error})`);
43
+ continue;
44
+ }
45
+ answers.push({ server: server.name, tool: server.tool, query, text: result.text });
46
+ }
47
+ return { answers, findings };
48
+ }