simt-dev 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  4. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  5. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  6. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  7. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  8. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  9. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  10. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  12. package/package.json +2 -1
  13. package/src/agenthooks/decide.mjs +123 -0
  14. package/src/agenthooks/policy.mjs +78 -0
  15. package/src/agenthooks/render.mjs +240 -0
  16. package/src/argv.mjs +36 -1
  17. package/src/bench/ablate.mjs +630 -0
  18. package/src/bench/adapters.json +149 -0
  19. package/src/bench/grade.mjs +229 -0
  20. package/src/bench/mine.mjs +811 -0
  21. package/src/bench/probes.mjs +1350 -0
  22. package/src/bench/report.mjs +899 -0
  23. package/src/bench/run.mjs +1002 -0
  24. package/src/commands/agenthook.mjs +282 -0
  25. package/src/commands/bench.mjs +359 -0
  26. package/src/commands/harness.mjs +156 -5
  27. package/src/commands/help.mjs +58 -1
  28. package/src/commands/index.mjs +25 -1
  29. package/src/commands/plan.mjs +99 -0
  30. package/src/commands/tokens.mjs +31 -0
  31. package/src/decisions/index.mjs +23 -5
  32. package/src/kernel/card.mjs +254 -0
  33. package/src/kernel/inferability.mjs +188 -0
  34. package/src/mcp/resources.mjs +157 -0
  35. package/src/mcp/server.mjs +37 -6
  36. package/src/mcp/tools.mjs +84 -0
  37. package/src/measure/context.mjs +177 -6
  38. package/src/measure/loaderView.mjs +435 -0
  39. package/src/measure/session.mjs +267 -0
  40. package/src/plan/index.mjs +650 -0
  41. package/src/plan/template.mjs +331 -0
  42. package/src/projection/loaders.mjs +689 -0
  43. package/src/projection/manifest.mjs +26 -3
  44. package/src/projection/rules.mjs +80 -5
  45. package/src/projection/surfaces.mjs +22 -8
  46. package/src/toolwords.mjs +69 -0
  47. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,254 @@
1
+ // The command card: the exact commands for THIS repository, at the top of the
2
+ // projected region (plan 0003 R10).
3
+ //
4
+ // The card holds the bytes a reader cannot infer. "Run the tests" is useless advice in a
5
+ // repository whose test command is `./gradlew :app:testDebugUnitTest`; the runner, the
6
+ // wrapper and the task name are facts about this repository, and none of them are in the
7
+ // rules, which are the same in every repository. So the card is derived rather than
8
+ // written: from the manifest's declared gates, the repository's own `package.json`
9
+ // scripts, and the marker files that name its ecosystem.
10
+ //
11
+ // It reads committed evidence ONLY. It must never consult PATH or `node_modules`, because
12
+ // the projected region is required to be byte-identical on two machines (plan 0003 P4,
13
+ // R13) and a card that named whichever linter happened to be installed would differ
14
+ // between a laptop and CI for no reason a diff could explain. Runnability is a property
15
+ // of the machine; a command is a property of the repository.
16
+ //
17
+ // The focused-test entries are TEMPLATES. `{file}`, `{pkg}`, `{Test}` and `{class}` are
18
+ // placeholders the reader fills from the file they are editing, and the label names the
19
+ // path kind so a template is never mistaken for a command that can be pasted as-is.
20
+ // Refining which test a given `{file}` maps to is R24's job, not R10's.
21
+
22
+ import { readFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+
25
+ import { ECOSYSTEMS } from '../projection/manifest.mjs';
26
+
27
+ /**
28
+ * The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
29
+ * dropped rather than the ceiling raised: the point of the card is the bytes a reader
30
+ * most needs, and an unbounded card is a second copy of the rules.
31
+ */
32
+ export const CARD_LIMIT_BYTES = 600;
33
+
34
+ /**
35
+ * The verbs the card carries, in the order a reader meets them. The plan names exactly
36
+ * these five. A `build` command exists only where a repository declares one, so the order
37
+ * is a priority as well as a sequence: the most universally true verb comes first.
38
+ *
39
+ * Exported because a second reader has to agree with it: a rule may declare `card:<verb>`
40
+ * as the evidence that makes its line redundant (R09), and a copy of this list living in
41
+ * the filter would let the card name a verb the filter called unknown.
42
+ */
43
+ export const CARD_VERBS = ['build', 'test', 'lint', 'format', 'typecheck'];
44
+
45
+ const VERBS = CARD_VERBS;
46
+
47
+ /**
48
+ * The order verbs are dropped in when the card is over its ceiling: the verb whose loss a
49
+ * reader misses least goes first. `test` is absent on purpose — a card that dropped the
50
+ * test command would have dropped the one command it exists to carry.
51
+ */
52
+ const DROP_ORDER = ['format', 'typecheck', 'lint', 'build'];
53
+
54
+ /**
55
+ * Build commands for ecosystems whose build entrypoint is not a package script. Keyed by
56
+ * a marker fact or stack tag, whichever the ecosystem is identified by. `npm` is handled
57
+ * before this table, because a Node repository states its own build script and stating it
58
+ * again here would be a second answer to the same question.
59
+ */
60
+ const BUILDS = [
61
+ { fact: 'has-cargo-toml', command: 'cargo build' },
62
+ { fact: 'has-go-mod', command: 'go build ./...' },
63
+ { fact: 'has-nuget', command: 'dotnet build' },
64
+ { fact: 'has-swift-package', command: 'swift build' },
65
+ { fact: 'has-mix', command: 'mix compile' },
66
+ { stack: 'gradle', command: './gradlew build' },
67
+ ];
68
+
69
+ /**
70
+ * One focused-test template per path kind. Entries are ordered, and the FIRST match wins,
71
+ * so a repository that carries more than one marker gets the more specific template:
72
+ * `has-uv-lock` before `has-pyproject`, `has-android-manifest` before the plain `gradle`
73
+ * tag, `has-deno-config` before the Node fallback. A duplicate template for the same path
74
+ * kind would be two answers, and the second is the wrong one.
75
+ */
76
+ const FOCUSED = [
77
+ { kind: 'Android', when: (s, f) => f.has('has-android-manifest'), template: () => './gradlew :{module}:testDebugUnitTest --tests {class}' },
78
+ { kind: 'JVM', when: (s) => s.has('gradle'), template: () => './gradlew test --tests {class}' },
79
+ { kind: 'Maven', when: (s, f) => f.has('has-maven-pom'), template: () => './mvnw test -Dtest={class}' },
80
+ { kind: 'Go package', when: (s, f) => f.has('has-go-mod'), template: () => 'go test ./{pkg} -run {Test}' },
81
+ { kind: 'Rust', when: (s, f) => f.has('has-cargo-toml'), template: () => 'cargo test {name}' },
82
+ { kind: 'Python', when: (s, f) => f.has('has-uv-lock'), template: () => 'uv run -m pytest {file}::{test}' },
83
+ { kind: 'Python', when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), template: () => 'pytest {file}::{test}' },
84
+ { kind: '.NET', when: (s, f) => f.has('has-nuget'), template: () => 'dotnet test --filter {test}' },
85
+ { kind: 'Swift', when: (s, f) => f.has('has-swift-package'), template: () => 'swift test --filter {test}' },
86
+ { kind: 'Deno', when: (s, f) => f.has('has-deno-config'), template: () => 'deno test {file}' },
87
+ { kind: 'Ruby', when: (s, f) => f.has('has-gemfile'), template: () => 'bundle exec rspec {file}' },
88
+ { kind: 'Dart', when: (s, f) => f.has('has-pubspec'), template: () => 'dart test {file}' },
89
+ { kind: 'Elixir', when: (s, f) => f.has('has-mix'), template: () => 'mix test {file}' },
90
+ {
91
+ kind: 'Node',
92
+ when: (s) => s.has('node'),
93
+ // The repo's own runner decides the shape. A repository whose test script is vitest
94
+ // or jest takes a file argument through the script; one with no such script gets the
95
+ // built-in runner, which is the only answer that needs nothing installed to already
96
+ // be true. `npm test -- <file>` is the ADR-010 shape (never `npx`).
97
+ template: (s, f, scripts) => {
98
+ const test = scripts?.test ?? '';
99
+ return /vitest|jest/.test(test) ? 'npm test -- {file}' : 'node --test {file}';
100
+ },
101
+ },
102
+ ];
103
+
104
+ /**
105
+ * Verbs for ecosystems the gate table does not seed, used by the card alone.
106
+ *
107
+ * Python is the case, and it is not an oversight that the tool seeds no Python gate: the
108
+ * runner genuinely varies (uv, poetry, tox, nox, a project wrapper), so a seeded gate would
109
+ * be wrong in the repositories it guessed about. The card can still name the one command a
110
+ * repository has proved: a committed `uv.lock` means uv, and the uv profile is explicit
111
+ * that `uv run -m pytest` is correct and `uv run pytest` is not — the console entrypoint
112
+ * can miss the `uv` import path and run a different copy of the package than the one under
113
+ * test. Without a lockfile the card names the plain runner, which is what the python
114
+ * profile assumes when it says "no `pytest.skip`".
115
+ */
116
+ const CARD_FALLBACKS = [
117
+ { when: (s, f) => f.has('has-uv-lock'), commands: { test: 'uv run -m pytest' } },
118
+ { when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), commands: { test: 'pytest' } },
119
+ ];
120
+
121
+ /** A non-empty string, or null. Guards JSON that is present but not a command. */
122
+ function text(value) {
123
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
124
+ }
125
+
126
+ /** The repository's own `package.json` scripts, or an empty map. */
127
+ async function readScripts(root) {
128
+ try {
129
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
130
+ return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
131
+ } catch {
132
+ // No package.json is a normal shape (a Python or Go repository), and an unreadable one
133
+ // is reported by the manifest path. Either way the card has no scripts to draw on.
134
+ return {};
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The command for one verb, or null when this repository proves none.
140
+ *
141
+ * Precedence is deliberate and mirrors the manifest's own. A declared `gates.commands`
142
+ * entry is the repository naming its own program and wins over anything inferred; a
143
+ * `package.json` script is the repository's declared entrypoint (`npm run <verb>`); and
144
+ * the ecosystem table is the canonical command for a marker that is present but whose
145
+ * repository has declared nothing. The last is the same table the tool seeds gates from,
146
+ * so the card and the gate list cannot name two different commands for one repository.
147
+ */
148
+ function resolveVerb(verb, { manifest, facts, stack, scripts }) {
149
+ const declared = text(manifest?.gates?.commands?.[verb]);
150
+ if (declared) return declared;
151
+
152
+ // The script a verb maps to. `format` is a staged gate rather than a script gate, so its
153
+ // two conventional script names are tried directly — `format:check` first, because that
154
+ // is the one a hook and CI run, which is what the card is reporting.
155
+ const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
156
+ for (const name of scriptNames) {
157
+ if (text(scripts?.[name])) return `npm run ${name}`;
158
+ }
159
+
160
+ if (verb === 'build') {
161
+ if (text(scripts?.build)) return 'npm run build';
162
+ for (const row of BUILDS) {
163
+ if (row.fact && facts.has(row.fact)) return row.command;
164
+ if (row.stack && stack.has(row.stack)) return row.command;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ for (const eco of ECOSYSTEMS) {
170
+ if (!facts.has(eco.fact)) continue;
171
+ const command = text(eco.commands?.[verb]);
172
+ if (command) return command;
173
+ }
174
+ for (const row of CARD_FALLBACKS) {
175
+ if (!row.when(stack, facts, scripts)) continue;
176
+ const command = text(row.commands?.[verb]);
177
+ if (command) return command;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** The text a set of lines renders to. Kept beside `commandCard` so both agree. */
183
+ function renderCard(lines) {
184
+ const body = lines.map((l) => `- ${l.label}: \`${l.command}\``).join('\n');
185
+ return `Commands for this repository:\n\n${body}`;
186
+ }
187
+
188
+ function renderBytes(lines) {
189
+ return lines.length === 0 ? 0 : Buffer.byteLength(renderCard(lines));
190
+ }
191
+
192
+ /**
193
+ * The index of the next line to drop, or -1 when nothing may be dropped.
194
+ *
195
+ * Verbs drop in `DROP_ORDER`. Focused templates drop only after every verb, from the last
196
+ * first, because a focused template is the plan's headline content and a repository with
197
+ * several is the one case where dropping one still leaves the reader a template.
198
+ */
199
+ function pickDroppable(lines) {
200
+ for (const verb of DROP_ORDER) {
201
+ const at = lines.findIndex((l) => l.verb === verb);
202
+ if (at !== -1) return at;
203
+ }
204
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
205
+ if (lines[i].verb === 'focused') return i;
206
+ }
207
+ return -1;
208
+ }
209
+
210
+ /**
211
+ * Build the card for one repository.
212
+ *
213
+ * @param {{root: string, manifest?: object, stack?: string[], facts?: string[]}} input
214
+ * @returns {Promise<{text: string, bytes: number, lines: {label: string, command: string,
215
+ * verb: string}[], dropped: string[]}>}
216
+ */
217
+ export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
218
+ const factSet = new Set(facts);
219
+ const stackSet = new Set(stack);
220
+ const scripts = await readScripts(root);
221
+
222
+ const lines = [];
223
+ for (const verb of VERBS) {
224
+ const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
225
+ if (command) lines.push({ label: verb, command, verb });
226
+ }
227
+ for (const entry of FOCUSED) {
228
+ if (!entry.when(stackSet, factSet, scripts)) continue;
229
+ // One template per path kind: `has-uv-lock` and `has-pyproject` both match a uv repo,
230
+ // and two Python lines would be two answers with the second one wrong.
231
+ if (lines.some((l) => l.kind === entry.kind)) continue;
232
+ lines.push({
233
+ label: `focused test (${entry.kind})`,
234
+ command: entry.template(stackSet, factSet, scripts),
235
+ kind: entry.kind,
236
+ verb: 'focused',
237
+ });
238
+ }
239
+
240
+ // Fit under the ceiling by dropping whole lines, least valuable first. Nothing is cut
241
+ // silently: `dropped` names every line removed so the caller can say so, because a card
242
+ // that quietly lost a command is a card a reader cannot tell is incomplete.
243
+ const dropped = [];
244
+ let kept = lines.slice();
245
+ while (renderBytes(kept) > CARD_LIMIT_BYTES) {
246
+ const victim = pickDroppable(kept);
247
+ if (victim === -1) break;
248
+ dropped.push(kept[victim].label);
249
+ kept = kept.filter((_, i) => i !== victim);
250
+ }
251
+
252
+ const text = kept.length > 0 ? renderCard(kept) : '';
253
+ return { text, bytes: Buffer.byteLength(text), lines: kept, dropped };
254
+ }
@@ -0,0 +1,188 @@
1
+ // The inferability filter: a rule line the repository already proves does not ship
2
+ // (plan 0003 R09).
3
+ //
4
+ // The costliest byte in a projection is the one the reader already knows. A Go repository
5
+ // with one runner does not need to be told to use it; a repository whose manifest declares
6
+ // a format gate does not need to be told how it indents. The evidence is committed — the
7
+ // marker files, the declared gates, the command card — so the same repository filters the
8
+ // same way on every machine (plan 0003 P4, R13).
9
+ //
10
+ // Nothing is dropped by guesswork. A rule author marks the line with
11
+ // `<!-- simt-inferable-when: <expression> -->`, naming the evidence that makes it
12
+ // redundant, and the line leaves only when the repository proves every term. A line nobody
13
+ // marked is a line nobody claimed was inferable, and stays.
14
+ //
15
+ // The unit dropped is the blank-line-delimited block, so a wrapped bullet is marked once
16
+ // and leaves whole rather than leaving half a sentence behind. The marker is stripped from
17
+ // the region either way: `simt-` vocabulary is for the rule's author, not its reader.
18
+
19
+ import { gateNames } from '../hooks/gates.mjs';
20
+ import { KNOWN_TAGS } from '../resolve/fingerprint.mjs';
21
+
22
+ import { CARD_VERBS } from './card.mjs';
23
+
24
+ /**
25
+ * The marker a rule line carries. Named here rather than at its use, because the defect
26
+ * message has to spell the thing the author must type.
27
+ */
28
+ export const INFERABLE_MARKER = 'simt-inferable-when';
29
+
30
+ const MARKER = /<!--\s*simt-inferable-when:\s*([^>]*?)\s*-->/g;
31
+
32
+ /**
33
+ * Marks the trailing position of a marker on a line, so the text a reader keeps is the
34
+ * line without the comment and not the line with a hole in it.
35
+ */
36
+ const MARKER_AT = /\s*<!--\s*simt-inferable-when:\s*[^>]*?-->/g;
37
+
38
+ /** A non-global probe, because a global regex carries `lastIndex` between calls. */
39
+ const HAS_MARKER = /<!--\s*simt-inferable-when:/;
40
+
41
+ /**
42
+ * Split a body into the units a marker drops. Blank lines separate blocks, except inside a
43
+ * fenced code block, where a blank line is part of the sample and splitting there would
44
+ * cut a fence in half.
45
+ */
46
+ function blocks(text) {
47
+ const out = [];
48
+ let current = [];
49
+ let fenced = false;
50
+ for (const line of String(text ?? '').split('\n')) {
51
+ if (/^\s*(```|~~~)/.test(line)) {
52
+ if (current.length > 0 || fenced) current.push(line);
53
+ fenced = !fenced;
54
+ continue;
55
+ }
56
+ if (!fenced && line.trim() === '') {
57
+ if (current.length > 0) out.push(current.join('\n'));
58
+ current = [];
59
+ continue;
60
+ }
61
+ current.push(line);
62
+ }
63
+ if (current.length > 0) out.push(current.join('\n'));
64
+ return out;
65
+ }
66
+
67
+ /** Every marker in one block, as `{line, expr}`. More than one is a defect. */
68
+ function markers(block) {
69
+ const found = [];
70
+ for (const line of block.split('\n')) {
71
+ for (const match of line.matchAll(MARKER)) found.push({ line, expr: match[1] });
72
+ }
73
+ return found;
74
+ }
75
+
76
+ /**
77
+ * An expression is a conjunction of terms: `term + term`, and nothing else. There is no
78
+ * negation, because every reason a line is redundant is a positive proof the repository
79
+ * offers, and a grammar for a case nobody has is a grammar nobody can be taught.
80
+ */
81
+ export function parseInferable(expression) {
82
+ return String(expression ?? '')
83
+ .split('+')
84
+ .map((term) => term.trim())
85
+ .filter((term) => term !== '');
86
+ }
87
+
88
+ /**
89
+ * Whether a term is evidence this tool can check: a fact or stack tag the repository
90
+ * proves by its files, a verb its command card names, or a gate it declares. Anything else
91
+ * is a typo the author has to see, not a term that happens to be unproven — a marker
92
+ * naming evidence the tool never gathered would drop the line on a claim nothing verified.
93
+ */
94
+ function knownTerm(term) {
95
+ if (term.startsWith('card:')) return CARD_VERBS.includes(term.slice('card:'.length));
96
+ if (term.startsWith('gate:')) return gateNames().includes(term.slice('gate:'.length));
97
+ return KNOWN_TAGS.has(term);
98
+ }
99
+
100
+ /**
101
+ * The defect in a body's markers, or null. Reported through the same field as a
102
+ * frontmatter defect, so `check` and `rules list` need no second path to a broken rule.
103
+ */
104
+ export function inferableDefect(body) {
105
+ for (const block of blocks(body)) {
106
+ const found = markers(block);
107
+ if (found.length === 0) continue;
108
+ if (found.length > 1) {
109
+ return `carries more than one ${INFERABLE_MARKER} marker; a block is dropped whole, so one marker is one claim`;
110
+ }
111
+ const terms = parseInferable(found[0].expr);
112
+ if (terms.length === 0) {
113
+ return `${INFERABLE_MARKER} names no evidence, so nothing can decide whether the line is redundant`;
114
+ }
115
+ for (const term of terms) {
116
+ if (!knownTerm(term)) {
117
+ return `${INFERABLE_MARKER} names "${term}", which is not evidence this repository can prove; use a marker-file fact, card:${CARD_VERBS.join('|card:')} or gate:<name>`;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * The evidence a projection may treat as already known. Built from committed bytes only —
126
+ * never PATH or `node_modules` — because a proof read off the machine would make the
127
+ * region differ between a laptop and CI for no reason a diff could explain.
128
+ */
129
+ export function inferableProofs({ facts = [], stack = [], card = null, manifest = null } = {}) {
130
+ const proofs = new Set([...facts, ...stack]);
131
+ for (const line of card?.lines ?? []) {
132
+ if (line.verb) proofs.add(`card:${line.verb}`);
133
+ }
134
+ const gates = manifest?.gates ?? {};
135
+ for (const name of [...(gates.preCommit ?? []), ...(gates.prePush ?? [])]) {
136
+ proofs.add(`gate:${name}`);
137
+ }
138
+ return proofs;
139
+ }
140
+
141
+ /** The line a reader would have read, collapsed and clipped for a one-line note. */
142
+ function excerpt(text) {
143
+ const one = text.replace(/\s+/g, ' ').trim();
144
+ return one.length > 80 ? `${one.slice(0, 79)}…` : one;
145
+ }
146
+
147
+ /** The block without its markers, and without the line a marker stood on alone. */
148
+ function stripMarkers(block) {
149
+ const kept = [];
150
+ for (const line of block.split('\n')) {
151
+ const stripped = line.replace(MARKER_AT, '').replace(/\s+$/, '');
152
+ if (stripped === '' && HAS_MARKER.test(line)) continue;
153
+ kept.push(stripped);
154
+ }
155
+ return kept.join('\n');
156
+ }
157
+
158
+ /**
159
+ * Drop every marked block whose expression the repository proves, and strip the markers
160
+ * from the rest.
161
+ *
162
+ * `changed` is false when the body carries no marker, and then `body` is the input by
163
+ * identity: a projection of a rule nobody marked must not move a byte, or every
164
+ * repository in the estate would diff on a change none of them made.
165
+ *
166
+ * @returns {{body: string, removed: {text: string, expr: string}[], changed: boolean}}
167
+ */
168
+ export function filterInferable(body, proofs) {
169
+ const parts = blocks(body);
170
+ if (!parts.some((block) => markers(block).length > 0)) {
171
+ return { body, removed: [], changed: false };
172
+ }
173
+ const kept = [];
174
+ const removed = [];
175
+ for (const block of parts) {
176
+ const found = markers(block);
177
+ if (found.length > 0) {
178
+ const terms = parseInferable(found[0].expr);
179
+ if (terms.length > 0 && terms.every((term) => proofs.has(term))) {
180
+ removed.push({ text: excerpt(stripMarkers(block)), expr: found[0].expr.trim() });
181
+ continue;
182
+ }
183
+ }
184
+ const clean = stripMarkers(block).replace(/\s+$/, '');
185
+ if (clean.trim() !== '') kept.push(clean);
186
+ }
187
+ return { body: kept.join('\n\n'), removed, changed: true };
188
+ }
@@ -0,0 +1,157 @@
1
+ // Rules and decisions as MCP resources.
2
+ //
3
+ // The tools answer questions and the answers are arguments to the next call. These two
4
+ // answer a different request: "what is the rule you would apply, in full". A client that
5
+ // reads them can hold the text rather than reconstructing it from a tool's summary, and
6
+ // a reader outside this estate gets the same bytes an adopting repository's agent reads -
7
+ // redacted, because a decision record cites its own rulings and not this repository's
8
+ // layout (ADR-055, ADR-061).
9
+ //
10
+ // Both revisions agree on the shape here. They disagree about what a missing resource
11
+ // returns, and that difference is handled in the server, not here: this module answers
12
+ // with a resource or with nothing at all, and never with an empty `contents` array,
13
+ // which the newest revision forbids because it is indistinguishable from an empty rule.
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+
18
+ import { DECISIONS_DIR, loadDecisions, redactEstate, redactInternalPaths } from '../decisions/index.mjs';
19
+ import { CLI } from '../name.mjs';
20
+ import { loadMdcRules } from '../projection/mdc.mjs';
21
+ import { loadRules } from '../projection/rules.mjs';
22
+
23
+ const RULES_SCHEME = 'simt://rules/';
24
+ const DECISIONS_SCHEME = 'simt://decisions/';
25
+
26
+ /**
27
+ * RFC 6570 templates, so a client can discover the shape without a hard-coded string.
28
+ *
29
+ * `id` and `adr` are the handles a reader already has: a gate names a rule id and a
30
+ * finding names an ADR, so the template's variable names match what those print.
31
+ */
32
+ export const RESOURCE_TEMPLATES = [
33
+ {
34
+ uriTemplate: `${RULES_SCHEME}{id}`,
35
+ name: 'rules',
36
+ title: 'Projected rules',
37
+ description: 'One rule as this tool projects it: its frontmatter, its body and its decision citations.',
38
+ mimeType: 'text/markdown',
39
+ },
40
+ {
41
+ uriTemplate: `${DECISIONS_SCHEME}{adr}`,
42
+ name: 'decisions',
43
+ title: 'Decision records',
44
+ description: 'One decision record, including the rulings and the reasons they were given.',
45
+ mimeType: 'text/markdown',
46
+ },
47
+ ];
48
+
49
+ /**
50
+ * Split a resource URI into its collection and its handle.
51
+ *
52
+ * String handling rather than `new URL`, because a custom scheme is parsed by the WHATWG
53
+ * URL machinery according to rules written for http, and the one thing this has to get
54
+ * right is which character separates `simt://rules` from the id. A handle that carries a
55
+ * slash or a dot segment is refused rather than resolved: a resource URI is not a file
56
+ * path, and the specification requires a server to reject one that tries to be.
57
+ */
58
+ function parseResourceUri(uri) {
59
+ if (typeof uri !== 'string') return null;
60
+ const [collection, ...rest] = uri.split('://');
61
+ if (collection !== 'simt') return null;
62
+ const [name, ...handle] = rest.join('://').split('/');
63
+ const id = handle.join('/');
64
+ if (!id || id === '.' || id === '..' || id.includes('..')) return null;
65
+ if (name === 'rules' && id.includes('/')) return null;
66
+ if (name !== 'rules' && name !== 'decisions') return null;
67
+ return { collection: name, id };
68
+ }
69
+
70
+ function resource(collection, id, title, bytes) {
71
+ return {
72
+ uri: `simt://${collection}/${id}`,
73
+ name: id,
74
+ title,
75
+ description: `${title}, as this tool projects it`,
76
+ mimeType: 'text/markdown',
77
+ size: bytes,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Every rule and every decision record, one entry each.
83
+ *
84
+ * The list is built from what is on disk rather than from a hand-kept index, so it cannot
85
+ * describe a rule that was renamed or a record that was never written. Order is sorted:
86
+ * the newest revision forbids a set that varies per connection or as a side effect of
87
+ * another request, and a list built from a directory read is the only shape that satisfies
88
+ * that without a cache to invalidate.
89
+ */
90
+ export async function listResources() {
91
+ const [rules, mdc, { decisions }] = await Promise.all([loadRules(null), loadMdcRules(), loadDecisions({ all: true })]);
92
+ // `size` is the bytes on disk rather than the bytes served: `read` redacts a decision
93
+ // before sending it, so the served length is not knowable without reading the file, and
94
+ // a size that disagrees with the payload is worse than a size that rounds.
95
+ return [
96
+ ...[...rules, ...mdc]
97
+ .filter((rule) => rule.id !== null)
98
+ .map((rule) => resource('rules', rule.id, rule.title ?? rule.id, rule.bytes)),
99
+ ...decisions.map((decision) => resource('decisions', decision.id, decision.title ?? decision.id, decision.bytes)),
100
+ ].sort((a, b) => a.uri.localeCompare(b.uri));
101
+ }
102
+
103
+ /**
104
+ * One resource, or null when there is no such id.
105
+ *
106
+ * `null` is not a failure the caller swallows: the specification makes a missing resource
107
+ * a protocol error with a code that CHANGED between revisions, so the server decides the
108
+ * code and this module only declines to invent content.
109
+ */
110
+ export async function readResource(uri) {
111
+ const parsed = parseResourceUri(uri);
112
+ if (parsed === null) return null;
113
+
114
+ if (parsed.collection === 'rules') {
115
+ const [rules, mdc] = await Promise.all([loadRules(null), loadMdcRules()]);
116
+ const rule = [...rules, ...mdc].find((candidate) => candidate.id === parsed.id);
117
+ if (rule === undefined) return null;
118
+ const text = `${frontmatterOf(rule)}\n${rule.body ?? ''}`.trim();
119
+ return {
120
+ uri,
121
+ mimeType: 'text/markdown',
122
+ text,
123
+ // The body a tool returns is redacted for the same reason: a reader outside this
124
+ // estate is told what the rule says and not which directory here it is filed in.
125
+ bytes: Buffer.byteLength(text),
126
+ };
127
+ }
128
+
129
+ // `loadDecisions` reads the index, not the records: it reports status, currency and
130
+ // byte count, and keeps no body. Reading the file the index named is one read of a
131
+ // record the reader has already asked for by id.
132
+ const { decisions } = await loadDecisions({ all: true });
133
+ const decision = decisions.find((candidate) => candidate.id === parsed.id);
134
+ if (decision === undefined) return null;
135
+ const raw = await readFile(join(DECISIONS_DIR, decision.file), 'utf8');
136
+ const text = redactInternalPaths(redactEstate(raw)).trim();
137
+ return { uri, mimeType: 'text/markdown', text, bytes: Buffer.byteLength(text) };
138
+ }
139
+
140
+ function frontmatterOf(rule) {
141
+ const keys = ['id', 'title', 'applies', 'priority'];
142
+ const present = keys.filter((key) => rule[key] !== undefined && rule[key] !== null);
143
+ if (present.length === 0) return '';
144
+ return ['---', ...present.map((key) => `${key}: ${formatValue(rule[key])}`), '---'].join('\n');
145
+ }
146
+
147
+ function formatValue(value) {
148
+ return Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
149
+ }
150
+
151
+ /**
152
+ * The remedy a client gets when it asks for something that is not here.
153
+ *
154
+ * A command, not a path, for the reason every remedy in this package is a command: the
155
+ * reader runs this tool, not this checkout.
156
+ */
157
+ const MISSING_REMEDY = `run: ${CLI} rules list, or ${CLI} graph lint to see what this copy can read`;
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { createInterface } from 'node:readline';
23
23
  import { CLI } from '../name.mjs';
24
+ import { RESOURCE_TEMPLATES, listResources, readResource } from './resources.mjs';
24
25
 
25
26
  /**
26
27
  * Every protocol revision this server speaks, newest first.
@@ -141,7 +142,7 @@ export async function handle(request, tools, { packageVersion, state } = {}) {
141
142
  case 'initialize':
142
143
  return reply({
143
144
  protocolVersion: v,
144
- capabilities: capabilities(tools),
145
+ capabilities: capabilities(),
145
146
  // `serverInfo` stays in the body here even on 2026-07-28: a client that has just
146
147
  // called a method the newest revision removed is by definition an older client,
147
148
  // and the field is where it looks.
@@ -158,7 +159,7 @@ export async function handle(request, tools, { packageVersion, state } = {}) {
158
159
  case 'server/discover':
159
160
  return reply({
160
161
  supportedVersions: [...SUPPORTED_VERSIONS],
161
- capabilities: capabilities(tools),
162
+ capabilities: capabilities(),
162
163
  instructions: INSTRUCTIONS,
163
164
  ...(cacheable(v) ? LIST_CACHE : {}),
164
165
  });
@@ -207,6 +208,29 @@ export async function handle(request, tools, { packageVersion, state } = {}) {
207
208
  }
208
209
  }
209
210
 
211
+ case 'resources/list':
212
+ return reply(
213
+ cacheable(v) ? { resources: await listResources(), ...LIST_CACHE } : { resources: await listResources() },
214
+ );
215
+ case 'resources/templates/list':
216
+ return reply(
217
+ cacheable(v)
218
+ ? { resourceTemplates: RESOURCE_TEMPLATES, ...LIST_CACHE }
219
+ : { resourceTemplates: RESOURCE_TEMPLATES },
220
+ );
221
+ case 'resources/read': {
222
+ // The code for a resource that is not there CHANGED, and a server that answers
223
+ // with one code to both revisions is a server that is wrong to whichever half of
224
+ // the client's world it is not. The newest revision forbids an empty `contents`
225
+ // array as the answer, so "absent" can only be an error.
226
+ const code = atLeast(v, '2026-07-28') ? RPC.RESOURCE_NOT_FOUND : RPC.RESOURCE_NOT_FOUND_LEGACY;
227
+ const uri = request.params?.uri;
228
+ const found = await readResource(uri);
229
+ if (found === null) return error(id, code, `no such resource: ${String(uri)}`);
230
+ return reply(
231
+ cacheable(v) ? { contents: [found], ...LIST_CACHE } : { contents: [found] },
232
+ );
233
+ }
210
234
  default:
211
235
  return isNotification ? null : error(id, RPC.METHOD, `unknown method "${method}"`);
212
236
  }
@@ -219,12 +243,19 @@ function serverInfo(packageVersion) {
219
243
  return { ...SERVER_INFO, version: packageVersion ?? '0.0.0' };
220
244
  }
221
245
 
222
- function capabilities(tools) {
246
+ function capabilities() {
223
247
  return {
224
248
  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 } } : {}),
249
+ // Unconditional, and that is the point: every rule and every decision record ships
250
+ // with the package, so there is always something to read. The empty object is the
251
+ // documented way to say "resources, but no subscriptions and no change
252
+ // notifications" — both of which this server has no way to honour, and a client
253
+ // told otherwise would subscribe to a stream that never arrives.
254
+ //
255
+ // It used to be conditional on a property hung on the tools array, which nothing
256
+ // ever set: a capability gated on a flag nobody flips is the same promise a client
257
+ // waits on forever, only quieter.
258
+ resources: {},
228
259
  };
229
260
  }
230
261