simt-dev 0.2.2 → 0.3.1
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.
- package/CHANGELOG.md +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -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,112 @@
|
|
|
1
|
+
// The evidence ledger (ADR-087).
|
|
2
|
+
//
|
|
3
|
+
// A passing command is evidence about one tree and no other. The tree is taken with a
|
|
4
|
+
// private index, because `git add -A` on the real index would stage work the author had
|
|
5
|
+
// left unstaged. Editing any file afterwards produces a different tree, so the old line
|
|
6
|
+
// no longer proves anything about what is on disk now.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { appendFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
const exec = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
const LEDGER_FILE = '.simt-dev/ledger.jsonl';
|
|
18
|
+
|
|
19
|
+
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
20
|
+
* first, because both contain the word and neither is a claim. */
|
|
21
|
+
const CLAIM = /\btests?\s+pass(?:ed)?\b|\bverified\b/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The tree of everything in the worktree, without staging any of it.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} root
|
|
27
|
+
* @returns {Promise<string>} the write-tree id
|
|
28
|
+
*/
|
|
29
|
+
export async function treeId(root) {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), 'tree-index-'));
|
|
31
|
+
const index = join(dir, 'index');
|
|
32
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
33
|
+
try {
|
|
34
|
+
// The ledger is a record of the tree, so it cannot be part of the tree it names.
|
|
35
|
+
// `git add -A` would take it once the file exists, and the line would prove a
|
|
36
|
+
// tree that stopped existing when the line was written.
|
|
37
|
+
await exec('git', ['add', '-A', '--', '.', ':(exclude).simt-dev/ledger.jsonl'], { cwd: root, env });
|
|
38
|
+
const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append one run. The output itself is not stored: the digest is enough to notice a
|
|
47
|
+
* change, and a ledger that kept logs would keep whatever the command printed.
|
|
48
|
+
*/
|
|
49
|
+
export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
50
|
+
const line = {
|
|
51
|
+
v: 1,
|
|
52
|
+
ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
53
|
+
tree: await treeId(root),
|
|
54
|
+
cmd,
|
|
55
|
+
exit,
|
|
56
|
+
ms,
|
|
57
|
+
outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
|
|
58
|
+
summary: summarise(output, exit),
|
|
59
|
+
};
|
|
60
|
+
await mkdir(join(root, '.simt-dev'), { recursive: true });
|
|
61
|
+
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
62
|
+
return line;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every well-formed line. A line that does not parse is returned as an error so a
|
|
66
|
+
* damaged ledger cannot look empty. */
|
|
67
|
+
export async function readLedger(root) {
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = await readFile(join(root, LEDGER_FILE), 'utf8');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT') return { entries: [], errors: [] };
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const entries = [];
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (const [i, raw] of text.split('\n').entries()) {
|
|
78
|
+
if (raw.trim() === '') continue;
|
|
79
|
+
try {
|
|
80
|
+
const line = JSON.parse(raw);
|
|
81
|
+
if (typeof line.tree !== 'string' || typeof line.exit !== 'number') {
|
|
82
|
+
errors.push(`line ${i + 1} has no tree or exit`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
entries.push(line);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
errors.push(`line ${i + 1} does not parse (${error.message})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { entries, errors };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function claimsVerification(message) {
|
|
94
|
+
const text = String(message).replace(/\bunverified\b/gi, ' ').replace(/\bnot\s+verified\b/gi, ' ');
|
|
95
|
+
return CLAIM.test(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `block` fails the commit. Anything unset is a warning. A third spelling is an
|
|
99
|
+
* error, because a typo for `block` must not quietly warn. */
|
|
100
|
+
export function evidenceMode(manifest) {
|
|
101
|
+
const value = manifest?.gates?.evidence;
|
|
102
|
+
if (value === undefined || value === 'warn') return 'warn';
|
|
103
|
+
if (value === 'block') return 'block';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarise(output, exit) {
|
|
108
|
+
const pass = /(\d+)\s+pass\b/.exec(output) ?? /\bpass\s+(\d+)/.exec(output);
|
|
109
|
+
const fail = /(\d+)\s+fail\b/.exec(output) ?? /\bfail\s+(\d+)/.exec(output);
|
|
110
|
+
if (pass || fail) return `${pass?.[1] ?? 0} pass, ${fail?.[1] ?? 0} fail`;
|
|
111
|
+
return `exit ${exit}`;
|
|
112
|
+
}
|
|
@@ -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`;
|
package/src/mcp/server.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
246
|
+
function capabilities() {
|
|
223
247
|
return {
|
|
224
248
|
tools: { listChanged: false },
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { discover } from '../compare/discover.mjs';
|
|
|
27
27
|
import { buildRow, summarise } from '../compare/row.mjs';
|
|
28
28
|
import { GATES } from '../hooks/gates.mjs';
|
|
29
29
|
import { dirtyPaths, parsePathList, readBody } from '../commands/skills.mjs';
|
|
30
|
+
import { planNext, planDone } from '../plan/index.mjs';
|
|
30
31
|
import { resolveWorkspace } from '../workspace.mjs';
|
|
31
32
|
import { measureSkill } from '../graph/load.mjs';
|
|
32
33
|
import { CLI } from '../name.mjs';
|
|
@@ -308,6 +309,30 @@ const output = {
|
|
|
308
309
|
},
|
|
309
310
|
required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
|
|
310
311
|
},
|
|
312
|
+
plan_next: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
root: { type: 'string' },
|
|
316
|
+
next: { type: ['object', 'null'] },
|
|
317
|
+
reason: { type: ['string', 'null'] },
|
|
318
|
+
},
|
|
319
|
+
required: ['root', 'next', 'reason'],
|
|
320
|
+
},
|
|
321
|
+
plan_done: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
properties: {
|
|
324
|
+
root: { type: 'string' },
|
|
325
|
+
id: { type: 'string' },
|
|
326
|
+
doc: { type: 'string' },
|
|
327
|
+
line: { type: 'number' },
|
|
328
|
+
from: { type: 'string' },
|
|
329
|
+
to: { type: 'string' },
|
|
330
|
+
wouldChange: { type: 'boolean' },
|
|
331
|
+
dryRun: { type: 'boolean' },
|
|
332
|
+
remedy: { type: ['string', 'null'] },
|
|
333
|
+
},
|
|
334
|
+
required: ['root', 'id', 'wouldChange', 'dryRun'],
|
|
335
|
+
},
|
|
311
336
|
/** The shape of every tool failure, which is a result and not a protocol error. */
|
|
312
337
|
error: ERROR_SHAPE,
|
|
313
338
|
};
|
|
@@ -688,6 +713,65 @@ export const TOOLS = [
|
|
|
688
713
|
};
|
|
689
714
|
},
|
|
690
715
|
},
|
|
716
|
+
{
|
|
717
|
+
outputSchema: output.plan_next,
|
|
718
|
+
name: 'plan_next',
|
|
719
|
+
description: 'The next queued slice whose dependencies are complete, and only that slice\'s section. Pass id to read one slice whether or not it is ready.',
|
|
720
|
+
inputSchema: {
|
|
721
|
+
type: 'object',
|
|
722
|
+
properties: {
|
|
723
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
724
|
+
id: { type: 'string', description: 'slice id; when given, return that slice' },
|
|
725
|
+
},
|
|
726
|
+
required: [],
|
|
727
|
+
},
|
|
728
|
+
async run(args) {
|
|
729
|
+
const root = rootOf(args);
|
|
730
|
+
const id = typeof args?.id === 'string' && args.id.length > 0 ? args.id : null;
|
|
731
|
+
const result = await planNext(root, id);
|
|
732
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
733
|
+
return { root: result.root, next: result.next, reason: result.reason };
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
outputSchema: output.plan_done,
|
|
738
|
+
name: 'plan_done',
|
|
739
|
+
// Same function as the command, and deliberately a preview. This surface does
|
|
740
|
+
// not write: the row change is a diff, and the command applies it behind --yes.
|
|
741
|
+
description: 'Preview the one-row rewrite for a slice. The write stays on the plan done command, behind --yes.',
|
|
742
|
+
inputSchema: {
|
|
743
|
+
type: 'object',
|
|
744
|
+
properties: {
|
|
745
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
746
|
+
id: { type: 'string', description: 'slice id' },
|
|
747
|
+
sha: { type: 'string', description: 'commit sha that closed the slice' },
|
|
748
|
+
test: { type: 'string', description: 'test command that closed it' },
|
|
749
|
+
state: { type: 'string', description: 'target state; omitted means the completed state of the repository template (Complete for tracker)' },
|
|
750
|
+
},
|
|
751
|
+
required: ['id'],
|
|
752
|
+
},
|
|
753
|
+
async run(args) {
|
|
754
|
+
const root = rootOf(args);
|
|
755
|
+
const result = await planDone(root, args?.id, {
|
|
756
|
+
sha: args?.sha ?? null,
|
|
757
|
+
test: args?.test ?? null,
|
|
758
|
+
state: args?.state ?? null,
|
|
759
|
+
dryRun: true,
|
|
760
|
+
});
|
|
761
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
762
|
+
return {
|
|
763
|
+
root: result.root,
|
|
764
|
+
id: result.id,
|
|
765
|
+
doc: result.doc,
|
|
766
|
+
line: result.line,
|
|
767
|
+
from: result.from,
|
|
768
|
+
to: result.to,
|
|
769
|
+
wouldChange: result.wouldChange,
|
|
770
|
+
dryRun: true,
|
|
771
|
+
remedy: result.remedy,
|
|
772
|
+
};
|
|
773
|
+
},
|
|
774
|
+
},
|
|
691
775
|
];
|
|
692
776
|
|
|
693
777
|
export function toolNames() {
|