simt-dev 0.6.0 → 0.6.3

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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
package/src/diff.mjs ADDED
@@ -0,0 +1,254 @@
1
+ // Unified diffs of the plan (plan 0003 R50).
2
+ //
3
+ // Every writer accepts `--diff` and prints what it would change — or, with
4
+ // `--yes`, what it changed — as unified diffs, one per file. The engine is
5
+ // here, zero dependencies like everything else (a DX tool that needs an
6
+ // install step fails on a fresh machine); each command only supplies
7
+ // {path, before, after} pairs, read before anything is written.
8
+ //
9
+ // Two deliberate boundaries:
10
+ //
11
+ // - Lines, not bytes. All files this tool writes are text; a binary would
12
+ // diff as one giant replaced line, which says nothing. Callers pass
13
+ // strings (null when the file is absent on that side).
14
+ // - Big files degrade, they do not hang. Past 10,000 lines combined the
15
+ // pair renders as one delete-all/insert-all hunk: still a true statement
16
+ // about what changed, just without the search. Myers is O(ND) and a
17
+ // pathological pair would otherwise own the prompt.
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+
21
+ import { emit, isJson } from './output/index.mjs';
22
+
23
+ const CONTEXT = 3;
24
+ const LINE_CAP = 10000;
25
+
26
+ /**
27
+ * The current bytes of a path, or null when it does not exist. Any other
28
+ * failure propagates: an unreadable file is fail-closed, not treated as
29
+ * absent — a diff against the wrong before would be worse than no diff.
30
+ */
31
+ export async function readBefore(path) {
32
+ try {
33
+ return await readFile(path, 'utf8');
34
+ } catch (error) {
35
+ if (error.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Print a run's pairs as unified diffs, or nothing when the plan changes no
42
+ * bytes (an empty creation, an already-current file). Never changes what the
43
+ * run writes or its exit code — callers invoke it in preview and apply alike.
44
+ *
45
+ * Silent under `--json`: the document is the whole report there, and prose on
46
+ * stdout would break the one-document contract. Callers spread `diffField`
47
+ * into their payload instead, so the JSON answer carries the same bytes.
48
+ */
49
+ export function emitDiffs(pairs, flags) {
50
+ if (isJson(flags)) return;
51
+ const text = renderDiffs(pairs);
52
+ if (text !== '') emit(text, { maxBytes: flags.maxBytes ?? 0 });
53
+ }
54
+
55
+ /** The `--diff` bytes for a JSON payload: absent entirely without the flag, so
56
+ * payloads without it are byte-identical to before. */
57
+ export function diffField(pairs, flags) {
58
+ return flags.diff === true ? { diff: renderDiffs(pairs) } : {};
59
+ }
60
+
61
+ /** Split text into lines, remembering whether the last line was terminated. */
62
+ function splitLines(text) {
63
+ if (text === '') return { lines: [], terminated: true };
64
+ const terminated = text.endsWith('\n');
65
+ const raw = text.split('\n');
66
+ if (terminated) raw.pop();
67
+ return { lines: raw.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l)), terminated };
68
+ }
69
+
70
+ /**
71
+ * Myers O(ND) edit script as a list of {op, line} with op in keep/del/add.
72
+ * Deterministic: deletions sort before additions at every branch, so the
73
+ * same pair always renders the same hunks.
74
+ */
75
+ function script(a, b) {
76
+ const n = a.length;
77
+ const m = b.length;
78
+ if (n + m > LINE_CAP) {
79
+ return [...a.map((line) => ({ op: 'del', line })), ...b.map((line) => ({ op: 'add', line }))];
80
+ }
81
+ const max = n + m;
82
+ const trace = [];
83
+ let v = new Map([[1, 0]]);
84
+ let done = false;
85
+ for (let d = 0; d <= max && !done; d += 1) {
86
+ const cur = new Map(v);
87
+ for (let k = -d; k <= d; k += 2) {
88
+ let x;
89
+ if (k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1))) {
90
+ x = v.get(k + 1) ?? 0;
91
+ } else {
92
+ x = (v.get(k - 1) ?? -1) + 1;
93
+ }
94
+ let y = x - k;
95
+ while (x < n && y < m && a[x] === b[y]) {
96
+ x += 1;
97
+ y += 1;
98
+ }
99
+ cur.set(k, x);
100
+ if (x >= n && y >= m) {
101
+ done = true;
102
+ trace.push(cur);
103
+ break;
104
+ }
105
+ }
106
+ if (!done) trace.push(cur);
107
+ v = cur;
108
+ if (done) break;
109
+ }
110
+ // Walk the trace backwards, emitting the script in reverse.
111
+ const ops = [];
112
+ let x = n;
113
+ let y = m;
114
+ for (let d = trace.length - 1; d > 0; d -= 1) {
115
+ const prev = trace[d - 1];
116
+ const k = x - y;
117
+ const down = k === -d || (k !== d && (prev.get(k - 1) ?? -1) < (prev.get(k + 1) ?? -1));
118
+ const kPrev = down ? k + 1 : k - 1;
119
+ const xPrev = prev.get(kPrev) ?? 0;
120
+ const yPrev = xPrev - kPrev;
121
+ while (x > xPrev && y > yPrev) {
122
+ ops.push({ op: 'keep', line: a[x - 1] });
123
+ x -= 1;
124
+ y -= 1;
125
+ }
126
+ if (down) {
127
+ ops.push({ op: 'add', line: b[y - 1] });
128
+ y -= 1;
129
+ } else {
130
+ ops.push({ op: 'del', line: a[x - 1] });
131
+ x -= 1;
132
+ }
133
+ }
134
+ while (x > 0 && y > 0) {
135
+ ops.push({ op: 'keep', line: a[x - 1] });
136
+ x -= 1;
137
+ y -= 1;
138
+ }
139
+ return ops.reverse();
140
+ }
141
+
142
+ /**
143
+ * Group changed ops into hunks with context. Two changes merge when at most
144
+ * 2×CONTEXT unchanged lines sit between them — the trailing context of one
145
+ * is the leading context of the next — and split otherwise.
146
+ */
147
+ function hunks(ops) {
148
+ // Line numbers before each op, per side.
149
+ const nums = [];
150
+ let a = 0;
151
+ let b = 0;
152
+ for (const o of ops) {
153
+ nums.push([a, b]);
154
+ if (o.op !== 'add') a += 1;
155
+ if (o.op !== 'del') b += 1;
156
+ }
157
+ const changed = ops.map((o, i) => (o.op === 'keep' ? -1 : i)).filter((i) => i >= 0);
158
+ if (changed.length === 0) return [];
159
+ const groups = [];
160
+ let g = [changed[0]];
161
+ for (let i = 1; i < changed.length; i += 1) {
162
+ if (changed[i] - g[g.length - 1] <= CONTEXT * 2) g.push(changed[i]);
163
+ else {
164
+ groups.push(g);
165
+ g = [changed[i]];
166
+ }
167
+ }
168
+ groups.push(g);
169
+ return groups.map((idx) => {
170
+ const start = Math.max(0, idx[0] - CONTEXT);
171
+ const end = Math.min(ops.length - 1, idx[idx.length - 1] + CONTEXT);
172
+ const slice = ops.slice(start, end + 1);
173
+ let aLen = 0;
174
+ let bLen = 0;
175
+ for (const o of slice) {
176
+ if (o.op !== 'add') aLen += 1;
177
+ if (o.op !== 'del') bLen += 1;
178
+ }
179
+ // GNU shape: an empty side starts at the line before (`-0,0`), a single
180
+ // line omits its `,1`.
181
+ const aStart = aLen === 0 ? nums[start][0] : nums[start][0] + 1;
182
+ const bStart = bLen === 0 ? nums[start][1] : nums[start][1] + 1;
183
+ return { aStart, bStart, aLen, bLen, ops: slice };
184
+ });
185
+ }
186
+
187
+ function hunkHeader(h) {
188
+ const a = h.aLen === 1 ? `${h.aStart}` : `${h.aStart},${h.aLen}`;
189
+ const b = h.bLen === 1 ? `${h.bStart}` : `${h.bStart},${h.bLen}`;
190
+ return `@@ -${a} +${b} @@`;
191
+ }
192
+
193
+ /**
194
+ * One file's unified diff, or '' when the two sides are equal. `before` null
195
+ * is a creation, `after` null a removal. An empty creation (or removal of an
196
+ * empty file) has no line changes and renders as '' — the plan already says
197
+ * `created`, and headers with no hunks are noise.
198
+ */
199
+ export function unifiedDiff(path, before, after) {
200
+ const b = before ?? null;
201
+ const a = after ?? null;
202
+ if (b === a) return '';
203
+ const bs = b === null ? { lines: [], terminated: true } : splitLines(b);
204
+ const as = a === null ? { lines: [], terminated: true } : splitLines(a);
205
+ const ops = script(bs.lines, as.lines);
206
+ if (ops.every((o) => o.op === 'keep')) {
207
+ // Same lines, different terminator: the bytes still differ, so the last
208
+ // line rewrites rather than vanishing from the diff.
209
+ if (bs.terminated === as.terminated || ops.length === 0) return '';
210
+ const last = ops[ops.length - 1];
211
+ ops.splice(ops.length - 1, 1, { op: 'del', line: last.line }, { op: 'add', line: last.line });
212
+ }
213
+ const head = [
214
+ `diff --git a/${path} b/${path}`,
215
+ b === null ? '--- /dev/null' : `--- a/${path}`,
216
+ a === null ? '+++ /dev/null' : `+++ b/${path}`,
217
+ ];
218
+ const body = [];
219
+ for (const h of hunks(ops)) {
220
+ body.push(hunkHeader(h));
221
+ // Positions inside the hunk, to place the missing-newline markers exactly
222
+ // where GNU does: after the last line of a side that lacks its terminator.
223
+ let ai = h.aStart + (h.aLen === 0 ? 1 : 0);
224
+ let bi = h.bStart + (h.bLen === 0 ? 1 : 0);
225
+ for (const o of h.ops) {
226
+ if (o.op === 'keep') {
227
+ body.push(` ${o.line}`);
228
+ ai += 1;
229
+ bi += 1;
230
+ } else if (o.op === 'del') {
231
+ body.push(`-${o.line}`);
232
+ if (ai === bs.lines.length && !bs.terminated) body.push('\');
233
+ ai += 1;
234
+ } else {
235
+ body.push(`+${o.line}`);
236
+ if (bi === as.lines.length && !as.terminated) body.push('\');
237
+ bi += 1;
238
+ }
239
+ }
240
+ }
241
+ return [...head, ...body].join('\n');
242
+ }
243
+
244
+ /**
245
+ * Render a run's pairs, skipping the equal and the empty. Paths print in the
246
+ * order given — the caller owns the order, usually the write order — and
247
+ * blocks join with one blank line, the way a reader scans them.
248
+ */
249
+ export function renderDiffs(pairs) {
250
+ return pairs
251
+ .map(({ path, before, after }) => unifiedDiff(path, before ?? null, after ?? null))
252
+ .filter((d) => d !== '')
253
+ .join('\n\n');
254
+ }
@@ -0,0 +1,258 @@
1
+ // The finding-code table behind `simt explain <CODE>` (plan 0003 R46).
2
+ //
3
+ // Every status `simt check` can emit, one row each: what the reader is looking
4
+ // at, which decision settled that it is a finding (the `why` an agent needs
5
+ // before it "fixes" something that is working as designed), how to fix it, and
6
+ // a concrete trigger. The acceptance is coverage, not prose: `test/explain.test.mjs`
7
+ // scans the check pipeline for `status:` literals and fails when one has no row
8
+ // here, so a new finding that ships without documentation is a red gate.
9
+ //
10
+ // Keys are the canonical lowercase codes. `check` prints them uppercased; the
11
+ // command folds case before lookup, so DRIFT and drift are the same question.
12
+
13
+ /**
14
+ * @typedef {{what: string, why: string, fix: string, example: string}} CodeRow
15
+ * @type {Record<string, CodeRow>}
16
+ */
17
+ export const EXPLAIN_CODES = {
18
+ absent: {
19
+ what: 'The file the gate reads does not exist: no AGENTS.md at the repository root, so there is no region to compare.',
20
+ why: 'ADR-025: the region is the baseline in a file the repository owns. Absence is the pre-adoption state — there is nothing to verify against yet, which is why the gate reports it instead of passing a repository it never inspected.',
21
+ fix: 'Run: simt sync --yes to adopt the repository and write the region.',
22
+ example: 'A fresh clone before `simt init`: `check` reports ABSENT AGENTS.md.',
23
+ },
24
+ 'beyond-read-limit': {
25
+ what: 'The generated region ends past a reader\u2019s documented read limit, so that reader stops mid-region and never sees the rules below the cut.',
26
+ why: 'ADR-020: rules are projected per surface, and each surface declares what its reader actually loads. A region longer than the limit is a rule the reader was promised and cannot see.',
27
+ fix: 'Set projection.place to "top" in simt.json and run: simt sync --yes, or raise the reader\u2019s project_doc_max_bytes.',
28
+ example: 'A 40 KB region with a Cursor-side 20 KB read: BEYOND-READ-LIMIT names the reader, the limit and the overrun in bytes.',
29
+ },
30
+ budget: {
31
+ what: 'The always-on projection carries more bytes than this repository agreed to carry in budget.bytes — and nothing was cut.',
32
+ why: 'ADR-072: the budget is a number the repository declares, and the gate reports the overrun without enforcing it by deletion. Nothing is cut in full mode, so a reader told only "over budget" must not go looking for a removed rule.',
33
+ fix: 'Raise budget.bytes if the projection is meant to be this size, switch mode to kernel to defer convention and style rules, or narrow the rules that carry it.',
34
+ example: 'budget.bytes 12000 with a 18400-byte projection: BUDGET names the bytes and the ceiling.',
35
+ },
36
+ 'budget-cut': {
37
+ what: 'The projection was cut to fit budget.bytes: kernel mode packed least-valuable-first and named every cut rule under "Deferred rules".',
38
+ why: 'ADR-083: the cut is the tool doing what the repository was told — a repository that declared a budget asked for exactly this. Failing the gate for it would fail the tool for obeying.',
39
+ fix: 'No fix needed: this is reported, not failed. Read the Deferred rules section (simt rules show <id> restores any cut rule), or raise budget.bytes.',
40
+ example: 'A kernel-mode repo 3 KB over budget: BUDGET-CUT lists the deferred rule ids.',
41
+ },
42
+ 'check-unbuilt': {
43
+ what: 'A selected rule declares a checker this tool does not know, so the gate has no direction to enforce and refuses to pretend otherwise.',
44
+ why: 'ADR-088: declared checkers are compiled from rule prose, and a name that resolves to nothing is not a check — it is a sentence shaped like one. Reported, never blocking: a warning, and a checker with no direction to enforce, is not a red gate.',
45
+ fix: 'Correct the checker name in the rule, or implement it; until then the gate reports and does not fail.',
46
+ example: 'A rule naming `max-paragraphs` when no such checker exists.',
47
+ },
48
+ current: {
49
+ what: 'Not a defect: the file on disk is byte-for-byte what the tool would write. This is the gate\u2019s passing state for that file.',
50
+ why: 'ADR-036: a check that finds no drift exits 0, and the per-file CURRENT lines are the receipt — the answer to "which files did the gate actually look at".',
51
+ fix: 'No fix needed. If every file is current, the gate is green.',
52
+ example: 'AGENTS.md after `simt sync --yes`: CURRENT, with no remedy line.',
53
+ },
54
+ drift: {
55
+ what: 'The file on disk differs from what the tool would write: someone edited the generated region, or the rules changed underneath it.',
56
+ why: 'ADR-036: a bare sync is a check that prints the plan, and a check that finds drift exits non-zero. The region is generated text; hand edits to it are overwritten by the next sync, so the gate fails rather than silently blessing them.',
57
+ fix: 'Run: simt sync --yes to regenerate, or move the hand edit outside the region markers.',
58
+ example: 'A hand-added paragraph inside the simt region of AGENTS.md.',
59
+ },
60
+ 'file-lines': {
61
+ what: 'A file exceeds the 1000-line hard ceiling: it fails the gate.',
62
+ why: 'ADR-005: 400 lines is a review trigger, 1000 is a hard ceiling, and generated files are exempt. The ceiling is a default a repository can narrow, never a number an agent negotiates at write time.',
63
+ fix: 'Split the file, or narrow the ceiling deliberately in the rule that declares it — not by editing the generated line.',
64
+ example: 'A 1240-line module with no exemption: FILE-LINES names the file and the count.',
65
+ },
66
+ 'file-lines-warn': {
67
+ what: 'A file passed the 400-line review trigger but is under the ceiling: reported, and the gate stays green.',
68
+ why: 'ADR-005: the trigger exists to start a review, not to fail a gate. A warning that failed would turn every growing file into a red repository months before the ceiling mattered.',
69
+ fix: 'No fix needed unless the growth continues: review the file, split it early, or ignore the warning.',
70
+ example: 'A 460-line file: FILE-LINES-WARN, no remedy, exit still 0.',
71
+ },
72
+ 'forbid-regex': {
73
+ what: 'A rule forbids a pattern and the pattern occurs: the file contains text the repository\u2019s own rules refuse.',
74
+ why: 'ADR-088: declared checkers read the rule source, not the folded region, so a line that left the prompt is still the line that runs. The rule is the decision; this finding is its enforcement.',
75
+ fix: 'Remove the matching text, or amend the rule if the pattern is now legitimate.',
76
+ example: 'A rule forbidding `console.log` in a file that logs to the console.',
77
+ },
78
+ 'forbidden-path': {
79
+ what: 'A path the repository forbids was touched: the change reaches a file the rules say is out of bounds.',
80
+ why: 'ADR-088: path checkers are declared beside the rules they protect, and a gate that watched content but not location would guard the words while the layout rotted.',
81
+ fix: 'Revert the touch, move the work to an allowed path, or amend the rule.',
82
+ example: 'A commit editing generated output the rules declare read-only.',
83
+ },
84
+ 'gate-cwd-missing': {
85
+ what: 'gates.cwd names a directory that is not a directory in this repository: the gate would run somewhere that does not exist.',
86
+ why: 'ADR-017: a declared gate must be runnable, and a gate whose working directory is missing fails closed on every commit — after the commit was written, which is the wrong order. Reported here so the reader finds out before committing.',
87
+ fix: 'Point gates.cwd at a directory in this repository, or remove it to run the gate at the root.',
88
+ example: 'gates.cwd.test set to "frontend" after the directory was renamed to "web".',
89
+ },
90
+ 'gate-cwd-outside': {
91
+ what: 'gates.cwd resolves outside the repository: the gate would run outside the tree it is supposed to guard.',
92
+ why: 'ADR-017: the dispatcher fails closed on a cwd outside the repository, because a gate running elsewhere enforces nothing here while reporting green. The finding names the gate and the path, since "no such directory" and "outside the repository" are repaired by different edits.',
93
+ fix: 'Point gates.cwd at a directory inside this repository, or remove it.',
94
+ example: 'gates.cwd.test set to "../shared" in a monorepo member.',
95
+ },
96
+ inconsistent: {
97
+ what: 'The TypeScript base config disagrees with this tool\u2019s floor in a way no flag explains: the project\u2019s tsconfig and the shipped base describe different compilers.',
98
+ why: 'ADR-025 and ADR-026: the base file is owned here and pointed at there, so a disagreement is a real fork in what the typechecker enforces — not a formatting choice. A repo that edited the owned bytes is not drift, but a repo whose config contradicts them is.',
99
+ fix: 'Re-align the project tsconfig with the shipped base, or record why the fork is deliberate.',
100
+ example: 'A project tsconfig setting strict false under a base that requires it.',
101
+ },
102
+ inert: {
103
+ what: 'The TypeScript base config is present but inert: nothing in the repository reads it, so it constrains no file.',
104
+ why: 'ADR-026: a config nobody reads is decoration that misdescribes the artefact — the same defect as a rule no surface projects. Reported so the dead file is either wired up or deleted.',
105
+ fix: 'Point the project tsconfig at the base, or delete the base file.',
106
+ example: 'A tsconfig.base.json no project tsconfig extends.',
107
+ },
108
+ invalid: {
109
+ what: 'A file the tool reads declares something it cannot use: a rule with unparseable frontmatter, a manifest surface no editor documents, or a checker error the compiler reports.',
110
+ why: 'The tool reads strictly and fails loudly (ADR-088 for rules and checkers, ADR-020 for surfaces): a profile that silently skipped the broken half would project a region nobody can account for, and `rules why` could not resolve it.',
111
+ fix: 'Read the detail: it names the file and the reason. Repair the frontmatter key, the surface id, or the checker declaration it cites.',
112
+ example: 'A rule whose frontmatter has `simt-applies: [node` — the detail names the line.',
113
+ },
114
+ 'invalid-decision-frontmatter': {
115
+ what: 'An ADR\u2019s structured frontmatter does not parse: simt-supersedes names a record or ruling that does not exist, or the shape is not [{ adr, ruling }].',
116
+ why: 'Plan 0003 R34: supersession is data the tooling reads (`rules why`, `why`), and frontmatter that almost parses is a claim no reader can verify. Failing here is what keeps a typo from rewriting history silently.',
117
+ fix: 'Fix the simt-supersedes entry to [{ adr: "ADR-NNN", ruling: N }], with a real record and ruling number.',
118
+ example: 'simt-supersedes: [{ adr: "ADR-999", ruling: 1 }] when no ADR-999 exists.',
119
+ },
120
+ 'lock-absent': {
121
+ what: 'No lock is recorded: the projection was never locked, so there is nothing `--frozen` can compare against.',
122
+ why: 'ADR-086: the lock is the byte-identical contract across machines, and `--frozen` without one would compare the projection against nothing and call it current. Reported only under --frozen, because a repository that never locked is not drift.',
123
+ fix: 'Run: simt sync --yes to write the lock.',
124
+ example: '`simt check --frozen` on a repo adopted before the lock existed.',
125
+ },
126
+ 'lock-stale': {
127
+ what: 'The inputs changed and the lock was not rewritten: something the projection is computed from moved without re-locking.',
128
+ why: 'ADR-086: a stale lock is a lock describing another tree. Failing here is what makes the lock a contract rather than a souvenir — the wording says which of the two failures it was.',
129
+ fix: 'Run: simt sync --yes to rewrite the lock from the current inputs.',
130
+ example: 'A rule edited after the last sync, then `check --frozen`.',
131
+ },
132
+ 'lock-unexplained': {
133
+ what: 'The projection changed and the inputs did not: the outputs moved with no input change to explain them.',
134
+ why: 'ADR-086: this is the failure the lock exists to catch — an output no input explains is either a tool bug or a hand edit inside the region, and both must be loud rather than absorbed.',
135
+ fix: 'Run: simt sync --yes if the change is intended; if it recurs with no edits, file it as a tool defect with the lock diff.',
136
+ example: 'Hand-edited generated text with untouched rules, then `check --frozen`.',
137
+ },
138
+ 'lock-unreadable': {
139
+ what: 'The lock file cannot be read or parsed: --frozen has a lock it cannot verify.',
140
+ why: 'ADR-086: an unreadable lock is not an absent one — deleting the distinction would let a corrupted lock pass as "not yet locked". The error text is the detail, because the remedy depends on what broke.',
141
+ fix: 'Read the error: repair the file, or delete it and run: simt sync --yes to rewrite it.',
142
+ example: 'A lock file truncated by a crashed write: LOCK-UNREADABLE names the parse error.',
143
+ },
144
+ malformed: {
145
+ what: 'The generated region\u2019s markers do not parse: the begin/end lines are damaged, duplicated, or in the wrong order.',
146
+ why: 'The markers are the machine-readable boundary between owned prose and generated text. A region the parser cannot bound cannot be diffed, so the gate reports the damage (with the line) instead of guessing which half is yours.',
147
+ fix: 'Restore the marker lines, or delete the whole region and run: simt sync --yes to rewrite it cleanly.',
148
+ example: 'Two BEGIN lines after a bad merge: MALFORMED names the line.',
149
+ },
150
+ misplaced: {
151
+ what: 'The region is intact but not at the top of the file, so it overrides the settings below it — and in this format the last matching definition wins, so the repository\u2019s own settings silently stop applying.',
152
+ why: 'ADR-025: the baseline belongs where it cannot change meaning by position. The bytes are right and the meaning is wrong, which is the hardest kind of drift to notice, so it gets its own finding rather than a note.',
153
+ fix: 'Move the region markers above your own settings, or delete the region and set what you need.',
154
+ example: 'A project header prepended above the generated region in AGENTS.md.',
155
+ },
156
+ orphaned: {
157
+ what: 'A managed rule file this run would not write: narrowing a surface\u2019s output left a file the tool once generated and now ignores.',
158
+ why: 'ADR-021 ruling 6: sync does not delete a file it once wrote, and the gate must not fail on one either — a finding sync cannot repair and would never write again is a note with a remedy, not a problem (ADR-051 draws the same line for pointers).',
159
+ fix: 'Delete it by hand if it is truly stale, or leave it: the gate reports and passes.',
160
+ example: 'A .mcc rule dropped from the surface set, still on disk.',
161
+ },
162
+ 'pack-declared-twice': {
163
+ what: 'The manifest declares the same pack twice: the second declaration is read as the first, so one of them is dead text.',
164
+ why: 'ADR-065: a pack declaration is a pin, and two pins for one name cannot both be true. The resolver reads the first and reports the second rather than merging them and guessing.',
165
+ fix: 'Remove the duplicate entry, then: simt sync --yes.',
166
+ example: 'Two { name: "house" } entries after a bad merge of simt.json.',
167
+ },
168
+ 'pack-digest-mismatch': {
169
+ what: 'The pack\u2019s bytes no longer match the digest the manifest pins: someone edited the vendored copy, or vendored different bytes than the pin records.',
170
+ why: 'ADR-065: the digest is the per-repo answer to "which bytes were these". The pack still projects — dropping it would let the remedy delete the rules it carries, which is the opposite of what the reader meant — but the gate stays red until the pin is re-recorded, and both digests are printed.',
171
+ fix: 'Restore the pinned bytes with: git checkout -- .simt/packs/<name> (or record the new digest, then: simt sync --yes).',
172
+ example: 'A hand edit inside .simt/packs/house/rules/: PACK-DIGEST-MISMATCH prints both digests.',
173
+ },
174
+ 'pack-missing': {
175
+ what: 'The manifest declares a pack with no directory: every rule the pack carries is missing from this repository\u2019s projection.',
176
+ why: 'ADR-065: a region rendered without half its rules tells the reader something false, and the next sync would overwrite the truth on disk with the shorter region. The remedy restores the bytes git has — sync cannot help, because it would render the region without the pack and call it current.',
177
+ fix: 'Restore it with: git checkout -- .simt/packs/<name> (or remove the declaration, then: simt sync --yes).',
178
+ example: 'A fresh clone with .simt/packs untracked: PACK-MISSING names the directory.',
179
+ },
180
+ 'pack-name-invalid': {
181
+ what: 'The manifest names a pack that cannot be a directory name: it escapes the pack directory or uses characters a path cannot hold.',
182
+ why: 'ADR-065: a pack name is built into a path the renderer reads, so a hand-edited manifest pointing outside the repository would make the region depend on bytes the reader does not have — the one failure this tool exists to prevent.',
183
+ fix: 'Correct or remove the entry, then: simt sync --yes.',
184
+ example: '{ name: "../escape" }: PACK-NAME-INVALID, and the entry is not resolved as a pack.',
185
+ },
186
+ 'pack-unpinned': {
187
+ what: 'The pack has no digest: nothing records which bytes it was declared at. Its rules still render, and the gate stays green.',
188
+ why: 'ADR-065: an absent pin is a missing record, not a mismatch — refusing to project would punish a repository for a field the tool treats as optional. Reported with the digest to record, so pinning is one copy-paste.',
189
+ fix: 'Record the printed digest as its digest, then: simt sync --yes.',
190
+ example: 'A freshly vendored pack before the first pin: PACK-UNPINNED prints the digest.',
191
+ },
192
+ 'pointer-blocked': {
193
+ what: 'The pointer file cannot be written where it belongs: something the tool does not own blocks the path.',
194
+ why: 'ADR-051: the pointer is the reader\u2019s only route to the rules, so a blocked pointer is not a convenience failing — it is the rules going unread. The gate fails rather than leaving a reader with a pointer at a file that is not the rules.',
195
+ fix: 'Remove or relocate the blocking file, then: simt sync --yes.',
196
+ example: 'A hand-written CLAUDE.md where the pointer belongs: POINTER-BLOCKED.',
197
+ },
198
+ 'pointer-unread': {
199
+ what: 'The pointer aims at a file the reader will not read: it names a path no adopted surface documents.',
200
+ why: 'ADR-051: a pointer the reader never follows is a rule nobody is held to. Sync refuses to write it (a gate whose remedy is the command being run is worse than a noisy gate); check is where it blocks.',
201
+ fix: 'Point the surface at a path the reader loads, or drop the surface.',
202
+ example: 'A surface aimed at docs/notes.md: POINTER-UNREAD names the path.',
203
+ },
204
+ 'prose-partial-supersession': {
205
+ what: 'An ADR states a partial supersession only in prose: "ruling N superseded by ADR-MMM" with no matching simt-supersedes frontmatter entry, so tooling cannot see the move.',
206
+ why: 'Plan 0003 R34 with ADR-049: a supersession the parser cannot see is a ruling that `rules why` reports as still standing. Stating it twice — prose for the reader, frontmatter for the tool — is what keeps history from being rewritten by a typo.',
207
+ fix: 'Declare simt-supersedes: [{ adr: "ADR-MMM", ruling: N }] in the record\u2019s frontmatter.',
208
+ example: 'A status line "ruling 2 narrowed by ADR-024" with empty frontmatter: the lint names the raw clause.',
209
+ },
210
+ reformatted: {
211
+ what: 'The manifest\u2019s content is what simt-dev would write, but the bytes differ: another formatter owns this file\u2019s shape.',
212
+ why: 'ADR-050: a formatter that rewrites the file on every run and a tool that rewrites it back would fight forever, with init as the only repair — which re-runs adoption to change one file\u2019s formatting. Comparing content-to-content isolates the real question: does another tool own this file.',
213
+ fix: 'Add simt.json to .prettierignore (a generated file is not prose), then: simt sync --yes.',
214
+ example: 'Prettier collapsing the manifest to one line: REFORMATTED, content identical.',
215
+ },
216
+ 'required-file': {
217
+ what: 'A rule requires a file the repository does not have: the project is missing something its own rules demand.',
218
+ why: 'ADR-088: required-file is a declared checker, and a declaration nobody verifies is a wish. The gate fails because the rule\u2019s author already decided this file is load-bearing.',
219
+ fix: 'Add the file, or amend the rule if the requirement moved on.',
220
+ example: 'A rule requiring SECURITY.md in a repo without one.',
221
+ },
222
+ 'stale-generated-field': {
223
+ what: 'Generated manifest fields do not match what simt-dev writes: the projection SHA, the stack, or the version pin drifted without a re-run.',
224
+ why: 'ADR-050: sync repairs exactly this field set and nothing else, so init\u2019s re-adoption is never the repair for a stale field. check names the fields; sync rewrites them.',
225
+ fix: 'Run: simt sync --yes.',
226
+ example: 'A hand-bumped version pin with the old projection SHA still recorded.',
227
+ },
228
+ unmanaged: {
229
+ what: 'The file exists but carries no generated region: the repository was never adopted, or the region was deleted by hand.',
230
+ why: 'ADR-025: with no region there is no baseline to verify against. Unmanaged is the normal mid-migration state, and the gate says so with the command that moves it forward rather than failing mysteriously.',
231
+ fix: 'Run: simt sync --yes to write the region (or simt init for a first adoption).',
232
+ example: 'AGENTS.md present, hand-written, no simt region: UNMANAGED.',
233
+ },
234
+ 'unknown-gate-cwd': {
235
+ what: 'gates.cwd names a gate the manifest never declares: the working-directory override points at nothing.',
236
+ why: 'ADR-017: an override for a gate that does not exist cannot run, and a gate that cannot run fails closed on every commit. Reported here so the typo surfaces before the first commit, not after.',
237
+ fix: 'Correct the gate name in gates.cwd, or declare the gate it was meant for.',
238
+ example: 'gates.cwd.test set while the manifest declares no test gate.',
239
+ },
240
+ unreadable: {
241
+ what: 'The gate could not read a file it must verify: permissions, a broken symlink, or an I/O error — anything but a clean absence.',
242
+ why: 'A check that is wrong about existence is worse than one that crashes: only ENOENT is absence, and every other failure is reported with its own code rather than folded into "missing". The gate refuses rather than passing a repository it never inspected.',
243
+ fix: 'Read the detail (it carries the OS error): fix permissions, remove the broken link, then re-run.',
244
+ example: 'AGENTS.md unreadable (EACCES): UNREADABLE with the errno, not ABSENT.',
245
+ },
246
+ unrunnable: {
247
+ what: 'A declared gate cannot run here: the command it needs is not on PATH, or its runner is missing.',
248
+ why: 'ADR-017: a gate that cannot run fails closed on every commit once hooks are installed — after the reader committed, which is the wrong order. check reports it before anyone installs anything, with the probe result that proved it.',
249
+ fix: 'Install the missing program, declare the gate\u2019s command in gates.commands, or drop the gate from the manifest.',
250
+ example: 'A lint gate naming eslint with no eslint on PATH: UNRUNNABLE names the probe.',
251
+ },
252
+ 'version-mismatch': {
253
+ what: 'The binary running the gate is not the version the manifest pins: the gate that runs is not the gate CI runs.',
254
+ why: 'ADR-059: the version a repository runs is the one it pinned, and a stale binary would run a different gate than the one the pin promises. The mismatch fails the gate rather than warning, because a warning is a gate that passes while disagreeing with CI.',
255
+ fix: 'Run: simt upgrade --yes to move the pin (and the binary) forward.',
256
+ example: 'Pin simt-dev 0.5.0 with 0.6.2 installed: VERSION-MISMATCH names both.',
257
+ },
258
+ };
@@ -35,6 +35,8 @@
35
35
  // runs. A malformed block costs the reader one line, which is exactly what they need to fix
36
36
  // it — and a silent skip is the failure that line exists to prevent.
37
37
 
38
+ import { isAbsolute, relative, resolve } from 'node:path';
39
+
38
40
  import { readManifest } from '../projection/manifest.mjs';
39
41
  import { DEFAULT_TIMEOUT_MS } from './client.mjs';
40
42
 
@@ -120,6 +122,48 @@ export function federationServers(manifest) {
120
122
  return { servers, findings };
121
123
  }
122
124
 
125
+ /**
126
+ * Whether the MCP server may ask repo-declared servers on this call.
127
+ *
128
+ * Pure: everything it reads comes in through the arguments, so the same call always
129
+ * gives the same answer. Two gates, both closed by default (Q13):
130
+ *
131
+ * 1. The `root` a model supplies must sit inside the server's own ground — its cwd or
132
+ * the resolved workspace root. A root anywhere else is a directory with a crafted
133
+ * manifest whose `command` this tool would otherwise spawn.
134
+ * 2. Each server asked must be named in the *user-level* config, not only in the
135
+ * repo manifest. A repository the model points at does not get to authorise
136
+ * process spawns on the operator's machine; the operator does, in their own file.
137
+ * `federation.allow: true` names every repo-declared server at once.
138
+ *
139
+ * @returns {null | {message: string, remedy: string}} null when the call may proceed.
140
+ */
141
+ export function federationAccess({ root, cwd, workspaceRoot, serverNames, userConfig, configPath }) {
142
+ const requested = resolve(root);
143
+ const grounds = [cwd, workspaceRoot].filter((base) => typeof base === 'string' && base.length > 0).map((base) => resolve(base));
144
+ const inside = grounds.some((base) => {
145
+ const rel = relative(base, requested);
146
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
147
+ });
148
+ if (!inside) {
149
+ return {
150
+ message: `federation_query refuses a root outside the server workspace: ${requested}`,
151
+ remedy: 'point root at a repository under the workspace this server was started in',
152
+ };
153
+ }
154
+ const federation = userConfig?.federation;
155
+ const allowAll = federation?.allow === true;
156
+ const allowed = Array.isArray(federation?.servers) ? federation.servers.filter((name) => typeof name === 'string') : [];
157
+ const refused = (serverNames ?? []).filter((name) => !allowAll && !allowed.includes(name));
158
+ if (refused.length > 0) {
159
+ return {
160
+ message: `federation server \`${refused[0]}\` is not named in the user config`,
161
+ remedy: `name it under \`federation.servers\` in ${configPath} (or set \`federation.allow: true\` to allow every repo-declared server)`,
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+
123
167
  /**
124
168
  * The same two lists, read from the repository's manifest.
125
169
  *
@@ -116,13 +116,22 @@ async function defaultRegistry(flags) {
116
116
  * environment, user config file, then a sibling heuristic that must verify - and a
117
117
  * failure to resolve is a WARNING, not 38 `ENOENT` lines (ADR-014).
118
118
  *
119
- * Resolved once per process and memoised, because every caller needs the same answer
120
- * and the answer cannot change while the process runs.
119
+ * Memoised per the inputs that decide the answer: the `--workspace` flag, the
120
+ * `SIMT_DEV_WORKSPACE` environment and the user-config location. A single
121
+ * first-call-wins promise served one caller's workspace to every later caller with
122
+ * different flags (Q14) — `skills_index({workspace: ws})` followed by
123
+ * `skills_index({})` answered `ws` twice. Callers with the same inputs still share
124
+ * the one resolution, which is where the cost was.
121
125
  */
122
- let workspacePromise = null;
126
+ const workspacePromises = new Map();
123
127
  export function workspace(flags = {}) {
124
- if (!workspacePromise) workspacePromise = resolveWorkspace(flags);
125
- return workspacePromise;
128
+ const key = JSON.stringify([
129
+ flags?.workspace ?? null,
130
+ process.env.SIMT_DEV_WORKSPACE ?? null,
131
+ process.env.XDG_CONFIG_HOME ?? null,
132
+ ]);
133
+ if (!workspacePromises.has(key)) workspacePromises.set(key, resolveWorkspace(flags));
134
+ return workspacePromises.get(key);
126
135
  }
127
136
 
128
137
  const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'deprecated']);