simt-dev 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -18,32 +18,353 @@
18
18
  // Report what did not run. The summary names every gate, its disposition and
19
19
  // its reason, so "passed" can never be mistaken for "only some of it passed".
20
20
 
21
- import { execFile } from 'node:child_process';
22
- import { readFile, stat } from 'node:fs/promises';
23
- import { basename, join, resolve, sep } from 'node:path';
21
+ import { execFile, spawn } from 'node:child_process';
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';
31
- import { recordRun } from '../ledger/index.mjs';
33
+ import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
32
34
  import { renderFindings, scanStaged } from '../secrets.mjs';
33
35
 
34
36
  const exec = promisify(execFile);
35
37
 
38
+ /**
39
+ * The default bound on one gate, in seconds, and the grace a killed gate gets before
40
+ * SIGKILL. A hook that runs on every commit needs a ceiling: a test runner waiting on
41
+ * a socket that never closes blocks the commit with no output and no way to tell a
42
+ * hang from slow work. `gates.timeout` raises or removes it per repository.
43
+ */
44
+ const DEFAULT_GATE_TIMEOUT_SECONDS = 600;
45
+ const KILL_GRACE_MS = 5000;
46
+
47
+ /**
48
+ * How much of a gate's output a report can carry. This is a bound on what is KEPT, not
49
+ * on what the process may write: a gate whose output exceeded execFile's buffer used to
50
+ * be reported `failed` although it exited 0.
51
+ */
52
+ const GATE_OUTPUT_TAIL_BYTES = 64 * 1024;
53
+
54
+ function tailCollector() {
55
+ let kept = Buffer.alloc(0);
56
+ let dropped = 0;
57
+ return {
58
+ push(chunk) {
59
+ kept = Buffer.concat([kept, chunk]);
60
+ if (kept.length > GATE_OUTPUT_TAIL_BYTES) {
61
+ dropped += kept.length - GATE_OUTPUT_TAIL_BYTES;
62
+ kept = kept.subarray(kept.length - GATE_OUTPUT_TAIL_BYTES);
63
+ }
64
+ },
65
+ text: () => kept.toString('utf8'),
66
+ dropped: () => dropped,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Signal the gate's whole process group. A test runner spawns workers of its own, and
72
+ * signalling only the leader leaves them holding the pipes this function is waiting on.
73
+ * `detached` in the spawn below is what makes the group exist.
74
+ */
75
+ function signalTree(child, signal) {
76
+ try {
77
+ process.kill(-child.pid, signal);
78
+ } catch {
79
+ try {
80
+ child.kill(signal);
81
+ } catch {
82
+ // Already gone: a second signal has nothing to reach.
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Run one gate to completion, keeping its exit code and the tail of what it printed.
89
+ *
90
+ * `timeoutMs` of 0 means no bound, which is what `gates.timeout: 0` asks for and what
91
+ * the manifest does not have to say. Output is collected in full while the process
92
+ * runs and only the tail is retained, so a noisy passing gate passes.
93
+ */
94
+ async function runBounded(argv, { cwd, env, timeoutMs }) {
95
+ return await new Promise((settle) => {
96
+ let child;
97
+ try {
98
+ child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
99
+ } catch (error) {
100
+ settle({ code: 1, stdout: '', stderr: String(error.message ?? error), timedOut: false, dropped: 0 });
101
+ return;
102
+ }
103
+ const out = tailCollector();
104
+ const err = tailCollector();
105
+ let timedOut = false;
106
+ let timer = null;
107
+ let killer = null;
108
+ let done = false;
109
+ const finish = (code) => {
110
+ if (done) return;
111
+ done = true;
112
+ clearTimeout(timer);
113
+ clearTimeout(killer);
114
+ settle({ code, stdout: out.text(), stderr: err.text(), timedOut, dropped: out.dropped() + err.dropped() });
115
+ };
116
+ child.stdout?.on('data', (chunk) => out.push(chunk));
117
+ child.stderr?.on('data', (chunk) => err.push(chunk));
118
+ // A missing binary emits `error` and no `close`; the message is the only evidence.
119
+ child.on('error', (error) => {
120
+ err.push(Buffer.from(String(error.message ?? error)));
121
+ finish(1);
122
+ });
123
+ child.on('close', (code, signal) => finish(typeof code === 'number' ? code : signal === null ? 0 : 1));
124
+ if (timeoutMs > 0) {
125
+ timer = setTimeout(() => {
126
+ timedOut = true;
127
+ signalTree(child, 'SIGTERM');
128
+ killer = setTimeout(() => signalTree(child, 'SIGKILL'), KILL_GRACE_MS);
129
+ }, timeoutMs);
130
+ }
131
+ });
132
+ }
133
+
134
+ /**
135
+ * The bound for one gate, in milliseconds. A number applies to every gate; a map applies
136
+ * per name, the shape `gates.cwd` already has. A value that is neither a positive whole
137
+ * number of seconds nor 0 falls back to the default rather than removing the bound: an
138
+ * unreadable declaration must not be the quiet way to disable the ceiling.
139
+ */
140
+ function gateTimeoutMs(declared, name) {
141
+ const value = declared !== null && typeof declared === 'object' ? declared[name] : declared;
142
+ if (value === 0) return 0;
143
+ if (Number.isInteger(value) && value > 0) return value * 1000;
144
+ return DEFAULT_GATE_TIMEOUT_SECONDS * 1000;
145
+ }
146
+
36
147
  /** Gates that are known but not required for the event to pass. */
37
148
  const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
38
149
 
150
+ /** The sha git writes for a ref that does not exist on the remote yet. */
151
+ const ZERO_SHA = '0'.repeat(40);
152
+
153
+ /**
154
+ * The lines git feeds a pre-push hook on stdin, one per ref being pushed:
155
+ * `<local ref> <local sha> <remote ref> <remote sha>`.
156
+ *
157
+ * Returns `{ refs, error }`. A line this parser cannot read is an ERROR, not a
158
+ * dropped line: a push that cannot be read is a push that cannot be classified,
159
+ * and the classifier's contract is to fail closed to the full suite.
160
+ *
161
+ * @param {string} text the raw stdin
162
+ * @returns {{refs: {ref: string, localSha: string, remoteSha: string}[] | null, error: string | null}}
163
+ */
164
+ export function parsePushedRefs(text) {
165
+ const refs = [];
166
+ for (const line of String(text ?? '').split('\n')) {
167
+ const trimmed = line.trim();
168
+ if (trimmed === '') continue;
169
+ const parts = trimmed.split(' ');
170
+ if (parts.length !== 4) return { refs: null, error: `unrecognized pre-push line: ${trimmed}` };
171
+ const [ref, localSha, , remoteSha] = parts;
172
+ if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha)) {
173
+ return { refs: null, error: `unrecognized sha in pre-push line: ${trimmed}` };
174
+ }
175
+ refs.push({ ref, localSha, remoteSha });
176
+ }
177
+ return { refs, error: null };
178
+ }
179
+
180
+ /**
181
+ * Confirm a tag's commit is already accounted for, so the tag may ride along in a
182
+ * docs-only push (ADR-113). A tag carries no commits of its own in the release flow:
183
+ * it points at commits delivered (and tested) by a branch push. It is acceptable only
184
+ * when the commit it names is reachable from a remote-tracking ref (already pushed) or
185
+ * from a branch tip this same push is delivering. Any uncertainty - the tag does not
186
+ * resolve to a commit, the remote refs cannot be listed, no candidate is known, or the
187
+ * commit is unreachable from all of them - fails closed to the full suite.
188
+ *
189
+ * @param {string} root the repository to ask git
190
+ * @param {string} tagSha the tag's local sha from the pre-push line
191
+ * @param {string[]} pushingTips branch tips this same push is delivering
192
+ * @returns {Promise<{ok: boolean, reason: string | null}>}
193
+ */
194
+ async function tagCommitAccountedFor(root, tagSha, pushingTips) {
195
+ let commit;
196
+ try {
197
+ ({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
198
+ } catch (error) {
199
+ return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
200
+ }
201
+ commit = commit.trim();
202
+
203
+ let remoteRefs;
204
+ try {
205
+ ({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
206
+ } catch (error) {
207
+ return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
208
+ }
209
+ const candidates = [
210
+ ...remoteRefs.split('\n').map((s) => s.trim()).filter((s) => s !== ''),
211
+ ...pushingTips,
212
+ ];
213
+ if (candidates.length === 0) {
214
+ 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' };
215
+ }
216
+ for (const candidate of candidates) {
217
+ try {
218
+ await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
219
+ return { ok: true, reason: null };
220
+ } catch {
221
+ // not reachable from this ref; try the next candidate
222
+ }
223
+ }
224
+ 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` };
225
+ }
226
+
227
+ /**
228
+ * Decide whether a push carries only documentation, so the `test` gate may be
229
+ * skipped for it (ADR-113). Every uncertainty fails closed to `{ docsOnly: false }`
230
+ * with the reason the full suite runs: an empty ref list, a new branch (no remote
231
+ * sha to diff against), a tag whose commit is not confirmed already delivered, a
232
+ * commit count above the ceiling, a file outside the declared paths, or a git error.
233
+ *
234
+ * Branch refs are judged on a TREE diff (`remote..local`), so a force push that
235
+ * drops commits is judged on the trees it replaces, not on the commit count alone.
236
+ * Tag refs carry no commits of their own; each is accepted only when its commit is
237
+ * already accounted for (see `tagCommitAccountedFor`). Deletions push nothing.
238
+ *
239
+ * @param {string} root the repository to ask git
240
+ * @param {{ref: string, localSha: string, remoteSha: string}[]} refs
241
+ * @param {{maxCommits: number, paths: string[]}} config the manifest's `gates.prePushSkipTest`
242
+ * @returns {Promise<{docsOnly: boolean, reason: string}>}
243
+ */
244
+ export async function classifyPush(root, refs, { maxCommits, paths }) {
245
+ if (refs.length === 0) {
246
+ return { docsOnly: false, reason: 'the push carries no readable ref list; running the full suite' };
247
+ }
248
+ const deletions = refs.filter((r) => r.localSha === ZERO_SHA).length;
249
+ const branches = refs.filter((r) => r.localSha !== ZERO_SHA && !r.ref.startsWith('refs/tags/'));
250
+ const tags = refs.filter((r) => r.localSha !== ZERO_SHA && r.ref.startsWith('refs/tags/'));
251
+
252
+ let commits = 0;
253
+ const files = new Set();
254
+ for (const { ref, localSha, remoteSha } of branches) {
255
+ if (remoteSha === ZERO_SHA) {
256
+ return { docsOnly: false, reason: `a new branch is being pushed (${ref}); its history cannot be diffed against the remote` };
257
+ }
258
+ let count, names;
259
+ try {
260
+ ({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
261
+ ({ stdout: names } = await exec('git', ['diff', '--name-only', '-z', `${remoteSha}..${localSha}`], { cwd: root }));
262
+ } catch (error) {
263
+ return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
264
+ }
265
+ commits += Number(count.trim());
266
+ // `-z` and no trimming: a path is NUL-terminated because a path can contain a
267
+ // newline, and git quotes such a name in the line-terminated form, so splitting on
268
+ // newlines compares a quoted path against an unquoted glob and never matches.
269
+ for (const name of names.split('\0')) {
270
+ if (name !== '') files.add(name);
271
+ }
272
+ }
273
+
274
+ const pushingTips = branches.map((b) => b.localSha);
275
+ for (const { localSha } of tags) {
276
+ const verdict = await tagCommitAccountedFor(root, localSha, pushingTips);
277
+ if (!verdict.ok) return { docsOnly: false, reason: verdict.reason };
278
+ }
279
+
280
+ if (commits > maxCommits) {
281
+ return { docsOnly: false, reason: `${commits} commits exceed the ${maxCommits}-commit ceiling for a docs-only push` };
282
+ }
283
+ const outside = [...files].filter((f) => !paths.some((p) => matchesGlob(f, p)));
284
+ if (outside.length > 0) {
285
+ return { docsOnly: false, reason: `${outside.length} file(s) are outside the docs-only paths: ${outside.slice(0, 5).join(', ')}` };
286
+ }
287
+
288
+ const parts = [];
289
+ if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
290
+ if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
291
+ if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
292
+ if (parts.length === 0) return { docsOnly: true, reason: 'no commits carried' };
293
+ // The report prints this as-is (ADR-113). The label names what is being pushed:
294
+ // a push that carries only tags is a tag push, not a docs-only push.
295
+ const kind = commits > 0 ? 'docs-only push' : tags.length > 0 ? 'tag push' : 'deletion push';
296
+ return { docsOnly: true, reason: `${kind}: ${parts.join('; ')}` };
297
+ }
298
+
299
+ /**
300
+ * The pre-push skip decision, from the manifest and the ref list git fed on stdin.
301
+ *
302
+ * The skip is OPT-IN: a repository without `gates.prePushSkipTest` runs the full
303
+ * suite on every push, because a control a repo did not declare is one it did not
304
+ * review. A misdeclaration (missing fields) also runs the suite - a skip the
305
+ * manifest cannot state precisely is a skip a reviewer cannot see.
306
+ *
307
+ * @returns {Promise<{reason: string, adr: string} | null>} null runs the suite; a reason skips the `test` gate
308
+ */
309
+ async function resolveDocsOnlySkip(root, manifest, pushed) {
310
+ if (pushed === undefined) return null;
311
+ const config = manifest?.gates?.prePushSkipTest;
312
+ if (typeof config?.maxCommits !== 'number' || !Array.isArray(config?.paths)) return null;
313
+ const { refs, error } = parsePushedRefs(pushed);
314
+ if (error !== null) {
315
+ return null; // the caller cannot be sure what is being pushed; the suite runs
316
+ }
317
+ const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
318
+ return verdict.docsOnly ? { reason: verdict.reason, adr: 'ADR-113' } : null;
319
+ }
320
+
321
+ /**
322
+ * The pre-push skip for a suite the author already ran on this exact tree (ADR-120).
323
+ *
324
+ * The dispatcher records every gate run against the tree it ran on (ADR-087). If a passing
325
+ * `test` run is recorded for the committed tree about to be pushed, and nothing has changed
326
+ * since, re-running the full suite at push time buys nothing: it is the same tree, already
327
+ * proven. This reads that evidence and earns the same reported skip the docs-only path does.
328
+ *
329
+ * It matches the COMMITTED tree (`HEAD^{tree}`), not the working tree: the pre-push gate runs
330
+ * at HEAD, so the evidence that matters is a pass on exactly that tree. A file edited after
331
+ * the green pass changes the tree id, so the match fails and the suite runs - the "no changes
332
+ * since" condition is enforced by the digest, not by trusting the author. Every uncertainty -
333
+ * an unborn HEAD, an unreadable ledger, no entry for this tree, a later failure - fails closed
334
+ * to the suite.
335
+ *
336
+ * @param {string} root the real repository root
337
+ * @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
338
+ */
339
+ async function resolveAlreadyPassedSkip(root) {
340
+ let headTree;
341
+ try {
342
+ ({ stdout: headTree } = await exec('git', ['rev-parse', 'HEAD^{tree}'], { cwd: root }));
343
+ } catch {
344
+ return null;
345
+ }
346
+ headTree = headTree.trim();
347
+ if (!/^[0-9a-f]{40}$/.test(headTree)) return null;
348
+ const { entries, errors } = await readLedger(root);
349
+ if (errors.length > 0) return null;
350
+ if (!hasTestEvidence(entries, headTree)) return null;
351
+ return {
352
+ reason:
353
+ 'already passing on current state: the test gate ran green on this tree and nothing has changed since',
354
+ adr: 'ADR-120',
355
+ };
356
+ }
357
+
39
358
  /**
40
359
  * @param {string} event 'pre-commit' | 'pre-push'
41
360
  * @param {object} ctx
42
361
  * @param {string} ctx.root
43
362
  * @param {object} ctx.manifest parsed simt-dev.json, or null
363
+ * @param {string} [ctx.pushed] the ref list git feeds pre-push on stdin; undefined when no
364
+ * push is being classified (every other event, a manual run with no stdin)
44
365
  * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
45
366
  */
46
- export async function runGates(event, { root, manifest }) {
367
+ export async function runGates(event, { root, manifest, pushed }) {
47
368
  const key = DEFAULT_EVENTS[event];
48
369
  if (key === undefined) {
49
370
  return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
@@ -84,7 +405,92 @@ export async function runGates(event, { root, manifest }) {
84
405
  };
85
406
  }
86
407
 
408
+ // A pre-push gate runs against the COMMITTED tree, not the working one. Uncommitted
409
+ // work must not block the push of previous commits: a gate that fails on WIP the
410
+ // author has not committed is blocking a push it does not own. The worktree is
411
+ // disposable and shares the repository's object store, so `git worktree add` is cheap
412
+ // and needs no clone. The ledger and the version pin stay at the real root - the
413
+ // evidence is about this repository, not the throwaway checkout.
414
+ let runRoot = root;
415
+ let headWorktree = null;
416
+ let skipTest = null;
417
+ if (event === 'pre-push') {
418
+ // Decided before the worktree exists: a push that carries only documentation skips the
419
+ // `test` gate (ADR-113), and a push whose tree the author already proved green skips it
420
+ // on that evidence (ADR-120). A push that earns either skip needs no tree for a gate
421
+ // that will not run.
422
+ skipTest = (await resolveDocsOnlySkip(root, manifest, pushed)) ??
423
+ (await resolveAlreadyPassedSkip(root));
424
+ if (skipTest === null) {
425
+ const materialized = await materializeHeadWorktree(root);
426
+ if (materialized.path === null) {
427
+ return {
428
+ event,
429
+ gates: [
430
+ {
431
+ name: 'head',
432
+ status: 'failed',
433
+ detail: materialized.error,
434
+ remedy: 'commit the work, or resolve the git state so HEAD is a valid reference, then push again',
435
+ adr: null,
436
+ },
437
+ ],
438
+ failed: true,
439
+ };
440
+ }
441
+ runRoot = materialized.path;
442
+ headWorktree = materialized.path;
443
+ }
444
+ }
445
+
446
+ try {
447
+ return await runGatesAt(event, { root: runRoot, ledgerRoot: root, manifest, declared, key, skipTest });
448
+ } finally {
449
+ if (headWorktree !== null) await removeHeadWorktree(root, headWorktree);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Run the declared gates at `root`, recording evidence at `ledgerRoot`.
455
+ *
456
+ * For a pre-push run the two differ: the gates execute in a throwaway worktree at
457
+ * HEAD so uncommitted work cannot block the push, while the ledger line belongs to
458
+ * the repository the author is standing in. For every other event they are the same
459
+ * path, which is why this is one function with two names for the root rather than
460
+ * two functions.
461
+ *
462
+ * @param {string} event
463
+ * @param {object} ctx
464
+ * @param {string} ctx.root the tree the gates execute in
465
+ * @param {string} ctx.ledgerRoot the repository the evidence is recorded in
466
+ * @param {object} ctx.manifest
467
+ * @param {string[]} ctx.declared the gate names for this event
468
+ * @param {string} ctx.key the manifest key for this event
469
+ * @param {{reason: string, adr: string} | null} [ctx.skipTest] set when a push skips the
470
+ * `test` gate (ADR-113 for docs-only, ADR-120 for an already-green tree); the reason is
471
+ * what the report prints in its place, and the adr is the record that earned it
472
+ * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
473
+ */
474
+ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
87
475
  const pkg = await readPkg(root);
476
+ // Q31: the runner is committed evidence (lockfile, then `packageManager`),
477
+ // resolved once per hook run so every script gate spawns the same manager.
478
+ let runner = null;
479
+ if (pkg) {
480
+ const lockfiles = [];
481
+ for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
482
+ try {
483
+ await stat(join(root, file));
484
+ lockfiles.push(file);
485
+ } catch {
486
+ // Absent is the normal case.
487
+ }
488
+ }
489
+ runner = resolveNodeRunner({
490
+ lockfiles,
491
+ packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
492
+ }).id;
493
+ }
88
494
  const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
89
495
  const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
90
496
  const commands = manifest?.gates?.commands;
@@ -94,6 +500,9 @@ export async function runGates(event, { root, manifest }) {
94
500
  // mean re-reading the command string to find out where anything runs, and a manifest
95
501
  // written by an older release would have to be rewritten to gain the capability.
96
502
  const cwds = manifest?.gates?.cwd ?? {};
503
+ // `gates.timeout` bounds one gate, in seconds: a number for every gate, or a map by
504
+ // name like `gates.cwd`. Absent means the default; 0 means no bound, declared.
505
+ const timeouts = manifest?.gates?.timeout;
97
506
  const results = [];
98
507
  // What the format gate rewrote, so restage touches those files and nothing else.
99
508
  let formatted = [];
@@ -111,7 +520,16 @@ export async function runGates(event, { root, manifest }) {
111
520
  continue;
112
521
  }
113
522
 
114
- const resolution = await resolveGate(gate, { root, pkg, staged, commands });
523
+ // The one gate a push may not run (ADR-113 for a docs-only push, ADR-120 for a tree
524
+ // already proven green). It is reported, not dropped: a skip without a printed reason
525
+ // would let "passed" mean "the suite never ran", which is the exact confusion the report
526
+ // exists to prevent.
527
+ if (skipTest !== null && name === 'test') {
528
+ results.push({ name, status: 'skipped', detail: skipTest.reason, adr: skipTest.adr, exitCode: null });
529
+ continue;
530
+ }
531
+
532
+ const resolution = await resolveGate(gate, { root, pkg, staged, commands, runner });
115
533
  if (!resolution.ok) {
116
534
  results.push({
117
535
  name,
@@ -130,13 +548,17 @@ export async function runGates(event, { root, manifest }) {
130
548
  // declared command for this name takes the shell path below instead.
131
549
  if (resolution.builtin !== undefined) {
132
550
  const startedBuiltin = Date.now();
133
- const outcome = await runBuiltinGate(resolution.builtin, { root, staged });
551
+ // The manifest's per-builtin options, where a builtin has any: the secret
552
+ // scan takes its ignore globs from gates.secretScan (review 0003 Q24).
553
+ const outcome = await runBuiltinGate(resolution.builtin, { root, staged, options: manifest?.gates?.secretScan });
134
554
  try {
135
- await recordRun(root, {
555
+ await recordRun(ledgerRoot, {
136
556
  cmd: `builtin:${resolution.builtin}`,
137
557
  exit: outcome.ok ? 0 : 1,
138
558
  ms: Date.now() - startedBuiltin,
139
559
  output: outcome.output,
560
+ gate: name,
561
+ kind: 'builtin',
140
562
  });
141
563
  } catch {
142
564
  // Recorded by absence: the next evidence read for this tree has no line.
@@ -213,26 +635,41 @@ export async function runGates(event, { root, manifest }) {
213
635
  cwd = resolved;
214
636
  }
215
637
 
638
+ const timeoutMs = gateTimeoutMs(timeouts, name);
216
639
  const started = Date.now();
217
- const outcome = await runGate(resolution, cwd);
640
+ const outcome = await runGate(resolution, cwd, timeoutMs);
218
641
  // The ledger is not the gate. A tree that cannot be named leaves no line, and the
219
642
  // claim check then finds nothing, which is the direction that cannot invent a run.
220
643
  try {
221
644
  const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
222
- await recordRun(root, {
645
+ // The evidence claim needs the gate and its kind: a passing secret-scan is
646
+ // not a passing test run (review 0003 Q18). Coverage proves tests; anything
647
+ // else keeps its own kind or the gate name.
648
+ const kind =
649
+ name === 'test' || name === 'coverage' ? 'test' : name === 'lint' ? 'lint' : name === 'format' ? 'format' : (gate.kind ?? name);
650
+ await recordRun(ledgerRoot, {
223
651
  cmd,
224
652
  exit: outcome.code,
225
653
  ms: Date.now() - started,
226
654
  output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
655
+ gate: name,
656
+ kind,
227
657
  });
228
658
  } catch {
229
659
  // Recorded by absence: the next evidence read for this tree has no line.
230
660
  }
661
+ // A killed gate has no exit code of its own (`signal` instead), so the reason is
662
+ // stated rather than left to `exit 1`, and the remedy says how to change the bound.
231
663
  results.push({
232
664
  name,
233
665
  status: outcome.code === 0 ? 'passed' : 'failed',
234
- detail: `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
666
+ detail: outcome.timedOut
667
+ ? `${resolution.detail}: timed out after ${Math.round(timeoutMs / 1000)} s`
668
+ : `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
235
669
  output: outcome.code === 0 ? '' : tail(outcome.stderr || outcome.stdout),
670
+ ...(outcome.timedOut
671
+ ? { remedy: `fix the hang, raise gates.timeout to give it longer, or set gates.timeout to 0 to remove the bound` }
672
+ : {}),
236
673
  adr: gate.adr,
237
674
  exitCode: outcome.code,
238
675
  });
@@ -273,6 +710,54 @@ export async function runGates(event, { root, manifest }) {
273
710
  return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
274
711
  }
275
712
 
713
+ /**
714
+ * Materialize a detached worktree at HEAD so the pre-push gates run against the
715
+ * committed tree, not the working one. The worktree shares the repository's object
716
+ * store, so `git worktree add` is cheap and needs no clone. `node_modules` is
717
+ * symlinked in when the real root has one, because a script gate resolves its runner
718
+ * and binaries from there and a fresh worktree does not carry it.
719
+ *
720
+ * Fails closed: an unborn HEAD (no commits) or a git error returns `{ path: null }`
721
+ * with the reason, and the caller turns that into a gate failure rather than running
722
+ * against a tree it cannot name.
723
+ *
724
+ * @param {string} root the real repository root
725
+ * @returns {Promise<{path: string, error?: never}|{path: null, error: string}>}
726
+ */
727
+ async function materializeHeadWorktree(root) {
728
+ const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
729
+ try {
730
+ await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
731
+ } catch (error) {
732
+ await rm(path, { recursive: true, force: true }).catch(() => {});
733
+ return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
734
+ }
735
+ const nodeModules = join(root, 'node_modules');
736
+ try {
737
+ await symlink(nodeModules, join(path, 'node_modules'));
738
+ } catch {
739
+ // Absent node_modules is the normal case for a repo with no install step; a
740
+ // dangling link is harmless, and `npm run` does not need it to exist.
741
+ }
742
+ return { path };
743
+ }
744
+
745
+ /**
746
+ * Remove a pre-push worktree. `--force` because a gate may have left it dirty (a
747
+ * formatter, a test that writes fixtures); the worktree is disposable, the real tree
748
+ * is not. Falls back to `rm -rf` plus a prune when git cannot remove it (a lock, a
749
+ * path in a state git does not expect), so a cleanup failure never leaks the tree.
750
+ *
751
+ * @param {string} root the real repository root
752
+ * @param {string} path the worktree to remove
753
+ */
754
+ async function removeHeadWorktree(root, path) {
755
+ await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
756
+ await rm(path, { recursive: true, force: true }).catch(() => {});
757
+ await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
758
+ });
759
+ }
760
+
276
761
  /**
277
762
  * The lines that report one event's gate run: the event, what was declared, every
278
763
  * gate with its disposition, its reason and its remedy, and the verdict.
@@ -298,13 +783,8 @@ export function renderGateReport(event, declared, result) {
298
783
  return lines;
299
784
  }
300
785
 
301
- async function run(argv, cwd) {
302
- try {
303
- const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
304
- return { code: 0, stdout, stderr };
305
- } catch (error) {
306
- return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
307
- }
786
+ async function run(argv, cwd, env, timeoutMs) {
787
+ return await runBounded(argv, { cwd, env, timeoutMs });
308
788
  }
309
789
 
310
790
  /** The built-in gates this dispatcher can run, by name. */
@@ -320,7 +800,7 @@ const BUILTIN_REPORT_MAX = 12;
320
800
  * none of them can degrade into a pass, which is the same fail-closed contract
321
801
  * an unresolvable script gate has.
322
802
  */
323
- async function runBuiltinGate(name, { root, staged }) {
803
+ async function runBuiltinGate(name, { root, staged, options }) {
324
804
  const scan = BUILTIN_GATES.get(name);
325
805
  if (scan === undefined) {
326
806
  return {
@@ -333,7 +813,7 @@ async function runBuiltinGate(name, { root, staged }) {
333
813
  if (staged.length === 0) {
334
814
  return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
335
815
  }
336
- const result = await scan(root);
816
+ const result = await scan(root, options ?? {});
337
817
  if (result.error !== null) {
338
818
  return {
339
819
  ok: false,
@@ -375,20 +855,15 @@ async function runBuiltinGate(name, { root, staged }) {
375
855
  * that the two sources stay distinguishable, which is why this is a separate branch
376
856
  * rather than a flag on `run`.
377
857
  */
378
- async function runGate(resolution, cwd) {
858
+ async function runGate(resolution, cwd, timeoutMs) {
859
+ // The push override is for the push itself, not for the gates the hook runs.
860
+ // A test that asserts a push is denied sees the override and fails.
861
+ const env = { ...process.env };
862
+ delete env.SIMT_ALLOW;
379
863
  if (resolution.shell === true && typeof resolution.command === 'string') {
380
- try {
381
- const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
382
- return { code: 0, stdout, stderr };
383
- } catch (error) {
384
- return {
385
- code: typeof error.code === 'number' ? error.code : 1,
386
- stdout: error.stdout ?? '',
387
- stderr: error.stderr ?? String(error.message ?? error),
388
- };
389
- }
864
+ return await run(['sh', '-c', resolution.command], cwd, env, timeoutMs);
390
865
  }
391
- return run(resolution.argv, cwd);
866
+ return await run(resolution.argv, cwd, env, timeoutMs);
392
867
  }
393
868
 
394
869
  async function readPkg(root) {
@@ -402,8 +877,10 @@ async function readPkg(root) {
402
877
  /** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
403
878
  export async function stagedFiles(root) {
404
879
  try {
405
- const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { cwd: root });
406
- return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
880
+ // `-z`: git quotes a path containing a newline or a quote in the line form, and a
881
+ // quoted name is not the path the index holds, so it never matches a staged blob.
882
+ const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], { cwd: root });
883
+ return stdout.split('\0').filter(Boolean);
407
884
  } catch {
408
885
  return [];
409
886
  }
@@ -419,8 +896,8 @@ export async function stagedFiles(root) {
419
896
  */
420
897
  async function unstagedFiles(root) {
421
898
  try {
422
- const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
423
- return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
899
+ const { stdout } = await exec('git', ['diff', '--name-only', '-z'], { cwd: root });
900
+ return new Set(stdout.split('\0').filter(Boolean));
424
901
  } catch {
425
902
  return new Set();
426
903
  }