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.
Files changed (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,398 @@
1
+ // `simt shared` - carry a workspace's repeated rules once, in the reader's home directory.
2
+ //
3
+ // Plan 0003 R11. The estate measured 90 KB of always-on rules across seven repositories
4
+ // with 72% of it a paragraph an earlier root had already loaded (review 0002 S38), and the
5
+ // plan's answer was to install the repeated part once per harness and let each region carry
6
+ // a delta.
7
+ //
8
+ // Two subcommands and no more. `plan` answers "what would this do, and where", because a
9
+ // command that writes into `$HOME` without printing the plan first is the one command in
10
+ // this tool that can surprise somebody on their own machine. `install` does it. Staleness
11
+ // is `doctor`'s job rather than a third subcommand: a question asked about a repository is
12
+ // answered by the command that already reports on a repository (ADR-084).
13
+
14
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
15
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
16
+ import { targetRoot } from '../workspace.mjs';
17
+ import {
18
+ intersect,
19
+ sharedBody,
20
+ sharedCandidates,
21
+ sharedCoverage,
22
+ sharedMeta,
23
+ sharedRoots,
24
+ sharedState,
25
+ sharedTargets,
26
+ writeSharedFile,
27
+ } from '../projection/shared.mjs';
28
+ import { buildManifest, declaredShared, readManifest, writeManifest } from '../projection/manifest.mjs';
29
+ import { desiredRegion } from '../projection/rules.mjs';
30
+
31
+ /**
32
+ * Everything `plan` and `install` both need, computed once.
33
+ *
34
+ * One function because the two commands must not be able to disagree about what they are
35
+ * about: an `install` that computed a different shared set from the one it printed is a
36
+ * command that writes something the reader was not shown, into their home directory.
37
+ */
38
+ async function gather(flags) {
39
+ const target = await targetRoot(flags, []);
40
+ const { roots, error } = await sharedRoots(target.root);
41
+ if (error !== null) return { error: `${target.root}: ${error}` };
42
+ if (roots.length === 0) {
43
+ return {
44
+ error: `${target.root} contains no git repositories`,
45
+ remedy: `point --root at the directory holding the repositories, or run this in one repository with --root <path>`,
46
+ };
47
+ }
48
+ const rows = await sharedCandidates(roots, flags);
49
+ const { shared, divergent } = intersect(rows);
50
+ const meta = await sharedMeta(shared);
51
+
52
+ // The union of every root's surfaces: the reader's home carries one file per harness any
53
+ // repository in the estate uses, because a reader works in more than one of these roots
54
+ // and the file is per-machine rather than per-repository.
55
+ const surfaces = [...new Set(rows.flatMap((row) => row.desired.surfaces))];
56
+ const targets = await sharedTargets(surfaces);
57
+ // Deduped across roots, in the order the first root names them: two roots that both
58
+ // selected a rule and both refused to carry it are one line, not two identical ones.
59
+ const excluded = [];
60
+ for (const row of rows) {
61
+ for (const entry of row.desired.excluded) {
62
+ if (!excluded.some((e) => e.id === entry.id)) excluded.push(entry);
63
+ }
64
+ }
65
+ return { target, rows, shared, divergent, excluded, meta, targets, roots };
66
+ }
67
+
68
+ /**
69
+ * One sentence per rule the workspace cannot carry once, in the words its reason deserves.
70
+ *
71
+ * A rule every root carries but renders differently, a rule only some roots carry, and a
72
+ * rule this repository selected and cannot hand over at all are three different events, and
73
+ * a reader deciding whether to act on one needs to know which happened.
74
+ */
75
+ function divergentNote(d, total) {
76
+ if (d.reason === 'coverage') {
77
+ return `${d.id} carried by ${plain(d.roots.length)} of ${plain(total)} repositories, so each region keeps its own`;
78
+ }
79
+ return `${d.id} rendered differently in ${plain(d.roots.length)} repositories, so each region keeps its own`;
80
+ }
81
+
82
+ /** Why a selected rule cannot leave any region, in the words its reason deserves. */
83
+ function excludedNote({ id, why }) {
84
+ if (why === 'policy') return `${id} is a repository's own profile or pack policy, which stays where it was written`;
85
+ if (why === 'error') return `${id} could not be read cleanly, so it stays where it was written`;
86
+ return `${id} renders to nothing, so there is nothing to carry`;
87
+ }
88
+
89
+ /**
90
+ * What to say when the copy is larger than the harness's own limit on that file.
91
+ *
92
+ * Reported rather than prevented: the cap is a fact about the reader's harness and the
93
+ * estate's rules are the reader's to choose, but a copy that Codex stops reading halfway
94
+ * through is a failure the reader would otherwise discover as "the agent ignored my rules".
95
+ */
96
+ function capNote(target, bytes) {
97
+ if (target.path === null || target.cap === null || bytes <= target.cap.bytes) return null;
98
+ return [
99
+ `${target.surface} ${plain(bytes)} B exceeds the ${plain(target.cap.bytes)} B it allows for ${target.cap.appliesTo}`,
100
+ ` ${target.cap.why}`,
101
+ ` the rules above that limit are not read; move rules this workspace does not share back into each region, or drop the shared copy for this surface`,
102
+ ].join('\n');
103
+ }
104
+
105
+ /** The per-root line: what this repository carries now, and what it would stop carrying. */
106
+ function perRoot(rows, shared) {
107
+ const ids = new Set(shared.map((r) => r.id));
108
+ return rows.map((row) => {
109
+ const full = row.desired.shareable.filter((r) => ids.has(r.id));
110
+ return {
111
+ root: row.path,
112
+ name: basename(row.path),
113
+ surfaces: row.desired.surfaces,
114
+ stack: row.desired.stack,
115
+ mode: row.desired.mode,
116
+ // The bytes the region gives up, measured as the frames themselves rather than as a
117
+ // difference between two whole projections: the region's own header, card and index
118
+ // stay, and a number that hid them would be a saving no reader would ever see.
119
+ bytes: full.reduce((n, r) => n + Buffer.byteLength(`${r.rendered}\n\n`), 0),
120
+ rules: full.map((r) => r.id),
121
+ };
122
+ });
123
+ }
124
+
125
+ export async function sharedPlanCommand(_args, flags) {
126
+ const gathered = await gather(flags);
127
+ if (gathered.error !== undefined) return fail(gathered.error, EXIT.CONFIG, gathered.remedy);
128
+ const { target, roots, shared, divergent, excluded, meta, targets } = gathered;
129
+ const rows = perRoot(gathered.rows, shared);
130
+ const body = sharedBody(shared);
131
+ const bytes = Buffer.byteLength(body);
132
+ const save = rows.reduce((n, r) => n + r.bytes, 0);
133
+ // Every id any root put forward, so the count cannot read as "and the rest were carried".
134
+ const candidates = new Set([...shared, ...divergent, ...excluded].map((r) => r.id)).size;
135
+
136
+ if (isJson(flags)) {
137
+ emitJson({
138
+ ok: true,
139
+ command: 'shared plan',
140
+ root: target.root,
141
+ mode: 'plan',
142
+ source: target.source,
143
+ roots,
144
+ shared,
145
+ divergent,
146
+ excluded,
147
+ candidates,
148
+ bytes,
149
+ saving: save,
150
+ targets,
151
+ perRoot: rows,
152
+ provenance: meta,
153
+ });
154
+ return EXIT.OK;
155
+ }
156
+
157
+ const lines = [
158
+ `shared ${target.root} ${plain(roots.length)} repositories (${target.source})`,
159
+ `rules ${plain(shared.length)} of ${plain(candidates)} ids carried once ${plain(bytes)} B`,
160
+ ];
161
+ for (const r of shared) lines.push(` ${r.id} ${r.priority ?? 'unclassified'} ${plain(Buffer.byteLength(r.rendered))} B ${r.source}`);
162
+ if (divergent.length > 0) {
163
+ // Named, not summarised: a reader who expected a python rule here and did not get it
164
+ // is owed the reason, and "divergent: 2" is not a reason.
165
+ lines.push(`left in every region ${divergent.length} rule(s) the roots do not carry identically:`);
166
+ for (const d of divergent) lines.push(` ${divergentNote(d, roots.length)}`);
167
+ }
168
+ if (excluded.length > 0) {
169
+ lines.push(`left in every region ${excluded.length} rule(s) no root can hand over:`);
170
+ for (const e of excluded) lines.push(` ${excludedNote(e)}`);
171
+ }
172
+ lines.push(`saves ${plain(save)} B across ${plain(roots.length)} regions`);
173
+ for (const r of rows) {
174
+ lines.push(` ${r.name} ${plain(r.bytes)} B ${r.mode} ${r.rules.length > 0 ? r.rules.join(', ') : '(carries nothing extra)'}`);
175
+ }
176
+ lines.push('writes');
177
+ for (const t of targets) {
178
+ lines.push(
179
+ t.path === null
180
+ ? ` ${t.surface} printed for you to paste - ${t.why}`
181
+ : ` ${t.path} ${t.why}`,
182
+ );
183
+ }
184
+ lines.push(`manifest ${MANIFEST_FILE} in each repository shared.level = user, shared.rules = the ids above`);
185
+ for (const t of targets) {
186
+ const over = capNote(t, bytes);
187
+ if (over !== null) lines.push(`warn ${over}`);
188
+ }
189
+ lines.push(`nothing written; re-run with: ${CLI} shared install --yes`);
190
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
191
+ return EXIT.OK;
192
+ }
193
+
194
+ export async function sharedInstallCommand(_args, flags) {
195
+ const gathered = await gather(flags);
196
+ if (gathered.error !== undefined) return fail(gathered.error, EXIT.CONFIG, gathered.remedy);
197
+ const { roots, shared, divergent, meta, targets } = gathered;
198
+
199
+ if (shared.length === 0) {
200
+ // A workspace where every root carries different rules has nothing to share, and
201
+ // writing an empty file into a reader's home directory is a thing that looks like an
202
+ // install and is not one.
203
+ return fail(
204
+ `no rule is carried with identical bytes by all ${roots.length} repositories`,
205
+ EXIT.UNSUPPORTED,
206
+ divergent.length > 0
207
+ ? `${divergent.map((d) => d.id).join(', ')} differ between repositories; each region keeps its own copy`
208
+ : `every repository in ${roots.length === 1 ? 'this repository' : 'this workspace'} carries a different rule set`,
209
+ );
210
+ }
211
+
212
+ // The declaration goes into every root that already has a manifest. A root without
213
+ // one is skipped: adopting it is `init`'s job, and this command does not adopt a
214
+ // repository in order to share its rules. The list is never empty — a workspace with
215
+ // nothing to share was refused above — so the reader of the manifest sees a level and
216
+ // a set of ids, which is what `doctor` checks the reader's copy against.
217
+ const declaration = { level: 'user', rules: shared.map((r) => r.id) };
218
+ const manifests = [];
219
+ for (const root of roots) {
220
+ const { manifest, exists } = await readManifest(root);
221
+ if (!exists || manifest === null) {
222
+ manifests.push({ root, action: 'skipped', detail: `no ${MANIFEST_FILE}; run ${CLI} init in it first` });
223
+ continue;
224
+ }
225
+ if (JSON.stringify(manifest.shared ?? null) === JSON.stringify(declaration)) {
226
+ manifests.push({ root, action: 'unchanged' });
227
+ continue;
228
+ }
229
+ // `buildManifest` rather than a spread, so `shared` lands where `budget` and `mode`
230
+ // land and a reviewer sees it in the declared block. Its own generated fields are
231
+ // handed back the repository's existing pin rather than this build's: `shared install`
232
+ // does not re-project anything, and a manifest whose pin silently became `null` would
233
+ // be a repository that now claims no release produced its rules.
234
+ const built = buildManifest({
235
+ root,
236
+ stack: manifest.stack ?? [],
237
+ sha: manifest.projection?.simtDevSha ?? null,
238
+ version: manifest.projection?.version ?? null,
239
+ existing: { ...manifest, shared: declaration },
240
+ });
241
+ manifests.push({ root, action: 'declared', manifest: built });
242
+ }
243
+
244
+ if (flags.yes !== true) {
245
+ // Nothing above this line writes, and nothing below it runs without the reader's
246
+ // consent — because one of the two writes lands in their home directory and the other
247
+ // edits repositories other people may be reviewing. The states printed here come from
248
+ // the same `sharedState` `doctor` reads, so a preview cannot say "current" and then
249
+ // have `install` write anyway.
250
+ const states = [];
251
+ for (const t of targets) {
252
+ if (t.path === null) {
253
+ states.push({ surface: t.surface, path: null, state: 'paste' });
254
+ continue;
255
+ }
256
+ states.push({ ...t, ...(await sharedState(t.path, shared, meta)) });
257
+ }
258
+ if (isJson(flags)) {
259
+ emitJson({
260
+ ok: false,
261
+ command: 'shared install',
262
+ mode: 'preview',
263
+ roots,
264
+ shared,
265
+ divergent,
266
+ targets: states,
267
+ manifests: manifests.map(({ root: r, action, detail }) => ({ root: r, action, detail: detail ?? null })),
268
+ });
269
+ return EXIT.FAIL;
270
+ }
271
+ emit(
272
+ [
273
+ `shared ${plain(shared.length)} rule(s), ${plain(Buffer.byteLength(sharedBody(shared)))} B`,
274
+ ...states.map((s) =>
275
+ s.path === null
276
+ ? ` paste ${s.surface} no user-level file is recorded for it`
277
+ : ` ${s.state.padEnd(9)}${s.path}`,
278
+ ),
279
+ `manifest ${manifests.map((m) => `${m.action}:${basename(m.root)}`).join(' ')}`,
280
+ `nothing written; re-run with: ${CLI} shared install --yes`,
281
+ ].join('\n'),
282
+ { maxBytes: flags.maxBytes ?? 0 },
283
+ );
284
+ return EXIT.FAIL;
285
+ }
286
+
287
+ const written = [];
288
+ for (const t of targets) {
289
+ if (t.path === null) continue;
290
+ written.push(await writeSharedFile(t.path, shared, meta));
291
+ }
292
+
293
+ const declared = [];
294
+ for (const row of manifests) {
295
+ if (row.action !== 'declared') continue;
296
+ await writeManifest(row.root, row.manifest);
297
+ declared.push(row.root);
298
+ }
299
+
300
+ const lines = [
301
+ `shared installed for ${plain(roots.length)} repositories`,
302
+ `rules ${plain(shared.length)} carried once ${shared.map((r) => r.id).join(', ')}`,
303
+ ];
304
+ for (const w of written) {
305
+ // `inject`'s word for a file with no region in it is "added"; the word for a file
306
+ // that did not exist is "created", and printing "added" for a file this command
307
+ // brought into existence would be true and unreadable.
308
+ lines.push(` ${w.created ? 'created' : w.action} ${w.path}`);
309
+ }
310
+ for (const d of declared) lines.push(` declared ${d}/${MANIFEST_FILE}`);
311
+ // Now the delta, so the reader knows what each region stopped carrying — and that the
312
+ // next `simt sync` in each repository is what actually applies it.
313
+ if (declared.length > 0) {
314
+ lines.push(`regions ${declared.length} repository region(s) change on their next ${CLI} sync`);
315
+ }
316
+ for (const t of targets) {
317
+ if (t.path !== null) continue;
318
+ lines.push(`paste into ${t.surface} (${t.why}) - the block below, markers included:`);
319
+ lines.push('');
320
+ lines.push(sharedBody(shared));
321
+ lines.push('');
322
+ }
323
+ for (const t of targets) {
324
+ const over = capNote(t, Buffer.byteLength(sharedBody(shared)));
325
+ if (over !== null) lines.push(`warn ${over}`);
326
+ }
327
+ lines.push(`verify ${CLI} doctor reports the copy as current, and ${CLI} tokens --repos --root . reports what is left`);
328
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
329
+ return EXIT.OK;
330
+ }
331
+
332
+ /**
333
+ * What `doctor` says about the reader's copy of this repository's shared rules (ADR-084).
334
+ *
335
+ * Silent for a repository that declared nothing — the overwhelmingly common case — because
336
+ * a `doctor` line every repository carries is a line every reader learns to skip. Reported
337
+ * when there is a declaration, and never blocking: the region is correct either way, and
338
+ * what is wrong is the reader's machine.
339
+ *
340
+ * @returns {Promise<{declared: boolean, state: string, rules: string[], targets: object[],
341
+ * detail: string|null}>}
342
+ */
343
+ export async function sharedWarning(root, manifest, flags) {
344
+ const declared = declaredShared(manifest);
345
+ if (declared.level === null) {
346
+ return { declared: false, state: 'none', rules: [], targets: [], detail: null };
347
+ }
348
+ const desired = await desiredRegion(root, flags ?? {});
349
+ // What this repository expects to find in the copy: the declared ids it still carries.
350
+ // An id the repository no longer selects is simply not checked, because there is nothing
351
+ // this repository could do about it — a sibling declares it and `shared plan` reports it.
352
+ const rules = desired.shareable.filter((r) => declared.rules.has(r.id));
353
+ const targets = [];
354
+ for (const t of await sharedTargets(desired.surfaces)) {
355
+ if (t.path === null) continue;
356
+ targets.push({ surface: t.surface, path: t.path, ...(await sharedCoverage(t.path, rules)) });
357
+ }
358
+ const worst =
359
+ targets.find((t) => t.state === 'absent') ??
360
+ targets.find((t) => t.state === 'stale') ??
361
+ targets.find((t) => t.state === 'unreadable') ??
362
+ targets[0];
363
+ const state = targets.length === 0 ? 'no-target' : (worst?.state ?? 'none');
364
+ return {
365
+ declared: true,
366
+ state,
367
+ rules: [...declared.rules],
368
+ targets,
369
+ detail: describeShared(state, targets),
370
+ };
371
+ }
372
+
373
+ function describeShared(state, targets) {
374
+ if (state === 'no-target') {
375
+ return 'this repository declares shared rules, but no surface it selects has a user-level file this tool can write; paste the block from `simt shared plan` into each reader';
376
+ }
377
+ if (state === 'absent') {
378
+ return `no copy of the workspace rules at ${targets.find((t) => t.state === 'absent').path}; run: ${CLI} shared install --yes`;
379
+ }
380
+ if (state === 'stale') {
381
+ const stale = targets.find((t) => t.state === 'stale');
382
+ return `${stale.path} is missing ${stale.missing.join(', ')}; run: ${CLI} shared install --yes`;
383
+ }
384
+ if (state === 'unreadable') {
385
+ return `${targets.find((t) => t.state === 'unreadable').path} could not be read: ${targets.find((t) => t.state === 'unreadable').reason}`;
386
+ }
387
+ return `${targets.length} user-level file(s) carry every rule this repository handed over`;
388
+ }
389
+
390
+ /** `1234567`, never `1,234,567`: two machines must print the same bytes (ADR-013). */
391
+ function plain(n) {
392
+ return String(n);
393
+ }
394
+
395
+ /** The directory name, for a line that has to name several repositories at once. */
396
+ function basename(path) {
397
+ return path.split('/').filter(Boolean).pop() ?? path;
398
+ }
@@ -28,6 +28,8 @@ import { CLI } from '../name.mjs';
28
28
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
29
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
30
  import { resolvePacks } from '../packs.mjs';
31
+ import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
32
+ import { runDeclaredChecks } from '../kernel/checks.mjs';
31
33
 
32
34
  /** Findings `sync` reports and does not fail on.
33
35
  *
@@ -49,7 +51,15 @@ import { resolvePacks } from '../packs.mjs';
49
51
  * people learn to ignore. A *foreign* one — a real file or a real directory where the
50
52
  * pointer belongs — is reported loudly with what is in the way, because that one is a
51
53
  * decision somebody has to make. */
52
- export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
54
+ export const SYNC_NON_BLOCKING = new Set([
55
+ 'pointer-unread',
56
+ 'orphaned',
57
+ 'pack-unpinned',
58
+ 'budget',
59
+ 'budget-cut',
60
+ 'file-lines-warn',
61
+ 'check-unbuilt',
62
+ ]);
53
63
 
54
64
  /**
55
65
  * Findings `check` reports and does not fail on.
@@ -82,7 +92,11 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-un
82
92
  // missing file. Nothing is cut: the projection is still in full mode, so a gate that
83
93
  // failed here would be failing a repository for exceeding a ceiling it set itself,
84
94
  // with no way to fix it except lowering the number it wrote down.
85
- export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget']);
95
+ // `budget-cut` is here for the same reason and is the second half of the same ruling
96
+ // (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
97
+ // remedy is a decision about the number — and a cut the repository asked for by declaring
98
+ // a budget is the tool working, not a repository in need of a file.
99
+ export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
86
100
 
87
101
  export async function syncCommand(positional, flags) {
88
102
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -267,6 +281,18 @@ export async function syncCommand(positional, flags) {
267
281
  await writeFile(t.path, t.text);
268
282
  note(`wrote ${t.file}`);
269
283
  }
284
+ const lock = await computeLock(root, flags);
285
+ const lockText = `${JSON.stringify(lock, null, 2)}\n`;
286
+ let previousLock = null;
287
+ try {
288
+ previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
289
+ } catch (error) {
290
+ if (error.code !== 'ENOENT') throw error;
291
+ }
292
+ if (previousLock !== lockText) {
293
+ await writeFile(join(root, LOCK_FILE), lockText);
294
+ note(`wrote ${LOCK_FILE}`);
295
+ }
270
296
  for (const p of pointerPlan) {
271
297
  await mkdir(dirname(p.path), { recursive: true });
272
298
  // Only a symlink this tool wrote is replaced, and only because it has no content:
@@ -495,6 +521,12 @@ export async function checkCommand(positional, flags) { const root = flags.root
495
521
  const drift = await manifestDrift(root, stack, await provenanceDigest());
496
522
  if (drift) findings.push(drift);
497
523
  }
524
+ if (flags.frozen) findings.push(...(await frozenFindings(root, flags)));
525
+ // The checkers a selected rule declared (ADR-088). They read the rule source,
526
+ // not the folded region, so a line that left the prompt is still the line that runs.
527
+ const fp = await fingerprint(root);
528
+ const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
529
+ findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
498
530
 
499
531
  // `CHECK_NON_BLOCKING`, not the union. This is the gate whose exit code a repository
500
532
  // acts on, so a finding it reports and does not fail on is a decision recorded in a
@@ -526,4 +558,41 @@ export async function checkCommand(positional, flags) { const root = flags.root
526
558
  return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
527
559
  }
528
560
 
529
- export { plan, checkTargets, checkTarget, select };
561
+ /**
562
+ * What `--frozen` adds to the gate. Nothing, when the lock matches. One finding
563
+ * otherwise, and the wording says which of the two failures it was (ADR-086).
564
+ */
565
+ async function frozenFindings(root, flags) {
566
+ const { lock, error } = await readLock(root);
567
+ if (error) {
568
+ return [{ file: LOCK_FILE, status: 'lock-unreadable', detail: error, remedy: `run: ${CLI} sync --yes` }];
569
+ }
570
+ if (lock === null) {
571
+ return [{
572
+ file: LOCK_FILE,
573
+ status: 'lock-absent',
574
+ detail: 'no lock is recorded',
575
+ remedy: `run: ${CLI} sync --yes`,
576
+ }];
577
+ }
578
+ const next = await computeLock(root, flags);
579
+ if (next.inputs !== lock.inputs) {
580
+ return [{
581
+ file: LOCK_FILE,
582
+ status: 'lock-stale',
583
+ detail: 'the inputs changed and the lock was not rewritten',
584
+ remedy: `run: ${CLI} sync --yes`,
585
+ }];
586
+ }
587
+ if (next.outputs !== lock.outputs) {
588
+ return [{
589
+ file: LOCK_FILE,
590
+ status: 'lock-unexplained',
591
+ detail: 'the projection changed and the inputs did not',
592
+ remedy: `run: ${CLI} sync --yes`,
593
+ }];
594
+ }
595
+ return [];
596
+ }
597
+
598
+ export { plan, checkTargets, checkTarget, select, frozenFindings };