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
@@ -19,12 +19,14 @@
19
19
  // its reason, so "passed" can never be mistaken for "only some of it passed".
20
20
 
21
21
  import { execFile } from 'node:child_process';
22
- import { readFile, stat } from 'node:fs/promises';
23
- import { basename, join, resolve, sep } from 'node:path';
22
+ import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
23
+ import { tmpdir } from 'node:os';
24
+ import { basename, join, matchesGlob, resolve, sep } from 'node:path';
24
25
  import { promisify } from 'node:util';
25
26
 
26
27
  import { GATES } from './gates.mjs';
27
28
  import { resolveGate } from './resolve.mjs';
29
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
28
30
  import { CLI } from '../name.mjs';
29
31
  import { MANIFEST_FILE } from '../name.mjs';
30
32
  import { assertVersionPin } from '../package-version.mjs';
@@ -36,14 +38,181 @@ const exec = promisify(execFile);
36
38
  /** Gates that are known but not required for the event to pass. */
37
39
  const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
38
40
 
41
+ /** The sha git writes for a ref that does not exist on the remote yet. */
42
+ const ZERO_SHA = '0'.repeat(40);
43
+
44
+ /**
45
+ * The lines git feeds a pre-push hook on stdin, one per ref being pushed:
46
+ * `<local ref> <local sha> <remote ref> <remote sha>`.
47
+ *
48
+ * Returns `{ refs, error }`. A line this parser cannot read is an ERROR, not a
49
+ * dropped line: a push that cannot be read is a push that cannot be classified,
50
+ * and the classifier's contract is to fail closed to the full suite.
51
+ *
52
+ * @param {string} text the raw stdin
53
+ * @returns {{refs: {ref: string, localSha: string, remoteSha: string}[] | null, error: string | null}}
54
+ */
55
+ export function parsePushedRefs(text) {
56
+ const refs = [];
57
+ for (const line of String(text ?? '').split('\n')) {
58
+ const trimmed = line.trim();
59
+ if (trimmed === '') continue;
60
+ const parts = trimmed.split(' ');
61
+ if (parts.length !== 4) return { refs: null, error: `unrecognized pre-push line: ${trimmed}` };
62
+ const [ref, localSha, , remoteSha] = parts;
63
+ if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha)) {
64
+ return { refs: null, error: `unrecognized sha in pre-push line: ${trimmed}` };
65
+ }
66
+ refs.push({ ref, localSha, remoteSha });
67
+ }
68
+ return { refs, error: null };
69
+ }
70
+
71
+ /**
72
+ * Confirm a tag's commit is already accounted for, so the tag may ride along in a
73
+ * docs-only push (ADR-113). A tag carries no commits of its own in the release flow:
74
+ * it points at commits delivered (and tested) by a branch push. It is acceptable only
75
+ * when the commit it names is reachable from a remote-tracking ref (already pushed) or
76
+ * from a branch tip this same push is delivering. Any uncertainty - the tag does not
77
+ * resolve to a commit, the remote refs cannot be listed, no candidate is known, or the
78
+ * commit is unreachable from all of them - fails closed to the full suite.
79
+ *
80
+ * @param {string} root the repository to ask git
81
+ * @param {string} tagSha the tag's local sha from the pre-push line
82
+ * @param {string[]} pushingTips branch tips this same push is delivering
83
+ * @returns {Promise<{ok: boolean, reason: string | null}>}
84
+ */
85
+ async function tagCommitAccountedFor(root, tagSha, pushingTips) {
86
+ let commit;
87
+ try {
88
+ ({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
89
+ } catch (error) {
90
+ return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
91
+ }
92
+ commit = commit.trim();
93
+
94
+ let remoteRefs;
95
+ try {
96
+ ({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
97
+ } catch (error) {
98
+ return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
99
+ }
100
+ const candidates = [
101
+ ...remoteRefs.split('\n').map((s) => s.trim()).filter((s) => s !== ''),
102
+ ...pushingTips,
103
+ ];
104
+ if (candidates.length === 0) {
105
+ return { ok: false, reason: 'no remote refs are known and no branch is being pushed, so the tag’s commit cannot be confirmed as already delivered' };
106
+ }
107
+ for (const candidate of candidates) {
108
+ try {
109
+ await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
110
+ return { ok: true, reason: null };
111
+ } catch {
112
+ // not reachable from this ref; try the next candidate
113
+ }
114
+ }
115
+ return { ok: false, reason: `the tag’s commit (${commit.slice(0, 7)}) is not reachable from any pushed ref; it may not have been tested` };
116
+ }
117
+
118
+ /**
119
+ * Decide whether a push carries only documentation, so the `test` gate may be
120
+ * skipped for it (ADR-113). Every uncertainty fails closed to `{ docsOnly: false }`
121
+ * with the reason the full suite runs: an empty ref list, a new branch (no remote
122
+ * sha to diff against), a tag whose commit is not confirmed already delivered, a
123
+ * commit count above the ceiling, a file outside the declared paths, or a git error.
124
+ *
125
+ * Branch refs are judged on a TREE diff (`remote..local`), so a force push that
126
+ * drops commits is judged on the trees it replaces, not on the commit count alone.
127
+ * Tag refs carry no commits of their own; each is accepted only when its commit is
128
+ * already accounted for (see `tagCommitAccountedFor`). Deletions push nothing.
129
+ *
130
+ * @param {string} root the repository to ask git
131
+ * @param {{ref: string, localSha: string, remoteSha: string}[]} refs
132
+ * @param {{maxCommits: number, paths: string[]}} config the manifest's `gates.prePushSkipTest`
133
+ * @returns {Promise<{docsOnly: boolean, reason: string}>}
134
+ */
135
+ export async function classifyPush(root, refs, { maxCommits, paths }) {
136
+ if (refs.length === 0) {
137
+ return { docsOnly: false, reason: 'the push carries no readable ref list; running the full suite' };
138
+ }
139
+ const deletions = refs.filter((r) => r.localSha === ZERO_SHA).length;
140
+ const branches = refs.filter((r) => r.localSha !== ZERO_SHA && !r.ref.startsWith('refs/tags/'));
141
+ const tags = refs.filter((r) => r.localSha !== ZERO_SHA && r.ref.startsWith('refs/tags/'));
142
+
143
+ let commits = 0;
144
+ const files = new Set();
145
+ for (const { ref, localSha, remoteSha } of branches) {
146
+ if (remoteSha === ZERO_SHA) {
147
+ return { docsOnly: false, reason: `a new branch is being pushed (${ref}); its history cannot be diffed against the remote` };
148
+ }
149
+ let count, names;
150
+ try {
151
+ ({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
152
+ ({ stdout: names } = await exec('git', ['diff', '--name-only', `${remoteSha}..${localSha}`], { cwd: root }));
153
+ } catch (error) {
154
+ return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
155
+ }
156
+ commits += Number(count.trim());
157
+ for (const f of names.split('\n')) {
158
+ const name = f.trim();
159
+ if (name !== '') files.add(name);
160
+ }
161
+ }
162
+
163
+ const pushingTips = branches.map((b) => b.localSha);
164
+ for (const { localSha } of tags) {
165
+ const verdict = await tagCommitAccountedFor(root, localSha, pushingTips);
166
+ if (!verdict.ok) return { docsOnly: false, reason: verdict.reason };
167
+ }
168
+
169
+ if (commits > maxCommits) {
170
+ return { docsOnly: false, reason: `${commits} commits exceed the ${maxCommits}-commit ceiling for a docs-only push` };
171
+ }
172
+ const outside = [...files].filter((f) => !paths.some((p) => matchesGlob(f, p)));
173
+ if (outside.length > 0) {
174
+ return { docsOnly: false, reason: `${outside.length} file(s) are outside the docs-only paths: ${outside.slice(0, 5).join(', ')}` };
175
+ }
176
+
177
+ const parts = [];
178
+ if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
179
+ if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
180
+ if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
181
+ return { docsOnly: true, reason: parts.length > 0 ? parts.join('; ') : 'no commits carried' };
182
+ }
183
+
184
+ /**
185
+ * The pre-push skip decision, from the manifest and the ref list git fed on stdin.
186
+ *
187
+ * The skip is OPT-IN: a repository without `gates.prePushSkipTest` runs the full
188
+ * suite on every push, because a control a repo did not declare is one it did not
189
+ * review. A misdeclaration (missing fields) also runs the suite - a skip the
190
+ * manifest cannot state precisely is a skip a reviewer cannot see.
191
+ *
192
+ * @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
193
+ */
194
+ async function resolveDocsOnlySkip(root, manifest, pushed) {
195
+ if (pushed === undefined) return null;
196
+ const config = manifest?.gates?.prePushSkipTest;
197
+ if (typeof config?.maxCommits !== 'number' || !Array.isArray(config?.paths)) return null;
198
+ const { refs, error } = parsePushedRefs(pushed);
199
+ if (error !== null) {
200
+ return null; // the caller cannot be sure what is being pushed; the suite runs
201
+ }
202
+ const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
203
+ return verdict.docsOnly ? { reason: verdict.reason } : null;
204
+ }
205
+
39
206
  /**
40
207
  * @param {string} event 'pre-commit' | 'pre-push'
41
208
  * @param {object} ctx
42
209
  * @param {string} ctx.root
43
210
  * @param {object} ctx.manifest parsed simt-dev.json, or null
211
+ * @param {string} [ctx.pushed] the ref list git feeds pre-push on stdin; undefined when no
212
+ * push is being classified (every other event, a manual run with no stdin)
44
213
  * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
45
214
  */
46
- export async function runGates(event, { root, manifest }) {
215
+ export async function runGates(event, { root, manifest, pushed }) {
47
216
  const key = DEFAULT_EVENTS[event];
48
217
  if (key === undefined) {
49
218
  return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
@@ -84,7 +253,89 @@ export async function runGates(event, { root, manifest }) {
84
253
  };
85
254
  }
86
255
 
256
+ // A pre-push gate runs against the COMMITTED tree, not the working one. Uncommitted
257
+ // work must not block the push of previous commits: a gate that fails on WIP the
258
+ // author has not committed is blocking a push it does not own. The worktree is
259
+ // disposable and shares the repository's object store, so `git worktree add` is cheap
260
+ // and needs no clone. The ledger and the version pin stay at the real root - the
261
+ // evidence is about this repository, not the throwaway checkout.
262
+ let runRoot = root;
263
+ let headWorktree = null;
264
+ let skipTest = null;
265
+ if (event === 'pre-push') {
266
+ // Decided before the worktree exists: a push that carries only documentation
267
+ // skips the `test` gate (ADR-113), and a push that earns the skip needs no
268
+ // tree for a gate that will not run.
269
+ skipTest = await resolveDocsOnlySkip(root, manifest, pushed);
270
+ if (skipTest === null) {
271
+ const materialized = await materializeHeadWorktree(root);
272
+ if (materialized.path === null) {
273
+ return {
274
+ event,
275
+ gates: [
276
+ {
277
+ name: 'head',
278
+ status: 'failed',
279
+ detail: materialized.error,
280
+ remedy: 'commit the work, or resolve the git state so HEAD is a valid reference, then push again',
281
+ adr: null,
282
+ },
283
+ ],
284
+ failed: true,
285
+ };
286
+ }
287
+ runRoot = materialized.path;
288
+ headWorktree = materialized.path;
289
+ }
290
+ }
291
+
292
+ try {
293
+ return await runGatesAt(event, { root: runRoot, ledgerRoot: root, manifest, declared, key, skipTest });
294
+ } finally {
295
+ if (headWorktree !== null) await removeHeadWorktree(root, headWorktree);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Run the declared gates at `root`, recording evidence at `ledgerRoot`.
301
+ *
302
+ * For a pre-push run the two differ: the gates execute in a throwaway worktree at
303
+ * HEAD so uncommitted work cannot block the push, while the ledger line belongs to
304
+ * the repository the author is standing in. For every other event they are the same
305
+ * path, which is why this is one function with two names for the root rather than
306
+ * two functions.
307
+ *
308
+ * @param {string} event
309
+ * @param {object} ctx
310
+ * @param {string} ctx.root the tree the gates execute in
311
+ * @param {string} ctx.ledgerRoot the repository the evidence is recorded in
312
+ * @param {object} ctx.manifest
313
+ * @param {string[]} ctx.declared the gate names for this event
314
+ * @param {string} ctx.key the manifest key for this event
315
+ * @param {{reason: string} | null} [ctx.skipTest] set when a docs-only push skips the
316
+ * `test` gate (ADR-113); the reason is what the report prints in its place
317
+ * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
318
+ */
319
+ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
87
320
  const pkg = await readPkg(root);
321
+ // Q31: the runner is committed evidence (lockfile, then `packageManager`),
322
+ // resolved once per hook run so every script gate spawns the same manager.
323
+ let runner = null;
324
+ if (pkg) {
325
+ const lockfiles = [];
326
+ for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
327
+ try {
328
+ await stat(join(root, file));
329
+ lockfiles.push(file);
330
+ } catch {
331
+ // Absent is the normal case.
332
+ }
333
+ }
334
+ runner = resolveNodeRunner({
335
+ lockfiles,
336
+ packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
337
+ }).id;
338
+ }
88
339
  const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
89
340
  const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
90
341
  const commands = manifest?.gates?.commands;
@@ -111,7 +362,15 @@ export async function runGates(event, { root, manifest }) {
111
362
  continue;
112
363
  }
113
364
 
114
- const resolution = await resolveGate(gate, { root, pkg, staged, commands });
365
+ // The one gate a docs-only push does not run (ADR-113). It is reported, not
366
+ // dropped: a skip without a printed reason would let "passed" mean "the suite
367
+ // never ran", which is the exact confusion the report exists to prevent.
368
+ if (skipTest !== null && name === 'test') {
369
+ results.push({ name, status: 'skipped', detail: `docs-only push: ${skipTest.reason}`, adr: 'ADR-113', exitCode: null });
370
+ continue;
371
+ }
372
+
373
+ const resolution = await resolveGate(gate, { root, pkg, staged, commands, runner });
115
374
  if (!resolution.ok) {
116
375
  results.push({
117
376
  name,
@@ -130,13 +389,17 @@ export async function runGates(event, { root, manifest }) {
130
389
  // declared command for this name takes the shell path below instead.
131
390
  if (resolution.builtin !== undefined) {
132
391
  const startedBuiltin = Date.now();
133
- const outcome = await runBuiltinGate(resolution.builtin, { root, staged });
392
+ // The manifest's per-builtin options, where a builtin has any: the secret
393
+ // scan takes its ignore globs from gates.secretScan (review 0003 Q24).
394
+ const outcome = await runBuiltinGate(resolution.builtin, { root, staged, options: manifest?.gates?.secretScan });
134
395
  try {
135
- await recordRun(root, {
396
+ await recordRun(ledgerRoot, {
136
397
  cmd: `builtin:${resolution.builtin}`,
137
398
  exit: outcome.ok ? 0 : 1,
138
399
  ms: Date.now() - startedBuiltin,
139
400
  output: outcome.output,
401
+ gate: name,
402
+ kind: 'builtin',
140
403
  });
141
404
  } catch {
142
405
  // Recorded by absence: the next evidence read for this tree has no line.
@@ -219,11 +482,18 @@ export async function runGates(event, { root, manifest }) {
219
482
  // claim check then finds nothing, which is the direction that cannot invent a run.
220
483
  try {
221
484
  const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
222
- await recordRun(root, {
485
+ // The evidence claim needs the gate and its kind: a passing secret-scan is
486
+ // not a passing test run (review 0003 Q18). Coverage proves tests; anything
487
+ // else keeps its own kind or the gate name.
488
+ const kind =
489
+ name === 'test' || name === 'coverage' ? 'test' : name === 'lint' ? 'lint' : name === 'format' ? 'format' : (gate.kind ?? name);
490
+ await recordRun(ledgerRoot, {
223
491
  cmd,
224
492
  exit: outcome.code,
225
493
  ms: Date.now() - started,
226
494
  output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
495
+ gate: name,
496
+ kind,
227
497
  });
228
498
  } catch {
229
499
  // Recorded by absence: the next evidence read for this tree has no line.
@@ -273,6 +543,54 @@ export async function runGates(event, { root, manifest }) {
273
543
  return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
274
544
  }
275
545
 
546
+ /**
547
+ * Materialize a detached worktree at HEAD so the pre-push gates run against the
548
+ * committed tree, not the working one. The worktree shares the repository's object
549
+ * store, so `git worktree add` is cheap and needs no clone. `node_modules` is
550
+ * symlinked in when the real root has one, because a script gate resolves its runner
551
+ * and binaries from there and a fresh worktree does not carry it.
552
+ *
553
+ * Fails closed: an unborn HEAD (no commits) or a git error returns `{ path: null }`
554
+ * with the reason, and the caller turns that into a gate failure rather than running
555
+ * against a tree it cannot name.
556
+ *
557
+ * @param {string} root the real repository root
558
+ * @returns {Promise<{path: string, error?: never}|{path: null, error: string}>}
559
+ */
560
+ async function materializeHeadWorktree(root) {
561
+ const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
562
+ try {
563
+ await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
564
+ } catch (error) {
565
+ await rm(path, { recursive: true, force: true }).catch(() => {});
566
+ return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
567
+ }
568
+ const nodeModules = join(root, 'node_modules');
569
+ try {
570
+ await symlink(nodeModules, join(path, 'node_modules'));
571
+ } catch {
572
+ // Absent node_modules is the normal case for a repo with no install step; a
573
+ // dangling link is harmless, and `npm run` does not need it to exist.
574
+ }
575
+ return { path };
576
+ }
577
+
578
+ /**
579
+ * Remove a pre-push worktree. `--force` because a gate may have left it dirty (a
580
+ * formatter, a test that writes fixtures); the worktree is disposable, the real tree
581
+ * is not. Falls back to `rm -rf` plus a prune when git cannot remove it (a lock, a
582
+ * path in a state git does not expect), so a cleanup failure never leaks the tree.
583
+ *
584
+ * @param {string} root the real repository root
585
+ * @param {string} path the worktree to remove
586
+ */
587
+ async function removeHeadWorktree(root, path) {
588
+ await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
589
+ await rm(path, { recursive: true, force: true }).catch(() => {});
590
+ await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
591
+ });
592
+ }
593
+
276
594
  /**
277
595
  * The lines that report one event's gate run: the event, what was declared, every
278
596
  * gate with its disposition, its reason and its remedy, and the verdict.
@@ -298,9 +616,9 @@ export function renderGateReport(event, declared, result) {
298
616
  return lines;
299
617
  }
300
618
 
301
- async function run(argv, cwd) {
619
+ async function run(argv, cwd, env) {
302
620
  try {
303
- const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
621
+ const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
304
622
  return { code: 0, stdout, stderr };
305
623
  } catch (error) {
306
624
  return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
@@ -320,7 +638,7 @@ const BUILTIN_REPORT_MAX = 12;
320
638
  * none of them can degrade into a pass, which is the same fail-closed contract
321
639
  * an unresolvable script gate has.
322
640
  */
323
- async function runBuiltinGate(name, { root, staged }) {
641
+ async function runBuiltinGate(name, { root, staged, options }) {
324
642
  const scan = BUILTIN_GATES.get(name);
325
643
  if (scan === undefined) {
326
644
  return {
@@ -333,7 +651,7 @@ async function runBuiltinGate(name, { root, staged }) {
333
651
  if (staged.length === 0) {
334
652
  return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
335
653
  }
336
- const result = await scan(root);
654
+ const result = await scan(root, options ?? {});
337
655
  if (result.error !== null) {
338
656
  return {
339
657
  ok: false,
@@ -376,9 +694,13 @@ async function runBuiltinGate(name, { root, staged }) {
376
694
  * rather than a flag on `run`.
377
695
  */
378
696
  async function runGate(resolution, cwd) {
697
+ // The push override is for the push itself, not for the gates the hook runs.
698
+ // A test that asserts a push is denied sees the override and fails.
699
+ const env = { ...process.env };
700
+ delete env.SIMT_ALLOW;
379
701
  if (resolution.shell === true && typeof resolution.command === 'string') {
380
702
  try {
381
- const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
703
+ const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
382
704
  return { code: 0, stdout, stderr };
383
705
  } catch (error) {
384
706
  return {
@@ -388,7 +710,7 @@ async function runGate(resolution, cwd) {
388
710
  };
389
711
  }
390
712
  }
391
- return run(resolution.argv, cwd);
713
+ return run(resolution.argv, cwd, env);
392
714
  }
393
715
 
394
716
  async function readPkg(root) {
@@ -68,7 +68,7 @@ export function hasScript(pkg, script) {
68
68
  * @returns {Promise<{ok: true, argv: string[], kind: string, detail: string}
69
69
  * | {ok: false, gate: string, kind: string, reason: string, remedy: string}>}
70
70
  */
71
- export async function resolveGate(gate, { root, pkg, staged, commands }) {
71
+ export async function resolveGate(gate, { root, pkg, staged, commands, runner = null }) {
72
72
  const name = gate.name;
73
73
  const declared = commands?.[name];
74
74
 
@@ -172,11 +172,17 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
172
172
  remedy: `add "${script}" to scripts, or remove "${name}" from ${MANIFEST_FILE} gates`,
173
173
  };
174
174
  }
175
+ // Q31: script gates run through the repo's own manager — `pnpm run`, not
176
+ // `npm run`, in a pnpm checkout. The default stays npm for callers that do
177
+ // not report one (older capabilities, hand-built contexts).
178
+ const { runScriptArgv, runScript } = await import('../ecosystem/node-runner.mjs');
179
+ const manager = runner ?? pkg?.packageManager?.split('@')?.[0] ?? 'npm';
180
+ const known = ['npm', 'pnpm', 'yarn', 'bun'].includes(manager) ? manager : 'npm';
175
181
  return {
176
182
  ok: true,
177
183
  kind: gate.kind,
178
- argv: ['npm', 'run', '--silent', script],
179
- detail: `npm run ${script}`,
184
+ argv: runScriptArgv(known, script),
185
+ detail: runScript(known, script),
180
186
  };
181
187
  }
182
188
 
@@ -48,14 +48,17 @@ export function fileFor(event) {
48
48
 
49
49
  /**
50
50
  * @param {string} event one of EVENTS
51
- * @param {{npm?: boolean, version?: string}} [opts]
51
+ * @param {{npm?: boolean, manager?: string|null, version?: string}} [opts]
52
52
  * `npm` is whether the repository has a package.json: it decides the remedy's install
53
53
  * (a devDependency where one exists, the release binary into the repository where
54
- * there is no node_modules to pin in). `version` defaults to the running package's own version,
55
- * so a shim generated by 0.2.0 names 0.2.0 in its remedy and CI install.
54
+ * there is no node_modules to pin in). `manager` names the Node manager when one
55
+ * was resolved (Q31: `pnpm add -D`, not `npm i -D`, in a pnpm checkout); it wins
56
+ * over the legacy `npm` boolean so old callers keep their bytes. `version`
57
+ * defaults to the running package's own version, so a shim generated by 0.2.0
58
+ * names 0.2.0 in its remedy and CI install.
56
59
  * @returns {string} POSIX sh source, LF-terminated, executable
57
60
  */
58
- export function renderShim(event, { npm = true, version } = {}) {
61
+ export function renderShim(event, { npm = true, manager = null, version } = {}) {
59
62
  if (!EVENTS.includes(event)) {
60
63
  throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
61
64
  }
@@ -65,7 +68,8 @@ export function renderShim(event, { npm = true, version } = {}) {
65
68
  // installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
66
69
  // so the remedy downloads the release binary into the repository - the same pinned
67
70
  // bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
68
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
71
+ const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
72
+ const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
69
73
  const D = '$';
70
74
  return `#!/bin/sh
71
75
  ${SHIM_MARKER}
@@ -103,11 +107,12 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
103
107
  * @param {{npm?: boolean, version?: string}} [opts]
104
108
  * @returns {string} POSIX sh source, LF-terminated
105
109
  */
106
- export function renderEvidenceShim({ npm = true, version } = {}) {
110
+ export function renderEvidenceShim({ npm = true, manager = null, version } = {}) {
107
111
  const v = version ?? packageVersion();
108
112
  // The same split as renderShim: a devDependency where one exists, the release
109
113
  // binary into the repository where there is no node_modules to pin in.
110
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
114
+ const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
115
+ const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
111
116
  const D = '$';
112
117
  return `#!/bin/sh
113
118
  ${SHIM_MARKER}
package/src/identity.mjs CHANGED
@@ -25,10 +25,12 @@
25
25
  // tracked file is not something an adopting command should do silently.
26
26
 
27
27
  import { execFile } from 'node:child_process';
28
- import { readFile, stat } from 'node:fs/promises';
28
+ import { readFile, stat, writeFile } from 'node:fs/promises';
29
29
  import { join } from 'node:path';
30
30
  import { promisify } from 'node:util';
31
31
 
32
+ import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
33
+
32
34
  const exec = promisify(execFile);
33
35
 
34
36
  /** The file, at the repository root. Named for what it is: a git config fragment. */
@@ -166,13 +168,67 @@ export async function includesOwnIdentity(root) {
166
168
  * break. Two of the repositories here had exactly that, and both worked only because
167
169
  * the sibling checkout happened to exist at the expected relative path.
168
170
  *
171
+ * A caller-supplied journal records the key so `undo` restores the routing (R49);
172
+ * without one the write stays unrecorded, as `commitJournal` needs it (review 0003
173
+ * Q30). The journaled path sets through `journaledGitConfig`, which records one
174
+ * key's before and after - the single-value case, and the only shape this tool
175
+ * writes.
176
+ *
169
177
  * @param {string} root
178
+ * @param {Array} [journal]
170
179
  * @returns {Promise<void>}
171
180
  */
172
- export async function includeOwnIdentity(root) {
181
+ export async function includeOwnIdentity(root, journal = null) {
182
+ // A directory that is not a git work tree has no config to route. The lenient
183
+ // `git()` used to swallow the failure here, and an adoption of a plain directory
184
+ // must keep working: there is simply nothing to point at.
185
+ if ((await git(root, ['rev-parse', '--is-inside-work-tree'])) !== 'true') return;
186
+ if (journal) {
187
+ await journaledGitConfig(root, journal, INCLUDE_KEY, `../${IDENTITY_FILE}`);
188
+ return;
189
+ }
173
190
  await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
174
191
  }
175
192
 
193
+ /**
194
+ * Whether .gitignore already names the identity file. The plan needs the answer
195
+ * before anything is written: a line the run does not print is a write the reader
196
+ * approves from the diff afterwards (review 0003 Q30).
197
+ *
198
+ * @param {string} root
199
+ * @returns {Promise<boolean>}
200
+ */
201
+ export async function identityIgnored(root) {
202
+ let text;
203
+ try {
204
+ text = await readFile(join(root, '.gitignore'), 'utf8');
205
+ } catch {
206
+ return false;
207
+ }
208
+ return text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
209
+ }
210
+
211
+ /**
212
+ * The .gitignore bytes with the identity entry appended, or null when it is
213
+ * already there. One owner for the edit's shape: `ignoreIdentity` writes it and
214
+ * `init --diff` previews it, so the two cannot disagree about the bytes (review
215
+ * 0003 Q30).
216
+ *
217
+ * @param {string} text the current bytes, '' when the file is absent
218
+ * @returns {string|null}
219
+ */
220
+ export function gitignoreWithIdentity(text) {
221
+ if (text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE)) return null;
222
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
223
+ return (
224
+ `${text}${prefix}\n` +
225
+ '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
226
+ '# it carries whoever is committing into the tree, and a repository has more than\n' +
227
+ '# one of those.\n' +
228
+ `${IDENTITY_FILE}\n`
229
+ );
230
+ }
231
+
176
232
  /**
177
233
  * Add the identity file to .gitignore, if it is not already there.
178
234
  *
@@ -181,10 +237,14 @@ export async function includeOwnIdentity(root) {
181
237
  * it means nothing, and appending a duplicate of an entry that is already present
182
238
  * is how a file ends up listed twice.
183
239
  *
240
+ * A caller-supplied journal records the edit so `undo` restores it (R49); without
241
+ * one the write stays unrecorded, as `commitJournal` needs it (review 0003 Q30).
242
+ *
184
243
  * @param {string} root
244
+ * @param {Array} [journal]
185
245
  * @returns {Promise<boolean>} whether the file was changed
186
246
  */
187
- export async function ignoreIdentity(root) {
247
+ export async function ignoreIdentity(root, journal = null) {
188
248
  const path = join(root, '.gitignore');
189
249
  let text;
190
250
  try {
@@ -192,16 +252,9 @@ export async function ignoreIdentity(root) {
192
252
  } catch {
193
253
  text = '';
194
254
  }
195
- const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
196
- if (already) return false;
197
- const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
198
- const block =
199
- `${prefix}\n` +
200
- '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
201
- '# it carries whoever is committing into the tree, and a repository has more than\n' +
202
- '# one of those.\n' +
203
- `${IDENTITY_FILE}\n`;
204
- const { writeFile } = await import('node:fs/promises');
205
- await writeFile(path, text + block);
255
+ const next = gitignoreWithIdentity(text);
256
+ if (next === null) return false;
257
+ if (journal) await journaledWriteFile(root, journal, path, next);
258
+ else await writeFile(path, next);
206
259
  return true;
207
260
  }