simt-dev 0.6.2 → 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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. 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
  17. 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
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. 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
  22. 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
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
package/src/diff.mjs ADDED
@@ -0,0 +1,254 @@
1
+ // Unified diffs of the plan (plan 0003 R50).
2
+ //
3
+ // Every writer accepts `--diff` and prints what it would change — or, with
4
+ // `--yes`, what it changed — as unified diffs, one per file. The engine is
5
+ // here, zero dependencies like everything else (a DX tool that needs an
6
+ // install step fails on a fresh machine); each command only supplies
7
+ // {path, before, after} pairs, read before anything is written.
8
+ //
9
+ // Two deliberate boundaries:
10
+ //
11
+ // - Lines, not bytes. All files this tool writes are text; a binary would
12
+ // diff as one giant replaced line, which says nothing. Callers pass
13
+ // strings (null when the file is absent on that side).
14
+ // - Big files degrade, they do not hang. Past 10,000 lines combined the
15
+ // pair renders as one delete-all/insert-all hunk: still a true statement
16
+ // about what changed, just without the search. Myers is O(ND) and a
17
+ // pathological pair would otherwise own the prompt.
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+
21
+ import { emit, isJson } from './output/index.mjs';
22
+
23
+ const CONTEXT = 3;
24
+ const LINE_CAP = 10000;
25
+
26
+ /**
27
+ * The current bytes of a path, or null when it does not exist. Any other
28
+ * failure propagates: an unreadable file is fail-closed, not treated as
29
+ * absent — a diff against the wrong before would be worse than no diff.
30
+ */
31
+ export async function readBefore(path) {
32
+ try {
33
+ return await readFile(path, 'utf8');
34
+ } catch (error) {
35
+ if (error.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Print a run's pairs as unified diffs, or nothing when the plan changes no
42
+ * bytes (an empty creation, an already-current file). Never changes what the
43
+ * run writes or its exit code — callers invoke it in preview and apply alike.
44
+ *
45
+ * Silent under `--json`: the document is the whole report there, and prose on
46
+ * stdout would break the one-document contract. Callers spread `diffField`
47
+ * into their payload instead, so the JSON answer carries the same bytes.
48
+ */
49
+ export function emitDiffs(pairs, flags) {
50
+ if (isJson(flags)) return;
51
+ const text = renderDiffs(pairs);
52
+ if (text !== '') emit(text, { maxBytes: flags.maxBytes ?? 0 });
53
+ }
54
+
55
+ /** The `--diff` bytes for a JSON payload: absent entirely without the flag, so
56
+ * payloads without it are byte-identical to before. */
57
+ export function diffField(pairs, flags) {
58
+ return flags.diff === true ? { diff: renderDiffs(pairs) } : {};
59
+ }
60
+
61
+ /** Split text into lines, remembering whether the last line was terminated. */
62
+ function splitLines(text) {
63
+ if (text === '') return { lines: [], terminated: true };
64
+ const terminated = text.endsWith('\n');
65
+ const raw = text.split('\n');
66
+ if (terminated) raw.pop();
67
+ return { lines: raw.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l)), terminated };
68
+ }
69
+
70
+ /**
71
+ * Myers O(ND) edit script as a list of {op, line} with op in keep/del/add.
72
+ * Deterministic: deletions sort before additions at every branch, so the
73
+ * same pair always renders the same hunks.
74
+ */
75
+ function script(a, b) {
76
+ const n = a.length;
77
+ const m = b.length;
78
+ if (n + m > LINE_CAP) {
79
+ return [...a.map((line) => ({ op: 'del', line })), ...b.map((line) => ({ op: 'add', line }))];
80
+ }
81
+ const max = n + m;
82
+ const trace = [];
83
+ let v = new Map([[1, 0]]);
84
+ let done = false;
85
+ for (let d = 0; d <= max && !done; d += 1) {
86
+ const cur = new Map(v);
87
+ for (let k = -d; k <= d; k += 2) {
88
+ let x;
89
+ if (k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1))) {
90
+ x = v.get(k + 1) ?? 0;
91
+ } else {
92
+ x = (v.get(k - 1) ?? -1) + 1;
93
+ }
94
+ let y = x - k;
95
+ while (x < n && y < m && a[x] === b[y]) {
96
+ x += 1;
97
+ y += 1;
98
+ }
99
+ cur.set(k, x);
100
+ if (x >= n && y >= m) {
101
+ done = true;
102
+ trace.push(cur);
103
+ break;
104
+ }
105
+ }
106
+ if (!done) trace.push(cur);
107
+ v = cur;
108
+ if (done) break;
109
+ }
110
+ // Walk the trace backwards, emitting the script in reverse.
111
+ const ops = [];
112
+ let x = n;
113
+ let y = m;
114
+ for (let d = trace.length - 1; d > 0; d -= 1) {
115
+ const prev = trace[d - 1];
116
+ const k = x - y;
117
+ const down = k === -d || (k !== d && (prev.get(k - 1) ?? -1) < (prev.get(k + 1) ?? -1));
118
+ const kPrev = down ? k + 1 : k - 1;
119
+ const xPrev = prev.get(kPrev) ?? 0;
120
+ const yPrev = xPrev - kPrev;
121
+ while (x > xPrev && y > yPrev) {
122
+ ops.push({ op: 'keep', line: a[x - 1] });
123
+ x -= 1;
124
+ y -= 1;
125
+ }
126
+ if (down) {
127
+ ops.push({ op: 'add', line: b[y - 1] });
128
+ y -= 1;
129
+ } else {
130
+ ops.push({ op: 'del', line: a[x - 1] });
131
+ x -= 1;
132
+ }
133
+ }
134
+ while (x > 0 && y > 0) {
135
+ ops.push({ op: 'keep', line: a[x - 1] });
136
+ x -= 1;
137
+ y -= 1;
138
+ }
139
+ return ops.reverse();
140
+ }
141
+
142
+ /**
143
+ * Group changed ops into hunks with context. Two changes merge when at most
144
+ * 2×CONTEXT unchanged lines sit between them — the trailing context of one
145
+ * is the leading context of the next — and split otherwise.
146
+ */
147
+ function hunks(ops) {
148
+ // Line numbers before each op, per side.
149
+ const nums = [];
150
+ let a = 0;
151
+ let b = 0;
152
+ for (const o of ops) {
153
+ nums.push([a, b]);
154
+ if (o.op !== 'add') a += 1;
155
+ if (o.op !== 'del') b += 1;
156
+ }
157
+ const changed = ops.map((o, i) => (o.op === 'keep' ? -1 : i)).filter((i) => i >= 0);
158
+ if (changed.length === 0) return [];
159
+ const groups = [];
160
+ let g = [changed[0]];
161
+ for (let i = 1; i < changed.length; i += 1) {
162
+ if (changed[i] - g[g.length - 1] <= CONTEXT * 2) g.push(changed[i]);
163
+ else {
164
+ groups.push(g);
165
+ g = [changed[i]];
166
+ }
167
+ }
168
+ groups.push(g);
169
+ return groups.map((idx) => {
170
+ const start = Math.max(0, idx[0] - CONTEXT);
171
+ const end = Math.min(ops.length - 1, idx[idx.length - 1] + CONTEXT);
172
+ const slice = ops.slice(start, end + 1);
173
+ let aLen = 0;
174
+ let bLen = 0;
175
+ for (const o of slice) {
176
+ if (o.op !== 'add') aLen += 1;
177
+ if (o.op !== 'del') bLen += 1;
178
+ }
179
+ // GNU shape: an empty side starts at the line before (`-0,0`), a single
180
+ // line omits its `,1`.
181
+ const aStart = aLen === 0 ? nums[start][0] : nums[start][0] + 1;
182
+ const bStart = bLen === 0 ? nums[start][1] : nums[start][1] + 1;
183
+ return { aStart, bStart, aLen, bLen, ops: slice };
184
+ });
185
+ }
186
+
187
+ function hunkHeader(h) {
188
+ const a = h.aLen === 1 ? `${h.aStart}` : `${h.aStart},${h.aLen}`;
189
+ const b = h.bLen === 1 ? `${h.bStart}` : `${h.bStart},${h.bLen}`;
190
+ return `@@ -${a} +${b} @@`;
191
+ }
192
+
193
+ /**
194
+ * One file's unified diff, or '' when the two sides are equal. `before` null
195
+ * is a creation, `after` null a removal. An empty creation (or removal of an
196
+ * empty file) has no line changes and renders as '' — the plan already says
197
+ * `created`, and headers with no hunks are noise.
198
+ */
199
+ export function unifiedDiff(path, before, after) {
200
+ const b = before ?? null;
201
+ const a = after ?? null;
202
+ if (b === a) return '';
203
+ const bs = b === null ? { lines: [], terminated: true } : splitLines(b);
204
+ const as = a === null ? { lines: [], terminated: true } : splitLines(a);
205
+ const ops = script(bs.lines, as.lines);
206
+ if (ops.every((o) => o.op === 'keep')) {
207
+ // Same lines, different terminator: the bytes still differ, so the last
208
+ // line rewrites rather than vanishing from the diff.
209
+ if (bs.terminated === as.terminated || ops.length === 0) return '';
210
+ const last = ops[ops.length - 1];
211
+ ops.splice(ops.length - 1, 1, { op: 'del', line: last.line }, { op: 'add', line: last.line });
212
+ }
213
+ const head = [
214
+ `diff --git a/${path} b/${path}`,
215
+ b === null ? '--- /dev/null' : `--- a/${path}`,
216
+ a === null ? '+++ /dev/null' : `+++ b/${path}`,
217
+ ];
218
+ const body = [];
219
+ for (const h of hunks(ops)) {
220
+ body.push(hunkHeader(h));
221
+ // Positions inside the hunk, to place the missing-newline markers exactly
222
+ // where GNU does: after the last line of a side that lacks its terminator.
223
+ let ai = h.aStart + (h.aLen === 0 ? 1 : 0);
224
+ let bi = h.bStart + (h.bLen === 0 ? 1 : 0);
225
+ for (const o of h.ops) {
226
+ if (o.op === 'keep') {
227
+ body.push(` ${o.line}`);
228
+ ai += 1;
229
+ bi += 1;
230
+ } else if (o.op === 'del') {
231
+ body.push(`-${o.line}`);
232
+ if (ai === bs.lines.length && !bs.terminated) body.push('\');
233
+ ai += 1;
234
+ } else {
235
+ body.push(`+${o.line}`);
236
+ if (bi === as.lines.length && !as.terminated) body.push('\');
237
+ bi += 1;
238
+ }
239
+ }
240
+ }
241
+ return [...head, ...body].join('\n');
242
+ }
243
+
244
+ /**
245
+ * Render a run's pairs, skipping the equal and the empty. Paths print in the
246
+ * order given — the caller owns the order, usually the write order — and
247
+ * blocks join with one blank line, the way a reader scans them.
248
+ */
249
+ export function renderDiffs(pairs) {
250
+ return pairs
251
+ .map(({ path, before, after }) => unifiedDiff(path, before ?? null, after ?? null))
252
+ .filter((d) => d !== '')
253
+ .join('\n\n');
254
+ }
@@ -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
+ }