simt-dev 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
21
21
  import { MANIFEST_FILE } from '../name.mjs';
22
22
  import { CLI } from '../name.mjs';
23
23
  import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
24
+ import { PARTIAL_CLAUSE, parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
25
+ import { affectsTokens } from '../index/govern.mjs';
24
26
 
25
27
  const HERE = dirname(fileURLToPath(import.meta.url));
26
28
  export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
@@ -96,19 +98,6 @@ const ADR_IN_TEXT = /\bADR-(\d{3})\b/g;
96
98
  */
97
99
  const SIBLING_FIELD = /^\*{0,2}[A-Z][\w]*(?:[ \t]+[\w]+){0,3}:\*{0,2}(?:[ \t]|$)/;
98
100
 
99
- /**
100
- * `ruling N <verb> by <ADR>`, in every form the corpus uses.
101
- *
102
- * Four spellings, all of them real: `ruling 3 superseded by ADR-046`, `rulings 1 and 2
103
- * narrowed by ADR-026`, `rulings 3 and 4 narrowed by the same record`, and `one ruling
104
- * narrowed by ADR-045` - which names no number at all, because the record does not say
105
- * which ruling it meant. That last one is carried with `ruling: null` rather than
106
- * dropped: a clause the tool can see but cannot resolve is reported as unresolved, and
107
- * the corpus test below pins that there are none.
108
- */
109
- const PARTIAL_CLAUSE =
110
- /\b(?:one ruling|rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*))\s+(superseded|narrowed|withdrawn)\s+by\s+(ADR-\d{3}|the same record)\b/gi;
111
-
112
101
  /** How a record says a ruling was numbered, when it did not use a digit. */
113
102
  const ORDINALS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
114
103
  const ORDINAL_NUMBER = new Map(ORDINALS.map((w, i) => [w, i + 1]));
@@ -173,6 +162,85 @@ export function citations(text) {
173
162
  return [...found].sort();
174
163
  }
175
164
 
165
+ /**
166
+ * Parse one ADR markdown document, reading structured `simt-affects` and
167
+ * `simt-supersedes` frontmatter first (plan 0003 R34).
168
+ *
169
+ * @param {string} file filename (e.g. `ADR-097-example.md`)
170
+ * @param {string} text raw markdown content
171
+ * @param {{all?: boolean}} [options]
172
+ * @returns {{ok: boolean, decision?: object, error?: string, rawBody?: string}}
173
+ */
174
+ export function parseDecision(file, text, { all = false } = {}) {
175
+ const fm = parseDecisionFrontmatter(text);
176
+ const { body } = splitFrontmatter(text);
177
+ const fileId = /^ADR-(\d{3,})/i.exec(file)?.[0]?.toUpperCase();
178
+ const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? null;
179
+ const headId = heading ? /^(ADR-\d{3,})/i.exec(heading)?.[1]?.toUpperCase() : null;
180
+ const id = fileId ?? headId ?? null;
181
+ if (!id) {
182
+ return { ok: false, error: `${file}: filename does not start with ADR-NNN` };
183
+ }
184
+ if (!heading) {
185
+ return { ok: false, error: `${file}: no level-1 heading` };
186
+ }
187
+ const rawStatus = statusParagraph(body);
188
+ if (rawStatus === null) {
189
+ return { ok: false, error: `${file}: no Status: line, so currency cannot be reported` };
190
+ }
191
+ const status = stripMarkdown(rawStatus);
192
+ const selfNum = Number(id.replace(/^ADR-/i, ''));
193
+
194
+ const fmPartial = [];
195
+ const fmWholeSupersedes = [];
196
+ for (const entry of fm.supersedes) {
197
+ const otherNum = Number(entry.adr.replace(/^ADR-/i, ''));
198
+ if (entry.ruling === null) {
199
+ if (otherNum < selfNum) fmWholeSupersedes.push(entry.adr);
200
+ } else if (otherNum > selfNum || entry.adr === id) {
201
+ fmPartial.push({
202
+ ruling: entry.ruling,
203
+ kind: entry.kind ?? 'superseded',
204
+ by: entry.adr === id ? null : entry.adr,
205
+ });
206
+ }
207
+ }
208
+
209
+ const prosePartial = partialSupersessions(status);
210
+ const partial = [...fmPartial];
211
+ for (const p of prosePartial) {
212
+ if (!partial.some((x) => x.ruling === p.ruling && x.by === p.by)) {
213
+ partial.push(p);
214
+ }
215
+ }
216
+
217
+ const proseSupersedes = [...status.matchAll(/supersedes ADR-(\d{3,})(?!\s+[Ss]?rulings?\b)/g)].map(
218
+ (m) => `ADR-${m[1]}`,
219
+ );
220
+ const supersedes = [...new Set([...fmWholeSupersedes, ...proseSupersedes])];
221
+ const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
222
+
223
+ return {
224
+ ok: true,
225
+ rawBody: body,
226
+ decision: {
227
+ id,
228
+ file,
229
+ title,
230
+ status: redactInternalPaths(redactCommand(status)),
231
+ accepted: /^accepted\b/i.test(status),
232
+ supersededBy: firstMatch(status, /^superseded by ADR-(\d{3,})/i),
233
+ supersedes,
234
+ supersedesRulings: fm.supersedes,
235
+ affects: affectsTokens(text),
236
+ frontmatter: fm.hasFrontmatter ? { affects: fm.affects, supersedes: fm.supersedes } : null,
237
+ partial,
238
+ summary: summarise(body, partial, { all, file }),
239
+ bytes: Buffer.byteLength(text),
240
+ },
241
+ };
242
+ }
243
+
176
244
  /**
177
245
  * Load every ADR record once.
178
246
  *
@@ -181,69 +249,52 @@ export function citations(text) {
181
249
  *
182
250
  * @returns {Promise<{ok: boolean, decisions: object[], errors: string[]}>}
183
251
  */
184
- export async function loadDecisions({ all = false } = {}) {
252
+ export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
185
253
  let names;
186
254
  try {
187
- names = (await readdir(DECISIONS_DIR)).filter((n) => /^ADR-\d{3}-.*\.md$/.test(n)).sort();
255
+ names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/.test(n)).sort();
188
256
  } catch (error) {
189
- return { ok: false, decisions: [], errors: [`${DECISIONS_DIR}: unreadable (${error.message})`] };
257
+ return { ok: false, decisions: [], errors: [`${dir}: unreadable (${error.message})`] };
190
258
  }
191
259
 
192
260
  const decisions = [];
261
+ const rawBodies = new Map();
193
262
  const errors = [];
194
263
 
195
264
  for (const file of names) {
196
- const text = await readFile(join(DECISIONS_DIR, file), 'utf8');
197
- const id = /^ADR-(\d{3})/.exec(file)?.[0];
198
- if (!id) {
199
- errors.push(`${file}: filename does not start with ADR-NNN`);
265
+ const text = await readFile(join(dir, file), 'utf8');
266
+ const parsed = parseDecision(file, text, { all });
267
+ if (!parsed.ok) {
268
+ errors.push(parsed.error);
200
269
  continue;
201
270
  }
202
- const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? null;
203
- if (!heading) {
204
- errors.push(`${file}: no level-1 heading`);
205
- continue;
206
- }
207
- const rawStatus = statusParagraph(text);
208
- if (rawStatus === null) {
209
- errors.push(`${file}: no Status: line, so currency cannot be reported`);
210
- continue;
271
+ rawBodies.set(parsed.decision.id, parsed.rawBody);
272
+ decisions.push(parsed.decision);
273
+ }
274
+
275
+ const index = byId(decisions);
276
+ for (const d of decisions) {
277
+ const selfNum = Number(d.id.replace(/^ADR-/i, ''));
278
+ for (const entry of d.supersedesRulings ?? []) {
279
+ const otherNum = Number(entry.adr.replace(/^ADR-/i, ''));
280
+ if (otherNum >= selfNum) continue;
281
+ const target = index.get(entry.adr);
282
+ if (!target) continue;
283
+ if (entry.ruling === null) {
284
+ target.supersededBy = d.id;
285
+ target.accepted = false;
286
+ } else if (!target.partial.some((p) => p.ruling === entry.ruling && p.by === d.id)) {
287
+ target.partial.unshift({
288
+ ruling: entry.ruling,
289
+ kind: entry.kind ?? 'superseded',
290
+ by: d.id,
291
+ });
292
+ target.summary = summarise(rawBodies.get(target.id) ?? '', target.partial, {
293
+ all,
294
+ file: target.file,
295
+ });
296
+ }
211
297
  }
212
- // Every field below is read off the flattened paragraph, with markdown already
213
- // removed. The link form and the bare form are then one spelling, so a record that
214
- // writes `superseded by ADR-046` and one that writes `superseded by
215
- // [ADR-046](...)` are read by the same line of code rather than by two regexes that
216
- // have to be kept in step.
217
- const status = stripMarkdown(rawStatus);
218
- const partial = partialSupersessions(status);
219
- // The heading repeats the id; the decision title is what follows the dash.
220
- const title = heading.replace(/^ADR-\d{3}\s*[—–-]\s*/, '').trim();
221
-
222
- decisions.push({
223
- id,
224
- file,
225
- title,
226
- // Everything structural above this line is derived from the record's own prose and
227
- // is therefore unredacted: `partial` in particular has to say "ruling 1 superseded
228
- // by ADR-053", and a ruling number is not a location. What a reader in another
229
- // repository receives is the status line, so that is what is scrubbed — here, once,
230
- // rather than at each of the four places that print it.
231
- status: redactInternalPaths(redactCommand(status)),
232
- // Anchored to the start of the status, and this is the whole of the rule. A record
233
- // is superseded only when its own status says so first. Unanchored, the clause
234
- // "ruling 2 superseded by [ADR-024]" inside ADR-023's paragraph matched, and the
235
- // command would then answer "ruling 2" by printing ADR-024's rulings and
236
- // suppressing the five in ADR-023 that still stand - a partial supersession
237
- // silently becoming a total one, which is the reverse of what the status says.
238
- accepted: /^accepted\b/i.test(status),
239
- supersededBy: firstMatch(status, /^superseded by ADR-(\d{3})/i),
240
- supersedes: [...status.matchAll(/supersedes ADR-(\d{3})(?!\s+[Ss]?rulings?\b)/g)].map(
241
- (m) => `ADR-${m[1]}`,
242
- ),
243
- partial,
244
- summary: summarise(text, partial, { all, file }),
245
- bytes: Buffer.byteLength(text),
246
- });
247
298
  }
248
299
 
249
300
  return { ok: errors.length === 0, decisions, errors };
@@ -524,7 +575,7 @@ export function redactEstate(text) {
524
575
  * Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
525
576
  * `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
526
577
  */
527
- const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|--[a-z-]+)';
578
+ const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|agent-hook|agent-hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|lessons|decide|brief|plan|--[a-z-]+)';
528
579
 
529
580
  function redactCommand(text) {
530
581
  return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
@@ -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
+ }