simt-dev 0.6.3 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -14,13 +14,20 @@
14
14
  // such, because "no decision behind this" is exactly what a reviewer needs to
15
15
  // see and exactly what an empty output would hide.
16
16
 
17
- import { readFile, readdir } from 'node:fs/promises';
17
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
18
18
  import { readdirSync } from 'node:fs';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { MANIFEST_FILE } from '../name.mjs';
22
22
  import { CLI } from '../name.mjs';
23
23
  import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
24
+ import { LOCAL_DIR } from '../state.mjs';
25
+ import { packageVersion } from '../package-version.mjs';
26
+ // Q55: read lazily (see commandVerbs). The registry imports the `decide`
27
+ // command, which imports this module, so touching COMMANDS during module
28
+ // evaluation would read an initialising binding on one of the two load
29
+ // orders. Every use below happens at runtime, when both sides are loaded.
30
+ import { COMMANDS } from '../commands/index.mjs';
24
31
  import { PARTIAL_CLAUSE, parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
25
32
  import { affectsTokens } from '../index/govern.mjs';
26
33
 
@@ -82,7 +89,16 @@ export function redactInternalPaths(text) {
82
89
  return String(text ?? '').replace(internalPathPattern(), 'a file in this tool');
83
90
  }
84
91
 
85
- const ADR_IN_TEXT = /\bADR-(\d{3})\b/g;
92
+ // Q55: four digits and more are real record numbers, not a typo. The old `\d{3}`
93
+ // silently dropped every citation past ADR-999, and lexical sorting put ADR-1000
94
+ // before ADR-999 in every listing that trusted string order.
95
+ const ADR_IN_TEXT = /\bADR-(\d{3,})\b/g;
96
+
97
+ /** Numeric sort for record ids: ADR-1000 follows ADR-999, not ADR-100. */
98
+ function compareAdrIds(a, b) {
99
+ const num = (id) => Number(id.replace(/^ADR-/i, ''));
100
+ return num(a) - num(b) || (a < b ? -1 : a > b ? 1 : 0);
101
+ }
86
102
 
87
103
  /**
88
104
  * A line that begins one of the record's other front-matter fields.
@@ -159,7 +175,7 @@ export function citations(text) {
159
175
  for (const match of String(text ?? '').matchAll(ADR_IN_TEXT)) {
160
176
  found.add(`ADR-${match[1]}`);
161
177
  }
162
- return [...found].sort();
178
+ return [...found].sort(compareAdrIds);
163
179
  }
164
180
 
165
181
  /**
@@ -252,7 +268,11 @@ export function parseDecision(file, text, { all = false } = {}) {
252
268
  export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
253
269
  let names;
254
270
  try {
255
- names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/.test(n)).sort();
271
+ // Numeric, so the first record `byId` keeps under a duplicate is deterministic
272
+ // (lint flags the collision; the load does not guess between two files).
273
+ names = (await readdir(dir))
274
+ .filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
275
+ .sort((a, b) => compareAdrIds(adrIdOf(a) ?? a, adrIdOf(b) ?? b));
256
276
  } catch (error) {
257
277
  return { ok: false, decisions: [], errors: [`${dir}: unreadable (${error.message})`] };
258
278
  }
@@ -300,11 +320,72 @@ export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
300
320
  return { ok: errors.length === 0, decisions, errors };
301
321
  }
302
322
 
323
+ /** The record id a filename claims, or null when it claims none. */
324
+ function adrIdOf(file) {
325
+ return /^ADR-(\d{3,})/i.exec(file)?.[0]?.toUpperCase() ?? null;
326
+ }
327
+
303
328
  /** Index by id, for a lookup per cited ADR. */
304
329
  export function byId(decisions) {
305
330
  return new Map(decisions.map((d) => [d.id, d]));
306
331
  }
307
332
 
333
+ /** The command verbs a record may quote, derived from the dispatch registry. */
334
+ export function decisionVerbs() {
335
+ return commandVerbs();
336
+ }
337
+
338
+ /** One file's cache identity: name, size and mtime. Any write changes one of them. */
339
+ async function fileSignature(dir, name) {
340
+ const st = await stat(join(dir, name));
341
+ return { name, size: st.size, mtimeMs: st.mtimeMs };
342
+ }
343
+
344
+ /**
345
+ * Load the records, reusing a machine-local cache when the tree is unchanged.
346
+ *
347
+ * Every call used to re-parse every record (`rules why` 44 ms; part of
348
+ * `resources/list` 771 ms). The cache lives under `.simt/local/` — machine
349
+ * state, never committed — and is keyed on the directory, the `all` flag, the
350
+ * running version and one signature per file, so an edit, an add or an upgrade
351
+ * all miss honestly. Best-effort throughout: any cache failure loads fresh.
352
+ *
353
+ * @param {{root: string, dir: string, all?: boolean}} input `dir` is absolute;
354
+ * callers resolve it with `decisionsDir(root)` (or the package directory for
355
+ * shipped records) so this loader never guesses whose records these are.
356
+ */
357
+ export async function loadDecisionsCached({ root, dir, all = false } = {}) {
358
+ const cachePath = root ? join(root, LOCAL_DIR, 'decisions.index.json') : null;
359
+ let files = null;
360
+ try {
361
+ const names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
362
+ files = [];
363
+ for (const name of names) files.push(await fileSignature(dir, name));
364
+ files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
365
+ } catch {
366
+ return loadDecisions({ all, dir });
367
+ }
368
+ const key = { v: 1, tool: packageVersion(), dir, all, files };
369
+ if (cachePath) {
370
+ try {
371
+ const cached = JSON.parse(await readFile(cachePath, 'utf8'));
372
+ if (JSON.stringify(cached.key) === JSON.stringify(key)) return cached.result;
373
+ } catch {
374
+ // A missing or corrupt cache is a miss, never an error.
375
+ }
376
+ }
377
+ const fresh = await loadDecisions({ all, dir });
378
+ if (cachePath) {
379
+ try {
380
+ await mkdir(join(root, LOCAL_DIR), { recursive: true });
381
+ await writeFile(cachePath, JSON.stringify({ key, result: fresh }), 'utf8');
382
+ } catch {
383
+ // A read-only checkout keeps working uncached.
384
+ }
385
+ }
386
+ return fresh;
387
+ }
388
+
308
389
  /**
309
390
  * What the decision actually says, in the reader's terms.
310
391
  *
@@ -506,7 +587,7 @@ const section = (name) => {
506
587
  // A record whose subject is the prefix is arguing rather than citing, so it names none.
507
588
  writtenAgainst: PREFIX_ARGUING.has(file)
508
589
  ? []
509
- : [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name)))].sort(),
590
+ : [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name.toLowerCase())))].sort(),
510
591
  };
511
592
  }
512
593
 
@@ -545,8 +626,11 @@ const section = (name) => {
545
626
  * them - which is what this file used to do. Detection of those now depends on a name
546
627
  * never being written into shipped text, rather than on a check.
547
628
  */
548
- export const ESTATE_NAME =
549
- /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/;
629
+ // Q55: one shape, two flags. The estate-name pattern used to be written out twice
630
+ // (`ESTATE_NAME` and `ESTATE`), so a shape fixed in one kept matching in the other.
631
+ const ESTATE_SOURCE = '\\b(?:simt-(?!dev\\b)[\\w-]+|repo-\\d{2})\\b';
632
+
633
+ export const ESTATE_NAME = new RegExp(ESTATE_SOURCE);
550
634
 
551
635
  /** Replace estate repository names with a phrase a reader who does not have them can use. */
552
636
  export function redactEstate(text) {
@@ -554,7 +638,7 @@ export function redactEstate(text) {
554
638
  // The tool's own names are left where they are. Redacting them turned a ruling about
555
639
  // `simt-priority` into a sentence about `a repository`, which is how a reader learned
556
640
  // that the record had surveyed something and not what it said (ADR-080).
557
- TOOL_WORDS.has(name) ? name : 'a repository',
641
+ TOOL_WORDS.has(name.toLowerCase()) ? name : 'a repository',
558
642
  );
559
643
  }
560
644
 
@@ -575,10 +659,24 @@ export function redactEstate(text) {
575
659
  * Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
576
660
  * `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
577
661
  */
578
- const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|agent-hook|agent-hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|lessons|decide|brief|plan|--[a-z-]+)';
662
+ // Q55: the verbs a record may quote are the commands this binary dispatches,
663
+ // not a hand list. The list used to miss `pack`, `plugin` and `lease`, so a record
664
+ // quoting one read as prose instead of a runnable command.
665
+ let verbAlternation = null;
666
+ function commandVerbs() {
667
+ if (verbAlternation === null) {
668
+ const words = new Set();
669
+ for (const path of COMMANDS) {
670
+ const first = path.split(' ')[0];
671
+ if (first) words.add(first.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
672
+ }
673
+ verbAlternation = `(?:${[...words].sort().join('|')}|--[a-z-]+)`;
674
+ }
675
+ return verbAlternation;
676
+ }
579
677
 
580
678
  function redactCommand(text) {
581
- return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
679
+ return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${commandVerbs()})\\b`, 'g'), `${CLI} `);
582
680
  }
583
681
 
584
682
  /**
@@ -590,7 +688,7 @@ function redactCommand(text) {
590
688
  * enumerating the names here put the portfolio in every adopting repository. The records
591
689
  * themselves now carry `repo-NN` labels, so that is what this matches in practice.
592
690
  */
593
- const ESTATE = /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/gi;
691
+ const ESTATE = new RegExp(ESTATE_SOURCE, 'gi');
594
692
 
595
693
  /** Trim to whole sentences within a budget, never leaving a stub fragment. */
596
694
  function sentence(text, limit = 260) {
@@ -34,7 +34,9 @@ import {
34
34
  } from './engine.mjs';
35
35
 
36
36
  export const DEFAULT_BUDGET = 2048;
37
- const DISTILL_DIR = '.simt/distilled';
37
+ import { LOCAL_DIR } from '../state.mjs';
38
+
39
+ const DISTILL_DIR = join(LOCAL_DIR, 'distilled');
38
40
 
39
41
  /**
40
42
  * The row's acceptance metric, stated where a caller reads results (ADR-076):
@@ -0,0 +1,144 @@
1
+ // The one Node runner resolver every consumer reads (review 0003 Q31).
2
+ //
3
+ // Before this, four tables answered "how does this repo run a script" and
4
+ // disagreed: the card printed `npm run`, the dispatcher spawned `npm run`,
5
+ // the manifest remedies named `npm run`, and CI installed with `npm ci` — in a
6
+ // repository whose lockfile and `packageManager` said pnpm. A pnpm adoption
7
+ // then failed on its first pull request, and a docs-only or Go repo got
8
+ // remedies proposing npm gates it could never run.
9
+ //
10
+ // One data table, one resolver, every consumer. The table is records, not
11
+ // code, so the Q70 ecosystem work can adopt it without a second answer: a
12
+ // future `ecosystems/<id>.json` row carries the same fields and this resolver
13
+ // stays the shape of the lookup.
14
+ //
15
+ // Precedence is committed evidence first. A lockfile on disk names the manager
16
+ // the repo actually installs with; `packageManager` names the one it declares.
17
+ // Where the two disagree `checkToolchain` already reports `mismatched`, and the
18
+ // runner follows the lockfile — the install step must restore the tree CI
19
+ // checks out, not the field. With no lockfile the declared field wins over the
20
+ // npm default, so a fresh `pnpm init` names pnpm before its first install.
21
+
22
+ export const NODE_MANAGERS = [
23
+ {
24
+ id: 'pnpm',
25
+ lockfiles: ['pnpm-lock.yaml'],
26
+ fact: 'has-pnpm-lock',
27
+ run: 'pnpm',
28
+ install: 'pnpm install --frozen-lockfile',
29
+ installFallback: 'pnpm install',
30
+ addDev: 'pnpm add -D',
31
+ silent: true,
32
+ setupNode: true,
33
+ },
34
+ {
35
+ id: 'yarn',
36
+ lockfiles: ['yarn.lock'],
37
+ fact: 'has-yarn-lock',
38
+ run: 'yarn',
39
+ install: 'yarn install --frozen-lockfile',
40
+ installFallback: 'yarn install',
41
+ addDev: 'yarn add -D',
42
+ silent: true,
43
+ setupNode: true,
44
+ },
45
+ {
46
+ id: 'bun',
47
+ lockfiles: ['bun.lock', 'bun.lockb'],
48
+ fact: 'has-bun-lock',
49
+ run: 'bun',
50
+ install: 'bun install --frozen-lockfile',
51
+ installFallback: 'bun install',
52
+ addDev: 'bun add -d',
53
+ silent: false,
54
+ setupNode: false,
55
+ },
56
+ {
57
+ id: 'npm',
58
+ lockfiles: ['package-lock.json'],
59
+ fact: 'has-package-lock',
60
+ run: 'npm',
61
+ install: 'npm ci',
62
+ installFallback: 'npm install',
63
+ addDev: 'npm i -D',
64
+ silent: true,
65
+ setupNode: true,
66
+ },
67
+ ];
68
+
69
+ const BY_ID = new Map(NODE_MANAGERS.map((row) => [row.id, row]));
70
+ const BY_LOCKFILE = new Map();
71
+ for (const row of NODE_MANAGERS) {
72
+ for (const file of row.lockfiles) BY_LOCKFILE.set(file, row);
73
+ }
74
+ const BY_FACT = new Map(NODE_MANAGERS.map((row) => [row.fact, row]));
75
+
76
+ /** The npm row. Exported so callers never spell a default twice. */
77
+ export function npmManager() {
78
+ return BY_ID.get('npm');
79
+ }
80
+
81
+ /**
82
+ * Name the manager a `packageManager` field declares, or null.
83
+ *
84
+ * `packageManager` is `manager@version`; only the manager names the runner.
85
+ * Unknown managers and non-strings are null, never a guess.
86
+ */
87
+ export function managerFromPackageField(value) {
88
+ if (typeof value !== 'string') return null;
89
+ const id = value.split('@')[0].trim();
90
+ return BY_ID.has(id) ? id : null;
91
+ }
92
+
93
+ /**
94
+ * Resolve the one Node runner for a repository.
95
+ *
96
+ * @param {{facts?: string[]|Set<string>, lockfiles?: string[], packageManager?: string|null}} input
97
+ * @returns {object} the NODE_MANAGERS row that owns this repo
98
+ */
99
+ export function resolveNodeRunner({ facts = [], lockfiles = [], packageManager = null } = {}) {
100
+ const present = facts instanceof Set ? facts : new Set(facts);
101
+ for (const row of NODE_MANAGERS) {
102
+ if (present.has(row.fact)) return row;
103
+ for (const file of row.lockfiles) {
104
+ if (lockfiles.includes(file)) return row;
105
+ }
106
+ }
107
+ const declared = managerFromPackageField(packageManager);
108
+ if (declared) return BY_ID.get(declared);
109
+ return npmManager();
110
+ }
111
+
112
+ /** Whether this runner id spells gates as `<run> run <script>`. */
113
+ export function isNodeRunner(runner) {
114
+ return BY_ID.has(runner);
115
+ }
116
+
117
+ /** The command that runs one script: `pnpm run lint`, `bun run lint`. */
118
+ export function runScript(manager, script) {
119
+ const id = typeof manager === 'string' ? manager : manager?.id ?? 'npm';
120
+ const run = BY_ID.get(id)?.run ?? 'npm';
121
+ return `${run} run ${script}`;
122
+ }
123
+
124
+ /** The argv the dispatcher spawns for one script gate. */
125
+ export function runScriptArgv(manager, script) {
126
+ const id = typeof manager === 'string' ? manager : manager?.id ?? 'npm';
127
+ const row = BY_ID.get(id) ?? npmManager();
128
+ if (row.silent) return [row.run, 'run', '--silent', script];
129
+ return [row.run, 'run', script];
130
+ }
131
+
132
+ /** Install step for CI: the frozen command plus its fallback. */
133
+ export function installCommands(manager) {
134
+ const row = typeof manager === 'string' ? (BY_ID.get(manager) ?? npmManager()) : (manager ?? npmManager());
135
+ return { install: row.install, fallback: row.installFallback, setupNode: row.setupNode };
136
+ }
137
+
138
+ /** Look up a manager record by lockfile name, fact, or id. */
139
+ export function lookupNodeManager({ lockfile = null, fact = null, id = null } = {}) {
140
+ if (id && BY_ID.has(id)) return BY_ID.get(id);
141
+ if (lockfile && BY_LOCKFILE.has(lockfile)) return BY_LOCKFILE.get(lockfile);
142
+ if (fact && BY_FACT.has(fact)) return BY_FACT.get(fact);
143
+ return null;
144
+ }
package/src/explain.mjs CHANGED
@@ -59,8 +59,8 @@ export const EXPLAIN_CODES = {
59
59
  },
60
60
  'file-lines': {
61
61
  what: 'A file exceeds the 1000-line hard ceiling: it fails the gate.',
62
- why: 'ADR-005: 400 lines is a review trigger, 1000 is a hard ceiling, and generated files are exempt. The ceiling is a default a repository can narrow, never a number an agent negotiates at write time.',
63
- fix: 'Split the file, or narrow the ceiling deliberately in the rule that declares it — not by editing the generated line.',
62
+ why: 'ADR-005: 400 lines is a review trigger, 1000 is a hard ceiling, and excluded files are exempt. The ceiling is a default a repository can narrow, never a number an agent negotiates at write time.',
63
+ fix: 'Split the file, or name it in limits.fileLines.exclude — not by editing the generated line.',
64
64
  example: 'A 1240-line module with no exemption: FILE-LINES names the file and the count.',
65
65
  },
66
66
  'file-lines-warn': {
@@ -94,10 +94,10 @@ export const EXPLAIN_CODES = {
94
94
  example: 'gates.cwd.test set to "../shared" in a monorepo member.',
95
95
  },
96
96
  inconsistent: {
97
- what: 'The TypeScript base config disagrees with this tool\u2019s floor in a way no flag explains: the project\u2019s tsconfig and the shipped base describe different compilers.',
98
- why: 'ADR-025 and ADR-026: the base file is owned here and pointed at there, so a disagreement is a real fork in what the typechecker enforces — not a formatting choice. A repo that edited the owned bytes is not drift, but a repo whose config contradicts them is.',
99
- fix: 'Re-align the project tsconfig with the shipped base, or record why the fork is deliberate.',
100
- example: 'A project tsconfig setting strict false under a base that requires it.',
97
+ what: 'The project\u2019s tsconfig spells `moduleResolution` in a form the TypeScript parser accepts but the documentation does not, so it compiles and quietly disagrees with every repository that spells it the documented way.',
98
+ why: 'The parser is case-insensitive, which is the whole reason a workspace drifts on this key: both spellings work, so nothing ever said the other one did not. Consistency with the documentation is the only spelling checkable in one repository at a time \u2014 consistency with the other repositories would need a workspace, and `check` runs in a repository on its own.',
99
+ fix: 'Use the documented spelling the finding names; it compiles identically.',
100
+ example: '"moduleResolution": "Bundler" where the documentation writes "bundler".',
101
101
  },
102
102
  inert: {
103
103
  what: 'The TypeScript base config is present but inert: nothing in the repository reads it, so it constrains no file.',
@@ -117,6 +117,12 @@ export const EXPLAIN_CODES = {
117
117
  fix: 'Fix the simt-supersedes entry to [{ adr: "ADR-NNN", ruling: N }], with a real record and ruling number.',
118
118
  example: 'simt-supersedes: [{ adr: "ADR-999", ruling: 1 }] when no ADR-999 exists.',
119
119
  },
120
+ 'duplicate-decision-id': {
121
+ what: 'Two decision records claim one number: the index only sees the first, so the second is a record no provenance answer can reach.',
122
+ why: 'Review 0003 Q55: byId keeps one silently, and a reader of the other gets a decision the tooling cannot resolve. Failing names both files while the collision is still a rename, not a rewrite of history.',
123
+ fix: 'Renumber the newer record to a free number, then rerun the check.',
124
+ example: 'ADR-002-first.md and ADR-002-second.md: DUPLICATE-DECISION-ID names both.',
125
+ },
120
126
  'lock-absent': {
121
127
  what: 'No lock is recorded: the projection was never locked, so there is nothing `--frozen` can compare against.',
122
128
  why: 'ADR-086: the lock is the byte-identical contract across machines, and `--frozen` without one would compare the projection against nothing and call it current. Reported only under --frozen, because a repository that never locked is not drift.',
@@ -243,6 +249,12 @@ export const EXPLAIN_CODES = {
243
249
  fix: 'Read the detail (it carries the OS error): fix permissions, remove the broken link, then re-run.',
244
250
  example: 'AGENTS.md unreadable (EACCES): UNREADABLE with the errno, not ABSENT.',
245
251
  },
252
+ unresolvable: {
253
+ what: 'The checked-in gate workflow cannot resolve simt-dev by any path its locator tries: no pinned devDependency, no release binary, no simt on PATH.',
254
+ why: 'Review 0003 Q31: a green tick from a check that never ran is the one outcome worse than a red one. The workflow installs whatever the repo pins, so a repo that pins nothing gates nothing — reported here instead of failing open in CI.',
255
+ fix: 'Run the install the finding names (the repo manager\u2019s add-dev command) so CI restores the pinned dependency.',
256
+ example: 'A pnpm repo whose workflow says npm ci with no simt-dev dependency: UNRESOLVABLE names pnpm add -D.',
257
+ },
246
258
  unrunnable: {
247
259
  what: 'A declared gate cannot run here: the command it needs is not on PATH, or its runner is missing.',
248
260
  why: 'ADR-017: a gate that cannot run fails closed on every commit once hooks are installed — after the reader committed, which is the wrong order. check reports it before anyone installs anything, with the probe result that proved it.',
@@ -154,10 +154,15 @@ export async function loadRegistry(path = null, flags = {}) {
154
154
  // errors are facts about ABSENCE - no registry here, or more than one and the reader must
155
155
  // pick - and are reported as such. A named path that is missing is a different fact: the
156
156
  // reader pointed at a file that is not there, and it names that path.
157
- let where = path;
157
+ //
158
+ // Resolution order for the registry itself (review 0003 Q37): positional path,
159
+ // then `--registry`, then `SIMT_REGISTRY`, then the default tiers above.
160
+ const explicit =
161
+ path ?? flags?.registry ?? process.env.SIMT_REGISTRY ?? process.env.SIMT_DEV_REGISTRY ?? null;
162
+ let where = explicit;
158
163
  let resolveErrors = null;
159
164
  let resolvedWorkspace = null;
160
- if (path === null) {
165
+ if (explicit === null) {
161
166
  const found = await defaultRegistry(flags);
162
167
  where = found.where;
163
168
  resolveErrors = found.errors;
@@ -181,7 +186,7 @@ export async function loadRegistry(path = null, flags = {}) {
181
186
  registry = JSON.parse(await readFile(where, 'utf8'));
182
187
  } catch (error) {
183
188
  const message =
184
- path === null && error.code === 'ENOENT'
189
+ explicit === null && error.code === 'ENOENT'
185
190
  ? `no skill registry at ${where}; create one there, or name it explicitly`
186
191
  : `${where}: unreadable or invalid JSON (${error.message})`;
187
192
  return {
@@ -260,6 +265,11 @@ export async function loadRegistry(path = null, flags = {}) {
260
265
  * broken registry rather than a wrong pointer, which is exactly backwards.
261
266
  */
262
267
  function holdsAnyRepo(registry, root) {
268
+ // Every repo the registry names, vendored or not: the question is whether this
269
+ // directory is a workspace at all. Filtering out vendored sources made the check
270
+ // unsatisfiable the moment the last non-vendored skill left the registry - an
271
+ // all-vendored registry names repos just the same, and a home folder holds none
272
+ // of them.
263
273
  const named = new Set(
264
274
  (registry.skills ?? []).flatMap((s) => (s.sources ?? []).map((x) => x.repo)).filter(Boolean),
265
275
  );
@@ -282,14 +292,20 @@ export function lintRegistry(registry) {
282
292
  const threshold = registry.deferredThreshold ?? 20000;
283
293
 
284
294
  // --- shape -----------------------------------------------------------------
295
+ // Every check below assumes arrays of objects. A registry that fails the shape
296
+ // is already fully described by the shape error; continuing past it only
297
+ // trades one true error for a crash (`for..of` over `undefined`) or a pile of
298
+ // secondary noise, so the lint stops here (review 0003 Q35).
285
299
  if (!Array.isArray(registry.capabilities) || capabilities.size === 0) {
286
300
  err('capabilities: missing or empty');
287
301
  }
288
302
  if (!Array.isArray(registry.skills) || skills.length === 0) {
289
303
  err('skills: missing or empty');
304
+ }
305
+ if (errors.length > 0) {
290
306
  return { sync: errors, warnings, index };
291
307
  }
292
- for (const c of registry.capabilities) {
308
+ for (const c of registry.capabilities ?? []) {
293
309
  if (!c.id || !c.summary) err(`capability ${c.id ?? '<no id>'}: needs id and summary`);
294
310
  }
295
311
 
@@ -449,22 +465,22 @@ function checkRequires(at, skill, index, err) {
449
465
  if (!index.has(dep)) err(`${at}: requires unknown skill "${dep}"`);
450
466
  if (dep === skill.name) err(`${at}: requires itself`);
451
467
  }
452
- // Cycle detection over `requires`. Depth is bounded by node count, so an
453
- // accidental cycle terminates rather than hanging the lint.
454
- const seen = new Set([skill.name]);
455
- let frontier = skill.requires ?? [];
456
- while (frontier.length > 0) {
457
- const next = [];
458
- for (const dep of frontier) {
459
- if (seen.has(dep)) {
460
- err(`requires cycle: ${[...seen].join(' -> ')} -> ${dep}`);
468
+ // Cycle detection over `requires`, depth-first with a recursion stack. The old
469
+ // breadth-first walk shared one `seen` set across branches, so a diamond
470
+ // (a requires b and c; both require d) reported d's second visit as a cycle.
471
+ // Only a dependency already on the current path is a cycle; a node reached
472
+ // through two branches is sharing, not looping (review 0003 Q35).
473
+ const visit = (name, path) => {
474
+ const stack = [...path, name];
475
+ for (const dep of index.get(name)?.requires ?? []) {
476
+ if (stack.includes(dep)) {
477
+ err(`requires cycle: ${[...stack, dep].join(' -> ')}`);
461
478
  return;
462
479
  }
463
- seen.add(dep);
464
- next.push(...(index.get(dep)?.requires ?? []));
480
+ visit(dep, stack);
465
481
  }
466
- frontier = next;
467
- }
482
+ };
483
+ visit(skill.name, []);
468
484
  }
469
485
 
470
486
  function checkAlias(at, skill, index, err) {
@@ -570,6 +586,10 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
570
586
  const warnings = [];
571
587
 
572
588
  for (const skill of registry.skills ?? []) {
589
+ // A vendored skill ships with the package; its source is the package root,
590
+ // not a workspace member. Verifying it against the workspace filesystem is
591
+ // the wrong check (the path will not exist there by design).
592
+ if (skill.vendored) continue;
573
593
  for (const source of skill.sources ?? []) {
574
594
  const at = `${skill.name}: ${source.repo}/${source.path}`;
575
595
  if (!source.repo || !source.path) continue;
@@ -644,8 +664,7 @@ export async function measureSkill(full) {
644
664
  hasEntry = false;
645
665
  }
646
666
  if (!hasEntry) {
647
- const md = files === 1 ? '' : '';
648
- throw Object.assign(new Error(`${full} has no SKILL.md entry point${md}`), { code: 'NO_SKILL_MD' });
667
+ throw Object.assign(new Error(`${full} has no SKILL.md entry point`), { code: 'NO_SKILL_MD' });
649
668
  }
650
669
 
651
670
  return { bytes, files, flat: false };
@@ -22,6 +22,7 @@ import { constants as fsConstants } from 'node:fs';
22
22
  import { access, readdir, readFile, stat } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
  import { ECOSYSTEMS, manifestPath } from '../projection/manifest.mjs';
25
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
25
26
 
26
27
  /** Binaries the shared dispatcher shells out to directly, rather than via a script. */
27
28
  const BIN_DIR = join('node_modules', '.bin');
@@ -104,10 +105,22 @@ async function probeTool(root, eco) {
104
105
  export async function readCapability(root) {
105
106
  const capability = { scripts: {}, binaries: [], commands: {}, tools: [], runner: null, error: null };
106
107
 
108
+ // Q31: a package.json names a Node repo, but not which manager it installs
109
+ // with. The lockfile on disk wins over the `packageManager` field (the field
110
+ // can disagree; `checkToolchain` reports that), and the field wins over the
111
+ // npm default — so a `packageManager: pnpm@9` repo resolves pnpm before its
112
+ // first install, and every consumer below reads the same answer.
113
+ let packageManager = null;
107
114
  try {
108
115
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
109
116
  capability.scripts = manifest.scripts ?? {};
110
- capability.runner = 'npm';
117
+ packageManager = typeof manifest.packageManager === 'string' ? manifest.packageManager : null;
118
+ const lockfiles = [];
119
+ for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
120
+ if (await fileExists(join(root, file))) lockfiles.push(file);
121
+ }
122
+ capability.runner = resolveNodeRunner({ lockfiles, packageManager }).id;
123
+ capability.packageManager = packageManager;
111
124
  } catch (error) {
112
125
  // A repo with no manifest is a normal shape — a Python or Gradle repo has no
113
126
  // package.json at all — so this is reported, never thrown. The gate filter treats