simt-dev 0.2.1 → 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 (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,339 @@
1
+ // Packages inside a repository, from the files that declare them.
2
+ //
3
+ // A monorepo is one repository with several packages in it, and every tool that
4
+ // reads a repository reads only the root: `fingerprint(root)` sees the root's
5
+ // markers, `plan(root)` writes the root's region, and a reader working in
6
+ // `apps/web` inherits whatever the root happened to be. The fix is not "a
7
+ // monorepo is two repositories" — it is one root projection plus a nested one
8
+ // per package, each carrying the profiles that package selects and the root does
9
+ // not (ADR-066).
10
+ //
11
+ // Detection is a table, not code per ecosystem (ADR-013): each row names the
12
+ // files that DECLARE members and how to read the member list out of them. Two
13
+ // rows are deliberately absent and their reason is recorded in ADR-066: `nx.json`
14
+ // declares no member list at all (its projects are computed by running `nx`), and
15
+ // a Gradle `include(...)` is written in the settings file rather than a manifest
16
+ // this table already parses. A row that could only be read by executing the
17
+ // toolchain is not a row: a projection that depended on running the build would
18
+ // be a projection this repository cannot reproduce.
19
+
20
+ import { readdir, readFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+
23
+ /** Packaged trees are not packages: their directories are dependencies. */
24
+ const SKIP_DIRS = new Set(['node_modules', 'target', 'vendor', 'dist', 'build', '.git', '.venv', 'venv']);
25
+
26
+ /** What makes a directory a package once a pattern has matched it. */
27
+ const PACKAGE_MARKERS = ['package.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pyproject.toml', 'mix.exs'];
28
+
29
+ /**
30
+ * The declarations this tool reads. `patterns` reads a list of repo-relative
31
+ * directory patterns out of the named file.
32
+ *
33
+ * `read` returns patterns in the order the file lists them, because the reader
34
+ * reviewing the generated files is entitled to see them in the order they wrote
35
+ * them, and a sort would hide a duplicate that the reader can act on.
36
+ */
37
+ const DECLARATIONS = [
38
+ {
39
+ ecosystem: 'node',
40
+ file: 'package.json',
41
+ evidence: 'workspaces field',
42
+ read: async (text) => {
43
+ let pkg;
44
+ try {
45
+ pkg = JSON.parse(text);
46
+ } catch {
47
+ return [];
48
+ }
49
+ const w = pkg.workspaces;
50
+ if (Array.isArray(w)) return w;
51
+ if (w && Array.isArray(w.packages)) return w.packages;
52
+ return [];
53
+ },
54
+ },
55
+ {
56
+ ecosystem: 'node',
57
+ file: 'pnpm-workspace.yaml',
58
+ evidence: 'packages list',
59
+ read: async (text) => {
60
+ // A YAML list under `packages:`. Only the flat list form is read: a pnpm
61
+ // workspace that needs a full YAML parser to declare its members is a
62
+ // workspace whose declaration this tool must not guess at, and
63
+ // `packages:` with nothing under it declares none.
64
+ const lines = text.split('\n');
65
+ const out = [];
66
+ let inside = false;
67
+ for (const line of lines) {
68
+ if (/^packages:\s*$/.test(line)) {
69
+ inside = true;
70
+ continue;
71
+ }
72
+ if (!inside) continue;
73
+ const item = /^\s+-\s*(.+?)\s*$/.exec(line);
74
+ if (item) out.push(stripQuotes(item[1]));
75
+ else if (/^\S/.test(line)) inside = false;
76
+ }
77
+ return out;
78
+ },
79
+ },
80
+ {
81
+ ecosystem: 'node',
82
+ file: 'turbo.json',
83
+ evidence: 'workspaces.packages list',
84
+ read: async (text) => {
85
+ let cfg;
86
+ try {
87
+ cfg = JSON.parse(text);
88
+ } catch {
89
+ return [];
90
+ }
91
+ const w = cfg.workspaces;
92
+ return w && Array.isArray(w.packages) ? w.packages : [];
93
+ },
94
+ },
95
+ {
96
+ ecosystem: 'rust',
97
+ file: 'Cargo.toml',
98
+ evidence: '[workspace] members list',
99
+ read: async (text) => {
100
+ const members = tomlList(text, 'workspace', 'members');
101
+ return members;
102
+ },
103
+ },
104
+ {
105
+ ecosystem: 'go',
106
+ file: 'go.work',
107
+ evidence: 'use list',
108
+ read: async (text) => {
109
+ const use = tomlList(text, '', 'use');
110
+ return use;
111
+ },
112
+ },
113
+ {
114
+ ecosystem: 'jvm',
115
+ file: 'settings.gradle',
116
+ evidence: 'include list',
117
+ read: async (text) => gradleIncludes(text),
118
+ },
119
+ {
120
+ ecosystem: 'jvm',
121
+ file: 'settings.gradle.kts',
122
+ evidence: 'include list',
123
+ read: async (text) => gradleIncludes(text),
124
+ },
125
+ ];
126
+
127
+ /**
128
+ * `include(":apps:web", ":core")` and its multi-line form, as paths.
129
+ *
130
+ * Gradle project paths are colon-separated and rooted at a colon, so `:apps:web`
131
+ * names the directory `apps/web`. That translation is all this reads: the arguments
132
+ * are literals, and a build that computes its includes from a variable is a build
133
+ * this tool does not evaluate - a directory with a marker file and no include is
134
+ * simply not discovered, which is the same honest absence every other unreadable
135
+ * declaration produces.
136
+ */
137
+ function gradleIncludes(text) {
138
+ const out = [];
139
+ for (const match of text.matchAll(/\binclude\s*\(([^)]*)\)/g)) {
140
+ for (const quoted of match[1].matchAll(/["']([^"']+)["']/g)) {
141
+ const path = quoted[1].replace(/^:/, '').split(':').filter(Boolean).join('/');
142
+ if (path !== '') out.push(path);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function stripQuotes(value) {
149
+ return value.replace(/^["']/, '').replace(/["']$/, '');
150
+ }
151
+
152
+ /**
153
+ * One table's key, read as a list of paths.
154
+ *
155
+ * Every shape this tool needs is a flat list — `members = ["a", "b"]`,
156
+ * `members = [\n "a",\n]`, `use (\n ./api\n ./cmd/x\n)` — so the reader is a
157
+ * line scan with two states rather than a parser. Paths are quoted in TOML and
158
+ * bare in a `go.work`, so both are accepted; a comment is stripped, because
159
+ * `use ./api # the api` declares one path and not two.
160
+ *
161
+ * `table` is `''` for a file with no tables (go.work), or the table name. A key
162
+ * this shape does not find returns an empty list: a declaration this tool cannot
163
+ * read is one it does not guess at.
164
+ */
165
+ function tomlList(text, table, key) {
166
+ const out = [];
167
+ let inTable = table === '';
168
+ let collecting = false;
169
+ for (const raw of text.split('\n')) {
170
+ const line = stripComment(raw);
171
+ if (/^\s*\[/.test(line)) {
172
+ inTable = table !== '' && line.trim() === `[${table}]`;
173
+ collecting = false;
174
+ continue;
175
+ }
176
+ if (!inTable) continue;
177
+ if (collecting) {
178
+ out.push(...pathTokens(line));
179
+ if (line.includes(']') || line.includes(')')) collecting = false;
180
+ continue;
181
+ }
182
+ // Two spellings, because `go.work` uses one and `Cargo.toml` the other:
183
+ // `members = ["crates/a"]` and a bare `use (` opening a list of paths.
184
+ const assigned = /^\s*([A-Za-z_.-]+)\s*=\s*(.*)$/.exec(line);
185
+ const opened = /^\s*([A-Za-z_.-]+)\s*\((.*)$/.exec(line);
186
+ const kv = assigned ?? opened;
187
+ if (kv === null || kv[1] !== key) continue;
188
+ const rest = (assigned !== null ? kv[2] : `(${kv[2]}`).trim();
189
+ if (rest === '[]' || rest === '') continue;
190
+ // `use (` and `members = [` both open a list that ends on a later line.
191
+ if (/[[(]$/.test(rest)) {
192
+ out.push(...pathTokens(rest));
193
+ collecting = true;
194
+ continue;
195
+ }
196
+ out.push(...pathTokens(rest));
197
+ }
198
+ return out;
199
+ }
200
+
201
+ function stripComment(line) {
202
+ const hash = line.indexOf('#');
203
+ return hash === -1 ? line : line.slice(0, hash);
204
+ }
205
+
206
+ /** The paths on one line of a list, quoted or bare, comma-separated. */
207
+ function pathTokens(line) {
208
+ const quoted = [...line.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
209
+ if (quoted.length > 0) return quoted;
210
+ return line
211
+ .replace(/[[\]()]/g, ' ')
212
+ .split(',')
213
+ .map((s) => s.trim())
214
+ .filter((s) => s !== '' && !s.startsWith('='));
215
+ }
216
+
217
+ /**
218
+ * Expand one declaration pattern into the directories it names.
219
+ *
220
+ * `*` matches within a path segment and `**` matches any depth. Both are what
221
+ * every one of these ecosystems writes, and a pattern syntax narrower than the
222
+ * declarations it reads is a detector that silently finds fewer packages than
223
+ * the repository has.
224
+ */
225
+ async function expand(root, pattern) {
226
+ const segments = pattern.split('/').filter((s) => s !== '' && s !== '.');
227
+ if (segments.length === 0) return [];
228
+ let dirs = [root];
229
+ for (const seg of segments) {
230
+ const next = [];
231
+ for (const dir of dirs) {
232
+ let entries;
233
+ try {
234
+ entries = await readdir(dir, { withFileTypes: true });
235
+ } catch {
236
+ continue;
237
+ }
238
+ for (const entry of entries) {
239
+ if (!entry.isDirectory()) continue;
240
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
241
+ if (matches(seg, entry.name)) next.push(join(dir, entry.name));
242
+ // `**` also admits what is below it, so a member pattern like
243
+ // `packages/**` finds `packages/a` and `packages/a/inner`.
244
+ if (seg === '**') {
245
+ next.push(join(dir, entry.name));
246
+ next.push(...(await expand(join(dir, entry.name), '**')));
247
+ }
248
+ }
249
+ }
250
+ dirs = [...new Set(next)];
251
+ }
252
+ return dirs;
253
+ }
254
+
255
+ /** One segment's match: `*` is any run of characters, everything else is literal. */
256
+ function matches(segment, name) {
257
+ if (segment === '**') return true;
258
+ if (!segment.includes('*')) return segment === name;
259
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
260
+ return new RegExp(`^${escaped}$`).test(name);
261
+ }
262
+
263
+ /**
264
+ * Every package this repository declares, in the order the declarations name
265
+ * them and then in path order, deduplicated.
266
+ *
267
+ * A pattern that names a directory with no package marker in it is not a
268
+ * package, and neither is a pattern that escapes the repository: a declaration
269
+ * like `../other-repo/packages/*` is a path this tool must refuse to follow,
270
+ * because everything this tool projects has to resolve inside the repository
271
+ * that received it.
272
+ *
273
+ * @returns {Promise<{packages: {path: string, ecosystem: string, evidence: string}[],
274
+ * notes: string[]}>}
275
+ */
276
+ export async function discoverPackages(root) {
277
+ const found = new Map();
278
+ const notes = [];
279
+
280
+ const declared = [];
281
+ for (const decl of DECLARATIONS) {
282
+ let text;
283
+ try {
284
+ text = await readFile(join(root, decl.file), 'utf8');
285
+ } catch {
286
+ continue;
287
+ }
288
+ const patterns = await decl.read(text);
289
+ if (patterns.length === 0) continue;
290
+ declared.push(`${decl.file} (${decl.evidence})`);
291
+ for (const pattern of patterns) {
292
+ const negated = stripQuotes(pattern).startsWith('!');
293
+ const clean = stripQuotes(pattern).replace(/^!/, '').replace(/\/+$/, '');
294
+ if (clean === '') {
295
+ notes.push(`${decl.file} names "${pattern}", which is not a path; ignored`);
296
+ continue;
297
+ }
298
+ if (clean.startsWith('..')) {
299
+ notes.push(`${decl.file} names "${pattern}", which is outside this repository; ignored`);
300
+ continue;
301
+ }
302
+ for (const dir of await expand(root, clean)) {
303
+ const rel = relativeTo(root, dir);
304
+ if (rel === '') continue;
305
+ // `!apps/legacy` excludes what an earlier pattern admitted, which is the
306
+ // only way pnpm and yarn express "this member is not built any more".
307
+ if (negated) {
308
+ found.delete(rel);
309
+ continue;
310
+ }
311
+ if (!(await isPackage(dir))) continue;
312
+ if (found.has(rel)) continue;
313
+ found.set(rel, { path: rel, ecosystem: decl.ecosystem, evidence: decl.evidence });
314
+ }
315
+ }
316
+ }
317
+
318
+ return {
319
+ packages: [...found.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
320
+ notes,
321
+ };
322
+ }
323
+
324
+ /** A directory is a package when something a build tool would install lives in it. */
325
+ async function isPackage(dir) {
326
+ for (const marker of PACKAGE_MARKERS) {
327
+ try {
328
+ await readFile(join(dir, marker));
329
+ return true;
330
+ } catch {
331
+ // not here
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+
337
+ function relativeTo(root, dir) {
338
+ return dir.slice(root.length).replace(/^\//, '');
339
+ }
@@ -28,6 +28,24 @@ const REMEDY = {
28
28
  [EXIT.REGISTRY]: `run: ${CLI} graph lint`,
29
29
  };
30
30
 
31
+ /**
32
+ * Whether failures are reported as JSON.
33
+ *
34
+ * A failure is a result: the caller asked a question and the answer is "no, and
35
+ * here is why". Written to stderr only, a caller parsing stdout under `--json`
36
+ * gets an empty document and has to believe the exit code, which tells it that
37
+ * something went wrong and nothing about what. So under `--json` the failure is
38
+ * the result and it goes where results go.
39
+ *
40
+ * The default answers "plain text" because that is what every run before the flags
41
+ * were parsed is, and those runs happen before anybody could have asked.
42
+ */
43
+ let jsonFailure = false;
44
+
45
+ export function jsonFailMode(on = false) {
46
+ jsonFailure = on;
47
+ }
48
+
31
49
  /**
32
50
  * Report a failure and return its exit code. Callers `return fail(...)` so the
33
51
  * code is impossible to forget.
@@ -38,6 +56,10 @@ export function fail(message, code = EXIT.FAIL, remedy) {
38
56
  // a reader in a consumer repo got a remedy naming a file that does not exist
39
57
  // where they are. Every remedy here is something the reader can run.
40
58
  const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
59
+ if (jsonFailure) {
60
+ emitJson({ ok: false, error: message, remedy: resolved, exitCode: code });
61
+ return code;
62
+ }
41
63
  process.stderr.write(`ERROR ${code} ${message}\nREMEDY ${resolved}\n`);
42
64
  return code;
43
65
  }
@@ -84,8 +106,62 @@ export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
84
106
  return true;
85
107
  }
86
108
 
87
- export function emitJson(value, { stream = process.stdout } = {}) {
88
- stream.write(`${JSON.stringify(value, null, 2)}\n`);
109
+ /**
110
+ * The shape of every JSON payload, in the payload.
111
+ *
112
+ * A reader parsing `--json` from an agent has no other way to detect that a field moved
113
+ * or was renamed between releases: the parse still succeeds, and the reader's next lookup
114
+ * returns undefined. The number is in every payload rather than in the documentation
115
+ * because documentation is the thing that goes stale without failing anything, and the
116
+ * failure this prevents is silent.
117
+ *
118
+ * Bumped when a payload changes in a way a reader would notice. Adding a field is not
119
+ * that; removing, renaming or retyping one is.
120
+ */
121
+ export const SCHEMA_VERSION = 1;
122
+
123
+ /**
124
+ * Whether the next payload is indented.
125
+ *
126
+ * Set once per run from the flags, rather than threaded through every call site: the
127
+ * alternative is twenty-five call sites each remembering to pass a value they do not
128
+ * otherwise care about, and the one that forgets is the one a reader pipes somewhere
129
+ * important. The default answers "is a person reading", which is the right answer
130
+ * before anybody sets it.
131
+ */
132
+ let prettyDefault = process.stdout.isTTY === true;
133
+
134
+ export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
135
+ prettyDefault = pretty;
136
+ }
137
+
138
+ /**
139
+ * Emit JSON, compact unless a human is reading it.
140
+ *
141
+ * Indentation is for a person, and an agent pays for it on every call: the same document
142
+ * is a third again larger, and the reader is `JSON.parse`, not an eye. So the default is
143
+ * compact, and `--pretty` asks for the readable form. The one exception is a TTY, where
144
+ * the only consumer is a person: the compact document saves them nothing and costs them
145
+ * the diff they were about to read.
146
+ */
147
+ export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
148
+ const payload =
149
+ value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
150
+ ? { schemaVersion: SCHEMA_VERSION, ...value }
151
+ : value;
152
+ const text = `${pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)}\n`;
153
+ // The same ceiling and the same announcement `emit` applies: a reader who asked for a
154
+ // bounded answer gets a bounded answer and is told how much is missing, rather than a
155
+ // half-parsed document it believes is whole.
156
+ if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
157
+ const slice = Buffer.from(text).subarray(0, maxBytes).toString('utf8');
158
+ const cut = slice.lastIndexOf('\n');
159
+ stream.write(cut > 0 ? `${slice.slice(0, cut)}\n` : slice);
160
+ stream.write(`TRUNCATED ${Buffer.byteLength(text) - maxBytes} bytes omitted, raise maxBytes\n`);
161
+ return false;
162
+ }
163
+ stream.write(text);
164
+ return true;
89
165
  }
90
166
 
91
167
  /**
package/src/packs.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // Shared rule sets, vendored into the repository that uses them.
2
+ //
3
+ // A pack is one answer to a question fourteen repositories kept asking separately:
4
+ // "several repositories share one set of rules, and it must change in one place."
5
+ // What a pack is NOT is a remote fetch. The content lives in the adopting
6
+ // repository at `.simt-dev/packs/<name>/`, and the manifest records a digest of
7
+ // it. Reading it from anywhere else would make the rendered region depend on a
8
+ // path that does not resolve in the receiving repository - which is the single
9
+ // failure this whole tool exists to prevent, and it would be introduced by the
10
+ // feature meant to remove duplication.
11
+ //
12
+ // `graph/skills.graph.json` is deliberately not part of a pack. ADR-061 ruled
13
+ // the registry to be workspace data; a second source for it would need its own
14
+ // resolution ruling, and inventing one here would be how that gets settled by
15
+ // accident.
16
+
17
+ import { readFile, readdir, stat } from 'node:fs/promises';
18
+ import { createHash } from 'node:crypto';
19
+ import { join } from 'node:path';
20
+
21
+ import { readManifest } from './projection/manifest.mjs';
22
+ import { CLI, MANIFEST_FILE } from './name.mjs';
23
+
24
+ /** Where a pack's files live, relative to a repository root. */
25
+ export const PACK_DIR = '.simt-dev/packs';
26
+
27
+ // A pack name is a directory name under `PACK_DIR`, and it ends up in a path the
28
+ // tool builds from manifest content. Restricted to the characters a directory
29
+ // name can hold without escaping, so a hand-edited manifest cannot point the
30
+ // reader's renderer at something outside the repository.
31
+ const NAME = /^[a-z0-9][a-z0-9._-]*$/;
32
+
33
+ /**
34
+ * @returns {string|null} the name if it is usable, else why it is not.
35
+ */
36
+ function packNameProblem(name) {
37
+ if (typeof name !== 'string' || name.length === 0) return 'a pack name must be a non-empty string';
38
+ if (!NAME.test(name)) return `\`${name}\` is not a usable pack name: lowercase letters, digits, dot, dash and underscore`;
39
+ if (name === '.' || name === '..') return `\`${name}\` is not a usable pack name`;
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * The digest of one pack directory: every file, relative path and bytes, in
45
+ * sorted path order. Same shape as the rule provenance digest on purpose - a
46
+ * reader who has seen one digest in a manifest has seen the other.
47
+ *
48
+ * The paths are pack-relative, so a pack moved to another repository hashes the
49
+ * same. That is the property that makes the digest a statement about CONTENT
50
+ * rather than about where the reader happens to keep it.
51
+ *
52
+ * @returns {Promise<string>} 40 hex chars, or `''` for a directory that is not there.
53
+ */
54
+ export async function packDigest(dir) {
55
+ const files = [];
56
+ const walk = async (d, prefix = '') => {
57
+ let entries;
58
+ try {
59
+ entries = await readdir(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
64
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
65
+ if (entry.isDirectory()) await walk(join(d, entry.name), rel);
66
+ else if (entry.isFile()) files.push(rel);
67
+ }
68
+ };
69
+ await walk(dir);
70
+ files.sort();
71
+
72
+ const hash = createHash('sha256');
73
+ for (const rel of files) {
74
+ const bytes = await readFile(join(dir, rel));
75
+ hash.update(`${rel}:${bytes.length}:`);
76
+ hash.update(bytes);
77
+ }
78
+ return hash.digest('hex').slice(0, 40);
79
+ }
80
+
81
+ /**
82
+ * Resolve the packs a repository declares, and report the ones it cannot use.
83
+ *
84
+ * A declared pack with no directory, or whose bytes no longer match the digest,
85
+ * is a finding rather than a silent omission: a repo whose region renders
86
+ * without half its rules is a repo whose reader was told something false, and the
87
+ * next `sync` would overwrite the truth on disk with the shorter region.
88
+ *
89
+ * `present` and `ok` are separate questions on purpose. A pack whose bytes do not
90
+ * match its pin is still PROJECTED - otherwise the remedy the finding names,
91
+ * `sync --yes`, would delete every rule the pack carries, which is the opposite of
92
+ * what a reader who ran the remedy meant. What is questioned is the pin, and the
93
+ * gate stays red until it is re-recorded; what is missing is the directory, and
94
+ * there is nothing to project.
95
+ *
96
+ * The remedy is `git checkout -- <dir>`, because the pack is vendored: git has
97
+ * the bytes this manifest's digest names. `sync` cannot help - it would render
98
+ * the region without the pack and call it current - so the remedy does not name
99
+ * it as the repair and does name it as the thing to run afterwards.
100
+ *
101
+ * @returns {Promise<{packs: {name: string, dir: string, source: string, version: string, digest: string, declared: string, ok: boolean}[], findings: {file: string, status: string, detail: string, remedy: string}[]}>}
102
+ */
103
+ export async function resolvePacks(root) {
104
+ const findings = [];
105
+ const packs = [];
106
+ const { manifest, exists, error } = await readManifest(root);
107
+ if (!exists || error !== null || manifest === null) return { packs, findings };
108
+
109
+ const declared = Array.isArray(manifest.packs) ? manifest.packs : [];
110
+ const seen = new Set();
111
+
112
+ for (const entry of declared) {
113
+ const name = entry?.name;
114
+ const problem = packNameProblem(name);
115
+ if (problem !== null) {
116
+ findings.push({
117
+ file: MANIFEST_FILE,
118
+ status: 'pack-name-invalid',
119
+ detail: problem,
120
+ remedy: `correct or remove the entry, then: ${CLI} sync --yes`,
121
+ });
122
+ continue;
123
+ }
124
+ if (seen.has(name)) {
125
+ findings.push({
126
+ file: MANIFEST_FILE,
127
+ status: 'pack-declared-twice',
128
+ detail: `pack \`${name}\` is declared twice; the second declaration is read as the first`,
129
+ remedy: `remove the duplicate entry, then: ${CLI} sync --yes`,
130
+ });
131
+ continue;
132
+ }
133
+ seen.add(name);
134
+
135
+ const dir = join(root, PACK_DIR, name);
136
+ const source = typeof entry.source === 'string' ? entry.source : null;
137
+ const version = typeof entry.version === 'string' ? entry.version : null;
138
+ let present = false;
139
+ try {
140
+ present = (await stat(dir)).isDirectory();
141
+ } catch {
142
+ present = false;
143
+ }
144
+ if (!present) {
145
+ findings.push({
146
+ file: `${PACK_DIR}/${name}`,
147
+ status: 'pack-missing',
148
+ detail: `the manifest declares pack \`${name}\`, and there is no directory for it; every rule it carries is missing from this repository's projection`,
149
+ remedy: `restore it with: git checkout -- ${PACK_DIR}/${name} (or remove the declaration, then: ${CLI} sync --yes)`,
150
+ });
151
+ packs.push({ name, dir, source, version, digest: '', declared: '', present: false, ok: false });
152
+ continue;
153
+ }
154
+
155
+ const digest = await packDigest(dir);
156
+ const declaredDigest = typeof entry.digest === 'string' ? entry.digest : null;
157
+ // An absent digest is not a mismatch. A pack someone vendored and has not
158
+ // pinned yet still carries its rules, and refusing to project them would make
159
+ // the tool refuse a repository for a field it has always treated as optional.
160
+ // It is reported, because "pinned to nothing" is not a fact about the content.
161
+ let ok = true;
162
+ if (declaredDigest === null) {
163
+ findings.push({
164
+ file: MANIFEST_FILE,
165
+ status: 'pack-unpinned',
166
+ detail: `pack \`${name}\` has no digest, so nothing records which bytes it was declared at`,
167
+ remedy: `record \`${digest}\` as its digest, then: ${CLI} sync --yes`,
168
+ });
169
+ } else if (declaredDigest !== digest) {
170
+ ok = false;
171
+ findings.push({
172
+ file: `${PACK_DIR}/${name}`,
173
+ status: 'pack-digest-mismatch',
174
+ detail: `pack \`${name}\` is pinned to ${declaredDigest} and its bytes digest to ${digest}`,
175
+ remedy: `restore the pinned bytes with: git checkout -- ${PACK_DIR}/${name} (or update the digest to ${digest}, then: ${CLI} sync --yes)`,
176
+ });
177
+ }
178
+ packs.push({ name, dir, source, version, digest, declared: declaredDigest ?? digest, present: true, ok });
179
+ }
180
+
181
+ return { packs, findings };
182
+ }