simt-dev 0.2.0 → 0.2.2

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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -16,16 +16,18 @@
16
16
  // three `.mdc` rules would let a reader believe a repo is fully current when
17
17
  // only the file they happened to open is.
18
18
 
19
- import { mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
20
- import { dirname } from 'node:path';
19
+ import { mkdir, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises';
20
+ import { dirname, join, resolve, sep } from 'node:path';
21
21
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
22
22
  import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
23
23
  import { fingerprint } from '../resolve/fingerprint.mjs';
24
- import { manifestDrift, readManifest, unrunnableGates } from '../projection/manifest.mjs';
24
+ import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText } from '../projection/manifest.mjs';
25
25
  import { readCapability } from '../hooks/capability.mjs';
26
26
  import { surfacesLine } from '../projection/surfaces.mjs';
27
27
  import { CLI } from '../name.mjs';
28
- import { MANIFEST_FILE } from '../name.mjs';
28
+ import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
+ import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
+ import { resolvePacks } from '../packs.mjs';
29
31
 
30
32
  /** Findings `sync` reports and does not fail on.
31
33
  *
@@ -47,7 +49,7 @@ import { MANIFEST_FILE } from '../name.mjs';
47
49
  * people learn to ignore. A *foreign* one — a real file or a real directory where the
48
50
  * pointer belongs — is reported loudly with what is in the way, because that one is a
49
51
  * decision somebody has to make. */
50
- export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
52
+ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
51
53
 
52
54
  /**
53
55
  * Findings `check` reports and does not fail on.
@@ -67,7 +69,20 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
67
69
  *
68
70
  * `orphaned` stays non-blocking in both. Nothing can satisfy it, in either command.
69
71
  */
70
- export const CHECK_NON_BLOCKING = new Set(['orphaned']);
72
+ // `pack-unpinned` is here and `pack-digest-mismatch` is not, which is the whole
73
+ // judgement. An unpinned pack still renders its rules correctly; what is missing
74
+ // is the record of which bytes were declared, and failing a gate over a field the
75
+ // tool does not require would fail every repository that vendored a pack and
76
+ // pinned it later. A pack whose bytes do not match its pin renders a region
77
+ // nobody can account for, so that one blocks.
78
+ //
79
+ // `budget` is here for the opposite reason, and it is a ruling rather than a patch
80
+ // (ADR-072). An over-budget finding says the projection is bigger than this repository
81
+ // said it would be, and the remedy is a decision about which rules to narrow — not a
82
+ // missing file. Nothing is cut: the projection is still in full mode, so a gate that
83
+ // failed here would be failing a repository for exceeding a ceiling it set itself,
84
+ // 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']);
71
86
 
72
87
  export async function syncCommand(positional, flags) {
73
88
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -76,6 +91,28 @@ export async function syncCommand(positional, flags) {
76
91
 
77
92
  const result = await plan(root, flags);
78
93
 
94
+ // Before the rule branch, and named for what it is. An unknown surface id is a
95
+ // misspelling in the manifest's `surfaces` list; reporting it as an unprojectable
96
+ // `.mdc` rule sent the reader to a `simt-substitutions` block that has nothing to do
97
+ // with the thing they typed. `check` reported the same problem correctly the whole
98
+ // time, so this was a writer disagreeing with the gate about the same manifest.
99
+ if (result.surfaceProblems.length > 0) {
100
+ if (isJson(flags)) {
101
+ emitJson({
102
+ ok: false,
103
+ command: 'sync',
104
+ root,
105
+ error: result.surfaceProblems[0],
106
+ problems: result.surfaceProblems,
107
+ });
108
+ }
109
+ for (const p of result.surfaceProblems) note(`PROBLEM ${p}`);
110
+ return fail(
111
+ `${result.surfaceProblems.length} surface(s) cannot be resolved (${result.surfaceProblems[0]})`,
112
+ EXIT.FAIL,
113
+ `fix the surface ids in the "surfaces" list of ${MANIFEST_FILE}, or remove the unknown one`,
114
+ );
115
+ }
79
116
 
80
117
  // A `.mdc` rule that cannot render is a defect in this build, not in the repo,
81
118
  // and projecting the rest would leave a family half-retired.
@@ -162,6 +199,40 @@ export async function syncCommand(positional, flags) {
162
199
  const changed = result.targets.filter((t) => t.action !== 'unchanged');
163
200
  const pointersAdded = pointerPlan.map((p) => p.path);
164
201
 
202
+ // The manifest is the second file this tool writes into a repository it does not own,
203
+ // and for two releases it was the only generated file with no path back to correct:
204
+ // `check` refused on a stale generated field and named `init --yes`, while `sync` -
205
+ // the command whose whole job is regenerating what this tool owns - reported
206
+ // "already current; nothing written". Both statements were true of the same
207
+ // repository, and all fourteen adopted repositories were in that state at once.
208
+ //
209
+ // One reason and one place, so `check`'s remedy and this plan cannot drift: the
210
+ // field set is `GENERATED_KEYS`, and the values come from the same `buildManifest`
211
+ // call `manifestDrift` compares against. A repair built from a second copy of
212
+ // either would pass its own test and leave the gate still red.
213
+ //
214
+ // `reformatted` is repaired too, and only when the repository has said the file is
215
+ // not prose: `simt.json` listed in `.prettierignore` is the repo stating that no
216
+ // formatter will take these bytes back.
217
+ //
218
+ // Without that condition there are two wrong answers. Repairing unconditionally
219
+ // means every `format` run rewrites a file the commit just wrote - a permanently
220
+ // dirty working tree. Never repairing means `init --yes` is the only way to put the
221
+ // bytes back, and `init` re-runs adoption: it tags, it switches branch, it asks every
222
+ // question again, to fix one file's formatting (ADR-050). The first repo routed in
223
+ // the estate roll-out hit this on its first commit, so both answers were live.
224
+ const manifestPlan = await planManifestRepair(root, result.stack, result.targets[0].sha);
225
+ if (manifestPlan) {
226
+ targets.push({
227
+ file: manifestPlan.file,
228
+ surface: 'manifest',
229
+ action: 'updated',
230
+ bytes: Buffer.byteLength(manifestPlan.text),
231
+ previousBytes: manifestPlan.previousBytes,
232
+ });
233
+ }
234
+ const manifestChanged = manifestPlan !== null;
235
+
165
236
  // Writes happen BEFORE output in every mode. An earlier build emitted `--json`
166
237
  // first and returned, so `upgrade --yes --json` reported a plan and changed
167
238
  // nothing — the exact combination automation reaches for.
@@ -178,6 +249,13 @@ export async function syncCommand(positional, flags) {
178
249
  // A comment that enumerates which commands follow a rule is a claim about code
179
250
  // this file does not own, and it was one commit out of date before anyone ran it.
180
251
  if (mode.apply) {
252
+ // The manifest first, with the rest: a gate that refuses on a stale stamp should
253
+ // not be able to read a half-finished write, and the manifest is the file every
254
+ // other write in this run is described by.
255
+ if (manifestPlan) {
256
+ await writeManifest(root, manifestPlan.manifest);
257
+ note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
258
+ }
181
259
  for (const t of changed) {
182
260
  // Three of the four rule surfaces live in directories that do not exist yet in a
183
261
  // repository that has only ever been written to by hand. `init` grew its own
@@ -210,7 +288,7 @@ export async function syncCommand(positional, flags) {
210
288
  // `ok` follows the exit code, which now reports drift for a previewing run
211
289
  // too. Leaving it `true` here would break the invariant ADR-035 sets for
212
290
  // every command, and the contract table asserts it for this one.
213
- ok: changed.length === 0 && pointersAdded.length === 0 ? true : mode.apply,
291
+ ok: changed.length === 0 && pointersAdded.length === 0 && !manifestChanged ? true : mode.apply,
214
292
  command: checkOnly ? 'sync --check' : 'sync',
215
293
  root,
216
294
  stack: result.stack,
@@ -220,7 +298,7 @@ export async function syncCommand(positional, flags) {
220
298
  sha: result.targets[0].sha,
221
299
  targets,
222
300
  pointers: pointersAdded,
223
- written: mode.apply ? changed.length + pointersAdded.length : 0,
301
+ written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
224
302
  dryRun: !mode.apply,
225
303
  },
226
304
  {},
@@ -233,12 +311,15 @@ export async function syncCommand(positional, flags) {
233
311
  surfacesLine({ ids: result.surfaces, source: result.surfaceSource }, 10),
234
312
  `sha ${result.targets[0].sha}`,
235
313
  '',
236
- ...result.targets.map(
314
+ // `targets`, not `result.targets`: the plan is the thing being reported, and a
315
+ // line the JSON payload carries but the text one drops is the reader of the
316
+ // terminal output finding out from the exit code what they were already told.
317
+ ...targets.map(
237
318
  (t) => `${t.file} ${t.action} ${t.previousBytes} -> ${t.bytes} bytes`,
238
319
  ),
239
320
  ...pointerPlan.map((p) => `${p.file} added pointer -> ${p.to}`),
240
321
  ];
241
- if (changed.length === 0 && pointerPlan.length === 0) lines.push('already current; nothing written');
322
+ if (changed.length === 0 && pointerPlan.length === 0 && !manifestChanged) lines.push('already current; nothing written');
242
323
  else if (checkOnly) lines.push('would write; rerun without --check');
243
324
  else if (!mode.apply) {
244
325
  // The preview names the command that writes it. A preview that reports what
@@ -247,26 +328,128 @@ export async function syncCommand(positional, flags) {
247
328
  lines.push(
248
329
  flags.dryRun === true
249
330
  ? 'dry-run; nothing written'
250
- : `would write ${changed.length + pointersAdded.length} file(s); apply with: ${CLI} sync --yes`,
331
+ : `would write ${changed.length + pointersAdded.length + (manifestChanged ? 1 : 0)} file(s); apply with: ${CLI} sync --yes`,
251
332
  );
252
333
  }
253
334
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
254
335
  }
255
336
 
256
- if (checkOnly) return changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
337
+ const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
338
+ if (checkOnly) return idle ? EXIT.OK : EXIT.FAIL;
257
339
  // A preview that found work is not a success: `simt-dev sync --yes` with no flags is a
258
340
  // check that also prints the plan, and a check that exits 0 while reporting drift
259
341
  // is the one-question-two-answers shape ADR-035 rules out. This is what lets a
260
342
  // caller run `sync` in place of `sync --check` and get the same verdict.
261
343
  if (!mode.apply) {
262
344
  if (flags.dryRun === true) note('dry-run no files written');
263
- return changed.length === 0 && pointerPlan.length === 0 ? EXIT.OK : EXIT.FAIL;
345
+ return idle ? EXIT.OK : EXIT.FAIL;
264
346
  }
265
347
  return EXIT.OK;
266
348
  }
267
349
 
268
- export async function checkCommand(positional, flags) {
269
- const root = flags.root ?? positional[0] ?? process.cwd();
350
+ /**
351
+ * The manifest repair `sync` would make, or null when there is nothing to repair.
352
+ *
353
+ * Returns the parsed manifest to hand to `writeManifest` as well as the bytes, so the
354
+ * writer keeps owning the legacy-name move and the preview keeps showing the real
355
+ * count. `sha` is the same digest the regions were rendered from: the stamp and the
356
+ * text it describes have to agree, or the next `check` finds a region carrying a pin
357
+ * the manifest does not claim.
358
+ *
359
+ * @returns {Promise<{file: string, manifest: object, text: string,
360
+ * stale: string[], previousBytes: number}|null>}
361
+ */
362
+ async function planManifestRepair(root, stack, sha) {
363
+ const drift = await manifestDrift(root, stack, sha);
364
+ if (drift === null) return null;
365
+ // A `reformatted` manifest is repaired only once the repository has declared that no
366
+ // formatter will take these bytes back. The declaration is read rather than assumed
367
+ // because a repo that runs no formatter should not need a `.prettierignore` to repair
368
+ // a manifest some other tool rewrote, and one that does must be believed only when it
369
+ // has said so.
370
+ if (drift.status === 'reformatted' && !(await formatterIgnoresManifest(root))) return null;
371
+
372
+ const { manifest } = await readManifest(root);
373
+ if (manifest === null) return null;
374
+ const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
375
+ const patched = withGenerated(manifest, expected);
376
+ const stale = Object.keys(expected).filter(
377
+ (key) => JSON.stringify(manifest[key]) !== JSON.stringify(patched[key]),
378
+ );
379
+ return {
380
+ file: MANIFEST_FILE,
381
+ manifest: patched,
382
+ text: manifestText(patched),
383
+ stale,
384
+ previousBytes: Buffer.byteLength(manifestText(manifest)),
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Whether this repository's `.prettierignore` excludes the manifest.
390
+ *
391
+ * Lines, `#` comments and blanks only. A glob is not evaluated: the question this
392
+ * answers is "did somebody deliberately write this file's name down", and an exact
393
+ * match is the only reading of that which cannot be wrong. A repository that ignores
394
+ * `*.json` has said something broader, and the honest answer there is that this tool
395
+ * cannot tell - so it declines to repair, which is the state that was already correct.
396
+ */
397
+ async function formatterIgnoresManifest(root) {
398
+ // ENOENT only. A catch that answers "no" to every error is a catch that turns a
399
+ // typo in this function into a repository silently keeping its pre-commit gate's
400
+ // formatting — which is how the first version of it failed: `join` was not imported,
401
+ // the ReferenceError was swallowed, and every repository read as having declared
402
+ // nothing. Absence is absence; anything else is a fault worth seeing.
403
+ let text;
404
+ try {
405
+ text = await readFile(join(root, '.prettierignore'), 'utf8');
406
+ } catch (error) {
407
+ if (error.code !== 'ENOENT') throw error;
408
+ return false;
409
+ }
410
+ return text
411
+ .split('\n')
412
+ .map((line) => line.trim())
413
+ .some((line) => line !== '' && !line.startsWith('#') && (line === MANIFEST_FILE || line === MANIFEST_LEGACY));
414
+ }
415
+
416
+ /**
417
+ * `gates.cwd` entries that do not resolve to a directory inside the repository.
418
+ *
419
+ * Reported here, not only where the gate runs: the dispatcher fails closed on the same
420
+ * condition, so a reader finds out on their next commit — after writing a commit, which
421
+ * is the wrong order. The finding names the gate and the path, and says which of the two
422
+ * things is wrong, because "no such directory" and "outside the repository" are repaired
423
+ * by different edits.
424
+ *
425
+ * @returns {Promise<{file: string, status: string, gate: string, detail: string, remedy: string}[]>}
426
+ */
427
+ async function gateCwdFindings(root, manifest) {
428
+ const cwds = manifest?.gates?.cwd;
429
+ if (!cwds || typeof cwds !== 'object' || Array.isArray(cwds)) return [];
430
+ const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
431
+ const findings = [];
432
+ for (const name of Object.keys(cwds).sort()) {
433
+ if (!declared.includes(name)) continue;
434
+ const dir = String(cwds[name]);
435
+ const resolved = resolve(root, dir);
436
+ const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
437
+ const isDir = inside && ((await stat(resolved).catch(() => null))?.isDirectory() ?? false);
438
+ if (isDir) continue;
439
+ findings.push({
440
+ file: MANIFEST_FILE,
441
+ status: inside ? 'gate-cwd-missing' : 'gate-cwd-outside',
442
+ gate: name,
443
+ detail: inside
444
+ ? `gates.cwd["${name}"] is ${dir}, which is not a directory in this repository`
445
+ : `gates.cwd["${name}"] is ${dir}, which resolves outside the repository`,
446
+ remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
447
+ });
448
+ }
449
+ return findings;
450
+ }
451
+
452
+ export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
270
453
  const findings = await checkTargets(root, flags);
271
454
  const rules = await loadRules(root);
272
455
 
@@ -288,7 +471,22 @@ export async function checkCommand(positional, flags) {
288
471
  // before anyone installs anything rather than after.
289
472
  const { manifest } = await readManifest(root);
290
473
  if (manifest) {
474
+ // The gate runs from whatever binary resolved it. For a repository without npm that
475
+ // is a global install, and the only thing that knows which version it should be is
476
+ // the pin the repo recorded at adoption. A stale binary would run a different gate
477
+ // than CI does, so the mismatch fails the gate rather than warning.
478
+ const mismatch = assertVersionPin(manifest);
479
+ if (mismatch) {
480
+ findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
481
+ }
291
482
  findings.push(...unrunnableGates(manifest, await readCapability(root)));
483
+ findings.push(...(await gateCwdFindings(root, manifest)));
484
+ // A pack the repository declares but cannot produce is reported here rather
485
+ // than when a rule happens to be missing, because that is the failure mode
486
+ // this guards: the region still renders, it renders SHORT, and `check` would
487
+ // pass. A reader whose rules were quietly halved is worse off than one whose
488
+ // gate is red, so this is a blocking finding, not a note.
489
+ findings.push(...(await resolvePacks(root)).findings);
292
490
  // A manifest a formatter has rewritten is not wrong, and not editable-away by
293
491
  // hand either: the next `init` puts simt-dev's own bytes back and the formatter
294
492
  // takes them away again. Reporting it here is what stops that being a surprise
@@ -0,0 +1,138 @@
1
+ // simt-dev tokens - what a reader loads, counted.
2
+ //
3
+ // Review 0002 S38 put the estate's always-on context at 90 KB per turn, 72% of it a
4
+ // paragraph an earlier root had already loaded, and could only say so by reading seven
5
+ // repositories by hand. This command is that reading, and it is the figure plan 0003's
6
+ // later slices are measured against: the context budget (S38 P7), the kernel split
7
+ // (R07) and the workspace dedupe (R11) all need a number that moves when the
8
+ // projection moves, or they are opinions.
9
+ //
10
+ // Three decisions keep the figure honest:
11
+ //
12
+ // 1. It is a READ. No write, no git object, no lock - the same ruling `compare`
13
+ // follows. An agent running this to orient itself must not be able to change what
14
+ // the next run will measure.
15
+ // 2. It exits 0. A measurement is not a verdict: a repository over budget is a fact
16
+ // about the repository, and a command that went red for it would train people to
17
+ // ignore the number it exists to print. The only failure is being pointed at a
18
+ // directory that holds no repository to measure.
19
+ // 3. It counts bytes and calls the tokens an approximation every time it prints one.
20
+ // See src/measure/context.mjs for what that costs and why.
21
+
22
+ import { resolve } from 'node:path';
23
+
24
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
25
+ import { CLI } from '../name.mjs';
26
+ import { discover } from '../compare/discover.mjs';
27
+ import { targetRoot } from '../workspace.mjs';
28
+ import {
29
+ ALWAYS_ON_BUDGET_BYTES,
30
+ BYTES_PER_TOKEN,
31
+ annotate,
32
+ measureRoot,
33
+ mcpTools,
34
+ totals,
35
+ } from '../measure/context.mjs';
36
+
37
+ /** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
38
+ * this command exists so two machines produce two numbers a pull request can diff. */
39
+ function plain(n) {
40
+ return String(n);
41
+ }
42
+
43
+ function files(n) {
44
+ return n === 1 ? '1 file' : `${plain(n)} files`;
45
+ }
46
+
47
+ export async function tokensCommand(positional, flags) {
48
+ // `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
49
+ // disagree about which directory was named (ADR-014).
50
+ const target = await targetRoot(flags, positional);
51
+ const root = resolve(target.root);
52
+
53
+ const found = await discover(root);
54
+ if (flags.repos) {
55
+ if (found.error) {
56
+ return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
57
+ }
58
+ if (found.repos.length === 0) {
59
+ return fail(
60
+ `${root} contains no git repositories`,
61
+ EXIT.CONFIG,
62
+ `point --root at the directory holding the repositories, or at one repository without --repos`,
63
+ );
64
+ }
65
+ // `discover` sorts, and the order decides which root owns a shared paragraph. A
66
+ // second sort here would move duplicate bytes between roots and make the workspace
67
+ // figure irreproducible.
68
+ } else if (found.shape === 'workspace') {
69
+ return fail(
70
+ `${root} holds repositories rather than being one`,
71
+ EXIT.CONFIG,
72
+ `measure one with: ${CLI} tokens --root <path>, or every repository under it with: ${CLI} tokens --repos --root ${root}`,
73
+ );
74
+ }
75
+ const roots = flags.repos ? found.repos : [root];
76
+
77
+ const rows = [];
78
+ for (const path of roots) rows.push(await measureRoot(path));
79
+ annotate(rows);
80
+
81
+ const summary = totals(rows);
82
+ const mcp = mcpTools();
83
+
84
+ if (isJson(flags)) {
85
+ emitJson({
86
+ ok: true,
87
+ command: 'tokens',
88
+ root,
89
+ mode: flags.repos ? 'repositories' : 'repository',
90
+ source: target.source,
91
+ budget: { bytes: ALWAYS_ON_BUDGET_BYTES, bytesPerToken: BYTES_PER_TOKEN },
92
+ mcp,
93
+ roots: rows.map(({ documents, ...row }) => row),
94
+ totals: summary,
95
+ });
96
+ return EXIT.OK;
97
+ }
98
+
99
+ const lines = [];
100
+ if (flags.repos) {
101
+ lines.push(`tokens ${root} ${plain(summary.roots)} repositories (${target.source})`);
102
+ for (const row of rows) {
103
+ const budget = row.budget.over ? ` over budget by ${plain(row.budget.overBy)} B` : '';
104
+ lines.push(
105
+ `${row.name} generated ${plain(row.generatedBytes)} B duplicate ${plain(row.redundancy.duplicateBytes)} B unique ${plain(row.redundancy.uniqueBytes)} B${budget}`,
106
+ );
107
+ for (const finding of row.findings) lines.push(`note ${row.name}: ${finding}`);
108
+ }
109
+ const share =
110
+ summary.generatedBytes === 0 ? 0 : Math.round((summary.duplicateBytes / summary.generatedBytes) * 100);
111
+ lines.push(
112
+ `totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
113
+ );
114
+ } else {
115
+ const row = rows[0];
116
+ lines.push(`tokens ${row.path}`);
117
+ lines.push(`state ${row.adoption} surfaces ${row.surfaces.length === 0 ? '(AGENTS.md only)' : row.surfaces.join(', ')}`);
118
+ for (const channel of row.channels) {
119
+ lines.push(
120
+ `${channel.channel.padEnd(9)}${files(channel.files)} ${plain(channel.generatedBytes)} B generated ${plain(channel.readerBytes)} B read`,
121
+ );
122
+ }
123
+ const verdict = row.budget.over ? `over by ${plain(row.budget.overBy)} B` : 'within budget';
124
+ lines.push(
125
+ `always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
126
+ );
127
+ lines.push(
128
+ `redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
129
+ );
130
+ for (const finding of row.findings) lines.push(`note ${finding}`);
131
+ }
132
+ lines.push(
133
+ `mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
134
+ );
135
+
136
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
137
+ return EXIT.OK;
138
+ }
@@ -11,8 +11,9 @@ import { readFile, stat } from 'node:fs/promises';
11
11
  import { basename, join } from 'node:path';
12
12
  import { promisify } from 'node:util';
13
13
 
14
- import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderShim } from '../hooks/shim.mjs';
14
+ import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderShim } from '../hooks/shim.mjs';
15
15
  import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
16
+ import { packageVersion, hasNpm } from '../package-version.mjs';
16
17
 
17
18
  const exec = promisify(execFile);
18
19
 
@@ -30,7 +31,7 @@ const exec = promisify(execFile);
30
31
  * @param {string} path
31
32
  * @returns {Promise<{event:string, target:string}|null>}
32
33
  */
33
- export async function readChainedShim(path) {
34
+ async function readChainedShim(path) {
34
35
  let content;
35
36
  try {
36
37
  content = await readFile(path, 'utf8');
@@ -49,6 +50,54 @@ async function isChainedShim(path) {
49
50
  return (await readChainedShim(path)) !== null;
50
51
  }
51
52
 
53
+ /**
54
+ * Whether this file is a dispatcher shim this tool wrote, whatever version wrote it.
55
+ *
56
+ * Content, on the same argument as {@link readChainedShim}: the name is what git calls
57
+ * it and the path is where this tool happens to put its own, and neither of those is a
58
+ * statement about authorship. The banner is.
59
+ */
60
+ async function isOurShim(path) {
61
+ try {
62
+ return (await readFile(path, 'utf8')).includes(SHIM_MARKER);
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /** Git event -> manifest gate list. Exported because the report prints from it too. */
69
+ export const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
70
+
71
+ /** Gates this manifest declares for an event. */
72
+ export function gateCount(manifest, event) {
73
+ return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
74
+ }
75
+
76
+ /**
77
+ * Whether a missing shim for this event is a defect.
78
+ *
79
+ * A repo that declares no gate for an event has no reason to want a shim for it, so
80
+ * its absence is the correct end state. A repo with NO manifest is different: we do
81
+ * not know what it wants, so we cannot rule the shim out - absence of knowledge is
82
+ * not a decision.
83
+ *
84
+ * It lives here, not in the command that first needed it, because there are two
85
+ * callers and they were answering differently. `hooks install` asked this question;
86
+ * `compare` did not ask it at all and defaulted to "yes, a shim is wanted for every
87
+ * event", so a repository declaring only `prePush` gates was reported as
88
+ * `hooksComplete: false` for the pre-commit shim it correctly has no reason to write.
89
+ * Four estate repositories were in that state and no amount of running `hooks install`
90
+ * could clear it.
91
+ *
92
+ * @param {object|null} manifest the repository's manifest
93
+ * @param {boolean} exists whether a manifest was found at all
94
+ * @param {string} event
95
+ */
96
+ export function wantsShim(manifest, exists, event) {
97
+ if (!exists) return true;
98
+ return gateCount(manifest, event) > 0;
99
+ }
100
+
52
101
  /** `core.hooksPath`, or null when unset or the repo is not a git repo. */
53
102
  export async function readHooksPath(root) {
54
103
  try {
@@ -64,27 +113,40 @@ export async function readHooksPath(root) {
64
113
  * One shim's state. A shim that differs from what this simt-dev generates is
65
114
  * `stale`: the gates it enforces are from another version, so it cannot be
66
115
  * assumed to know about a gate added since.
116
+ *
117
+ * `ours` is reported rather than inferred from `stale`, because the two answer different
118
+ * questions and only one is the one `hooks install` has to ask. A shim this tool wrote can be
119
+ * current (identical bytes), or from an earlier release (stale), or a shell it left behind for
120
+ * an event with no gates - also stale, and the one thing here that is obsolete rather than out
121
+ * of date. A file a person wrote by hand is stale too, and it is the one thing install must
122
+ * never delete. Authorship is only on the file, which is the same argument {@link isOurShim}
123
+ * already records for the same reason.
124
+ *
125
+ * `root` is the repository the shim belongs to: the remedy a shim prints depends on
126
+ * the repo's kind (a devDependency where package.json exists, a pinned global install
127
+ * where there is none), so the comparison must render the shim the way THIS repo got it,
128
+ * or a non-npm repository would read as stale the moment this tool ships.
67
129
  */
68
- export async function describeShim(path) {
130
+ async function describeShim(path, root) {
69
131
  let content;
70
132
  try {
71
133
  content = await readFile(path, 'utf8');
72
134
  } catch {
73
- return { present: false, executable: false, stale: false };
135
+ return { present: false, executable: false, stale: false, ours: false };
74
136
  }
75
137
  let info;
76
138
  try {
77
139
  info = await stat(path);
78
140
  } catch {
79
- return { present: false, executable: false, stale: false };
141
+ return { present: false, executable: false, stale: false, ours: false };
80
142
  }
81
143
  let stale = true;
82
144
  try {
83
- stale = content !== renderShim(basename(path));
145
+ stale = content !== renderShim(basename(path), { npm: await hasNpm(root), version: packageVersion() });
84
146
  } catch {
85
147
  stale = true;
86
148
  }
87
- return { present: true, executable: (info.mode & 0o111) !== 0, stale };
149
+ return { present: true, executable: (info.mode & 0o111) !== 0, stale, ours: content.includes(SHIM_MARKER) };
88
150
  }
89
151
 
90
152
  /**
@@ -100,7 +162,7 @@ export async function readHooksState(root, options = {}) {
100
162
  const current = await readHooksPath(root);
101
163
  const installed = [];
102
164
  for (const event of EVENTS) {
103
- installed.push({ event, ...(await describeShim(join(root, fileFor(event)))) });
165
+ installed.push({ event, ...(await describeShim(join(root, fileFor(event)), root)) });
104
166
  }
105
167
 
106
168
  // What git runs right now, which is not the same question as "is a simt-dev
@@ -112,8 +174,10 @@ export async function readHooksState(root, options = {}) {
112
174
  // hand-written one. Only the bytes can.
113
175
  const foreign = [];
114
176
  const chained = [];
177
+ const staleOurs = [];
115
178
  for (const hook of live) {
116
- if (EVENTS.includes(hook.event) && !(await describeShim(hook.path)).stale) continue;
179
+ const shim = EVENTS.includes(hook.event) ? await describeShim(hook.path, root) : { stale: true };
180
+ if (!shim.stale) continue;
117
181
  // A chained shim is ours too, and its name says nothing: `post-commit` is what the
118
182
  // repository called its own hook, and the shim exists under that name precisely so
119
183
  // git finds it. Reading it as foreign would make every re-install refuse with "this
@@ -124,6 +188,25 @@ export async function readHooksState(root, options = {}) {
124
188
  chained.push({ ...chain, path: hook.path });
125
189
  continue;
126
190
  }
191
+ // And so is a dispatcher shim from an EARLIER version of this tool, which is the
192
+ // ordinary state of every repository adopted before the last release.
193
+ //
194
+ // Reading it as foreign was wrong twice over. `compare` reported "not a simt-dev
195
+ // shim" about a file simt-dev wrote, and `hooks install --yes` then printed "stopped
196
+ // running <event> ... declare it in simt.json gates.commands to have the dispatcher
197
+ // run it" — advice that, followed, makes the dispatcher run the dispatcher. The file
198
+ // was also described as "still on disk, untouched" in the same breath as the install
199
+ // overwrote it. Both statements were false and both pointed a reader at a worse
200
+ // configuration; nine estate repositories were in exactly this state.
201
+ //
202
+ // What it actually needs is refreshing, which `installed` already reports as `stale`
203
+ // and `compare` already reports as "differs from this simt-dev; reinstall". Keeping
204
+ // it out of `foreign` also stops the takeover guard asking for consent the
205
+ // repository has already given: it routed through this tool once.
206
+ if (await isOurShim(hook.path)) {
207
+ staleOurs.push(hook);
208
+ continue;
209
+ }
127
210
  foreign.push(hook);
128
211
  }
129
212
 
@@ -146,6 +229,10 @@ export async function readHooksState(root, options = {}) {
146
229
  complete: current === HOOKS_DIR && problems.length === 0,
147
230
  installed,
148
231
  foreign,
232
+ // Dispatcher shims this tool wrote in an earlier version. Reported so a reader can
233
+ // tell an out-of-date shim - which `hooks install` refreshes - from one this tool
234
+ // has never heard of, which only a human may decide to replace.
235
+ staleOurs,
149
236
  // The chained shims this repo is already running, read back off disk. `install`
150
237
  // re-renders them from here so a second run keeps maintaining them rather than
151
238
  // quietly forgetting them; without this field the set of things install maintains