simt-dev 0.7.0 → 0.8.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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. 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
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -18,22 +18,129 @@
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';
21
+ import { spawn } from 'node:child_process';
22
22
  import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { basename, join, matchesGlob, resolve, sep } from 'node:path';
25
- import { promisify } from 'node:util';
26
25
 
27
- import { GATES } from './gates.mjs';
26
+ import { GATES, getGate } from './gates.mjs';
28
27
  import { resolveGate } from './resolve.mjs';
29
28
  import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
30
29
  import { CLI } from '../name.mjs';
31
30
  import { MANIFEST_FILE } from '../name.mjs';
32
31
  import { assertVersionPin } from '../package-version.mjs';
33
- import { recordRun } from '../ledger/index.mjs';
32
+ import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
34
33
  import { renderFindings, scanStaged } from '../secrets.mjs';
34
+ import { git, gitPaths, gitText } from '../sys/git.mjs';
35
35
 
36
- const exec = promisify(execFile);
36
+ /**
37
+ * The default bound on one gate, in seconds, and the grace a killed gate gets before
38
+ * SIGKILL. A hook that runs on every commit needs a ceiling: a test runner waiting on
39
+ * a socket that never closes blocks the commit with no output and no way to tell a
40
+ * hang from slow work. `gates.timeout` raises or removes it per repository.
41
+ */
42
+ const DEFAULT_GATE_TIMEOUT_SECONDS = 600;
43
+ const KILL_GRACE_MS = 5000;
44
+
45
+ /**
46
+ * How much of a gate's output a report can carry. This is a bound on what is KEPT, not
47
+ * on what the process may write: a gate whose output exceeded execFile's buffer used to
48
+ * be reported `failed` although it exited 0.
49
+ */
50
+ const GATE_OUTPUT_TAIL_BYTES = 64 * 1024;
51
+
52
+ function tailCollector() {
53
+ let kept = Buffer.alloc(0);
54
+ let dropped = 0;
55
+ return {
56
+ push(chunk) {
57
+ kept = Buffer.concat([kept, chunk]);
58
+ if (kept.length > GATE_OUTPUT_TAIL_BYTES) {
59
+ dropped += kept.length - GATE_OUTPUT_TAIL_BYTES;
60
+ kept = kept.subarray(kept.length - GATE_OUTPUT_TAIL_BYTES);
61
+ }
62
+ },
63
+ text: () => kept.toString('utf8'),
64
+ dropped: () => dropped,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Signal the gate's whole process group. A test runner spawns workers of its own, and
70
+ * signalling only the leader leaves them holding the pipes this function is waiting on.
71
+ * `detached` in the spawn below is what makes the group exist.
72
+ */
73
+ function signalTree(child, signal) {
74
+ try {
75
+ process.kill(-child.pid, signal);
76
+ } catch {
77
+ try {
78
+ child.kill(signal);
79
+ } catch {
80
+ // Already gone: a second signal has nothing to reach.
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Run one gate to completion, keeping its exit code and the tail of what it printed.
87
+ *
88
+ * `timeoutMs` of 0 means no bound, which is what `gates.timeout: 0` asks for and what
89
+ * the manifest does not have to say. Output is collected in full while the process
90
+ * runs and only the tail is retained, so a noisy passing gate passes.
91
+ */
92
+ async function runBounded(argv, { cwd, env, timeoutMs }) {
93
+ return await new Promise((settle) => {
94
+ let child;
95
+ try {
96
+ child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
97
+ } catch (error) {
98
+ settle({ code: 1, stdout: '', stderr: String(error.message ?? error), timedOut: false, dropped: 0 });
99
+ return;
100
+ }
101
+ const out = tailCollector();
102
+ const err = tailCollector();
103
+ let timedOut = false;
104
+ let timer = null;
105
+ let killer = null;
106
+ let done = false;
107
+ const finish = (code) => {
108
+ if (done) return;
109
+ done = true;
110
+ clearTimeout(timer);
111
+ clearTimeout(killer);
112
+ settle({ code, stdout: out.text(), stderr: err.text(), timedOut, dropped: out.dropped() + err.dropped() });
113
+ };
114
+ child.stdout?.on('data', (chunk) => out.push(chunk));
115
+ child.stderr?.on('data', (chunk) => err.push(chunk));
116
+ // A missing binary emits `error` and no `close`; the message is the only evidence.
117
+ child.on('error', (error) => {
118
+ err.push(Buffer.from(String(error.message ?? error)));
119
+ finish(1);
120
+ });
121
+ child.on('close', (code, signal) => finish(typeof code === 'number' ? code : signal === null ? 0 : 1));
122
+ if (timeoutMs > 0) {
123
+ timer = setTimeout(() => {
124
+ timedOut = true;
125
+ signalTree(child, 'SIGTERM');
126
+ killer = setTimeout(() => signalTree(child, 'SIGKILL'), KILL_GRACE_MS);
127
+ }, timeoutMs);
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The bound for one gate, in milliseconds. A number applies to every gate; a map applies
134
+ * per name, the shape `gates.cwd` already has. A value that is neither a positive whole
135
+ * number of seconds nor 0 falls back to the default rather than removing the bound: an
136
+ * unreadable declaration must not be the quiet way to disable the ceiling.
137
+ */
138
+ function gateTimeoutMs(declared, name) {
139
+ const value = declared !== null && typeof declared === 'object' ? declared[name] : declared;
140
+ if (value === 0) return 0;
141
+ if (Number.isInteger(value) && value > 0) return value * 1000;
142
+ return DEFAULT_GATE_TIMEOUT_SECONDS * 1000;
143
+ }
37
144
 
38
145
  /** Gates that are known but not required for the event to pass. */
39
146
  const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
@@ -85,15 +192,14 @@ export function parsePushedRefs(text) {
85
192
  async function tagCommitAccountedFor(root, tagSha, pushingTips) {
86
193
  let commit;
87
194
  try {
88
- ({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
195
+ commit = await gitText(root, ['rev-parse', `${tagSha}^{commit}`]);
89
196
  } catch (error) {
90
197
  return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
91
198
  }
92
- commit = commit.trim();
93
199
 
94
200
  let remoteRefs;
95
201
  try {
96
- ({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
202
+ remoteRefs = await gitText(root, ['for-each-ref', 'refs/remotes/', '--format=%(refname)']);
97
203
  } catch (error) {
98
204
  return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
99
205
  }
@@ -106,7 +212,7 @@ async function tagCommitAccountedFor(root, tagSha, pushingTips) {
106
212
  }
107
213
  for (const candidate of candidates) {
108
214
  try {
109
- await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
215
+ await git(root, ['merge-base', '--is-ancestor', commit, candidate]);
110
216
  return { ok: true, reason: null };
111
217
  } catch {
112
218
  // not reachable from this ref; try the next candidate
@@ -148,15 +254,17 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
148
254
  }
149
255
  let count, names;
150
256
  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 }));
257
+ count = await gitText(root, ['rev-list', '--count', `${remoteSha}..${localSha}`]);
258
+ names = await gitPaths(root, ['diff', '--name-only', `${remoteSha}..${localSha}`]);
153
259
  } catch (error) {
154
260
  return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
155
261
  }
156
- commits += Number(count.trim());
157
- for (const f of names.split('\n')) {
158
- const name = f.trim();
159
- if (name !== '') files.add(name);
262
+ commits += Number(count);
263
+ // NUL-separated paths because a path can contain a newline, and git quotes such a
264
+ // name in the line-terminated form, so splitting on newlines compares a quoted path
265
+ // against an unquoted glob and never matches.
266
+ for (const name of names) {
267
+ files.add(name);
160
268
  }
161
269
  }
162
270
 
@@ -178,7 +286,11 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
178
286
  if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
179
287
  if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
180
288
  if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
181
- return { docsOnly: true, reason: parts.length > 0 ? parts.join('; ') : 'no commits carried' };
289
+ if (parts.length === 0) return { docsOnly: true, reason: 'no commits carried' };
290
+ // The report prints this as-is (ADR-113). The label names what is being pushed:
291
+ // a push that carries only tags is a tag push, not a docs-only push.
292
+ const kind = commits > 0 ? 'docs-only push' : tags.length > 0 ? 'tag push' : 'deletion push';
293
+ return { docsOnly: true, reason: `${kind}: ${parts.join('; ')}` };
182
294
  }
183
295
 
184
296
  /**
@@ -189,7 +301,7 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
189
301
  * review. A misdeclaration (missing fields) also runs the suite - a skip the
190
302
  * manifest cannot state precisely is a skip a reviewer cannot see.
191
303
  *
192
- * @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
304
+ * @returns {Promise<{reason: string, adr: string} | null>} null runs the suite; a reason skips the `test` gate
193
305
  */
194
306
  async function resolveDocsOnlySkip(root, manifest, pushed) {
195
307
  if (pushed === undefined) return null;
@@ -200,7 +312,43 @@ async function resolveDocsOnlySkip(root, manifest, pushed) {
200
312
  return null; // the caller cannot be sure what is being pushed; the suite runs
201
313
  }
202
314
  const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
203
- return verdict.docsOnly ? { reason: verdict.reason } : null;
315
+ return verdict.docsOnly ? { reason: verdict.reason, adr: 'ADR-113' } : null;
316
+ }
317
+
318
+ /**
319
+ * The pre-push skip for a suite the author already ran on this exact tree (ADR-120).
320
+ *
321
+ * The dispatcher records every gate run against the tree it ran on (ADR-087). If a passing
322
+ * `test` run is recorded for the committed tree about to be pushed, and nothing has changed
323
+ * since, re-running the full suite at push time buys nothing: it is the same tree, already
324
+ * proven. This reads that evidence and earns the same reported skip the docs-only path does.
325
+ *
326
+ * It matches the COMMITTED tree (`HEAD^{tree}`), not the working tree: the pre-push gate runs
327
+ * at HEAD, so the evidence that matters is a pass on exactly that tree. A file edited after
328
+ * the green pass changes the tree id, so the match fails and the suite runs - the "no changes
329
+ * since" condition is enforced by the digest, not by trusting the author. Every uncertainty -
330
+ * an unborn HEAD, an unreadable ledger, no entry for this tree, a later failure - fails closed
331
+ * to the suite.
332
+ *
333
+ * @param {string} root the real repository root
334
+ * @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
335
+ */
336
+ async function resolveAlreadyPassedSkip(root) {
337
+ let headTree;
338
+ try {
339
+ headTree = await gitText(root, ['rev-parse', 'HEAD^{tree}']);
340
+ } catch {
341
+ return null;
342
+ }
343
+ if (!/^[0-9a-f]{40}$/.test(headTree)) return null;
344
+ const { entries, errors } = await readLedger(root);
345
+ if (errors.length > 0) return null;
346
+ if (!hasTestEvidence(entries, headTree)) return null;
347
+ return {
348
+ reason:
349
+ 'already passing on current state: the test gate ran green on this tree and nothing has changed since',
350
+ adr: 'ADR-120',
351
+ };
204
352
  }
205
353
 
206
354
  /**
@@ -263,10 +411,12 @@ export async function runGates(event, { root, manifest, pushed }) {
263
411
  let headWorktree = null;
264
412
  let skipTest = null;
265
413
  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);
414
+ // Decided before the worktree exists: a push that carries only documentation skips the
415
+ // `test` gate (ADR-113), and a push whose tree the author already proved green skips it
416
+ // on that evidence (ADR-120). A push that earns either skip needs no tree for a gate
417
+ // that will not run.
418
+ skipTest = (await resolveDocsOnlySkip(root, manifest, pushed)) ??
419
+ (await resolveAlreadyPassedSkip(root));
270
420
  if (skipTest === null) {
271
421
  const materialized = await materializeHeadWorktree(root);
272
422
  if (materialized.path === null) {
@@ -312,8 +462,9 @@ export async function runGates(event, { root, manifest, pushed }) {
312
462
  * @param {object} ctx.manifest
313
463
  * @param {string[]} ctx.declared the gate names for this event
314
464
  * @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
465
+ * @param {{reason: string, adr: string} | null} [ctx.skipTest] set when a push skips the
466
+ * `test` gate (ADR-113 for docs-only, ADR-120 for an already-green tree); the reason is
467
+ * what the report prints in its place, and the adr is the record that earned it
317
468
  * @returns {Promise<{event: string, gates: object[], failed: boolean}>}
318
469
  */
319
470
  async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
@@ -345,13 +496,16 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
345
496
  // mean re-reading the command string to find out where anything runs, and a manifest
346
497
  // written by an older release would have to be rewritten to gain the capability.
347
498
  const cwds = manifest?.gates?.cwd ?? {};
499
+ // `gates.timeout` bounds one gate, in seconds: a number for every gate, or a map by
500
+ // name like `gates.cwd`. Absent means the default; 0 means no bound, declared.
501
+ const timeouts = manifest?.gates?.timeout;
348
502
  const results = [];
349
503
  // What the format gate rewrote, so restage touches those files and nothing else.
350
504
  let formatted = [];
351
505
 
352
506
  for (const name of declared) {
353
- const gate = GATES[name];
354
- if (gate === undefined) {
507
+ const gate = getGate(name, commands);
508
+ if (gate === null) {
355
509
  results.push({
356
510
  name,
357
511
  status: 'failed',
@@ -362,11 +516,12 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
362
516
  continue;
363
517
  }
364
518
 
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.
519
+ // The one gate a push may not run (ADR-113 for a docs-only push, ADR-120 for a tree
520
+ // already proven green). It is reported, not dropped: a skip without a printed reason
521
+ // would let "passed" mean "the suite never ran", which is the exact confusion the report
522
+ // exists to prevent.
368
523
  if (skipTest !== null && name === 'test') {
369
- results.push({ name, status: 'skipped', detail: `docs-only push: ${skipTest.reason}`, adr: 'ADR-113', exitCode: null });
524
+ results.push({ name, status: 'skipped', detail: skipTest.reason, adr: skipTest.adr, exitCode: null });
370
525
  continue;
371
526
  }
372
527
 
@@ -476,8 +631,9 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
476
631
  cwd = resolved;
477
632
  }
478
633
 
634
+ const timeoutMs = gateTimeoutMs(timeouts, name);
479
635
  const started = Date.now();
480
- const outcome = await runGate(resolution, cwd);
636
+ const outcome = await runGate(resolution, cwd, timeoutMs);
481
637
  // The ledger is not the gate. A tree that cannot be named leaves no line, and the
482
638
  // claim check then finds nothing, which is the direction that cannot invent a run.
483
639
  try {
@@ -498,11 +654,18 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
498
654
  } catch {
499
655
  // Recorded by absence: the next evidence read for this tree has no line.
500
656
  }
657
+ // A killed gate has no exit code of its own (`signal` instead), so the reason is
658
+ // stated rather than left to `exit 1`, and the remedy says how to change the bound.
501
659
  results.push({
502
660
  name,
503
661
  status: outcome.code === 0 ? 'passed' : 'failed',
504
- detail: `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
662
+ detail: outcome.timedOut
663
+ ? `${resolution.detail}: timed out after ${Math.round(timeoutMs / 1000)} s`
664
+ : `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
505
665
  output: outcome.code === 0 ? '' : tail(outcome.stderr || outcome.stdout),
666
+ ...(outcome.timedOut
667
+ ? { remedy: `fix the hang, raise gates.timeout to give it longer, or set gates.timeout to 0 to remove the bound` }
668
+ : {}),
506
669
  adr: gate.adr,
507
670
  exitCode: outcome.code,
508
671
  });
@@ -560,7 +723,7 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
560
723
  async function materializeHeadWorktree(root) {
561
724
  const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
562
725
  try {
563
- await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
726
+ await git(root, ['worktree', 'add', '--detach', path, 'HEAD']);
564
727
  } catch (error) {
565
728
  await rm(path, { recursive: true, force: true }).catch(() => {});
566
729
  return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
@@ -585,9 +748,9 @@ async function materializeHeadWorktree(root) {
585
748
  * @param {string} path the worktree to remove
586
749
  */
587
750
  async function removeHeadWorktree(root, path) {
588
- await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
751
+ await git(root, ['worktree', 'remove', '--force', path]).catch(async () => {
589
752
  await rm(path, { recursive: true, force: true }).catch(() => {});
590
- await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
753
+ await git(root, ['worktree', 'prune']).catch(() => {});
591
754
  });
592
755
  }
593
756
 
@@ -616,13 +779,8 @@ export function renderGateReport(event, declared, result) {
616
779
  return lines;
617
780
  }
618
781
 
619
- async function run(argv, cwd, env) {
620
- try {
621
- const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
622
- return { code: 0, stdout, stderr };
623
- } catch (error) {
624
- return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
625
- }
782
+ async function run(argv, cwd, env, timeoutMs) {
783
+ return await runBounded(argv, { cwd, env, timeoutMs });
626
784
  }
627
785
 
628
786
  /** The built-in gates this dispatcher can run, by name. */
@@ -693,24 +851,15 @@ async function runBuiltinGate(name, { root, staged, options }) {
693
851
  * that the two sources stay distinguishable, which is why this is a separate branch
694
852
  * rather than a flag on `run`.
695
853
  */
696
- async function runGate(resolution, cwd) {
854
+ async function runGate(resolution, cwd, timeoutMs) {
697
855
  // The push override is for the push itself, not for the gates the hook runs.
698
856
  // A test that asserts a push is denied sees the override and fails.
699
857
  const env = { ...process.env };
700
858
  delete env.SIMT_ALLOW;
701
859
  if (resolution.shell === true && typeof resolution.command === 'string') {
702
- try {
703
- const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
704
- return { code: 0, stdout, stderr };
705
- } catch (error) {
706
- return {
707
- code: typeof error.code === 'number' ? error.code : 1,
708
- stdout: error.stdout ?? '',
709
- stderr: error.stderr ?? String(error.message ?? error),
710
- };
711
- }
860
+ return await run(['sh', '-c', resolution.command], cwd, env, timeoutMs);
712
861
  }
713
- return run(resolution.argv, cwd, env);
862
+ return await run(resolution.argv, cwd, env, timeoutMs);
714
863
  }
715
864
 
716
865
  async function readPkg(root) {
@@ -724,8 +873,9 @@ async function readPkg(root) {
724
873
  /** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
725
874
  export async function stagedFiles(root) {
726
875
  try {
727
- const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { cwd: root });
728
- return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
876
+ // NUL-separated: git quotes a path containing a newline or a quote in the line form,
877
+ // and a quoted name is not the path the index holds, so it never matches a staged blob.
878
+ return await gitPaths(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
729
879
  } catch {
730
880
  return [];
731
881
  }
@@ -741,8 +891,7 @@ export async function stagedFiles(root) {
741
891
  */
742
892
  async function unstagedFiles(root) {
743
893
  try {
744
- const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
745
- return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
894
+ return new Set(await gitPaths(root, ['diff', '--name-only']));
746
895
  } catch {
747
896
  return new Set();
748
897
  }
@@ -752,7 +901,7 @@ async function unstagedFiles(root) {
752
901
  async function stagedBlobs(root, files) {
753
902
  if (files.length === 0) return new Map();
754
903
  try {
755
- const { stdout } = await exec('git', ['ls-files', '-s', '--', ...files], { cwd: root });
904
+ const stdout = await git(root, ['ls-files', '-s', '--', ...files]);
756
905
  const blobs = new Map();
757
906
  for (const line of stdout.split('\n')) {
758
907
  const [meta, ...rest] = line.split('\t');
@@ -777,7 +926,7 @@ async function restage(root, files) {
777
926
  if (files.length === 0) return { restaged: [], error: null };
778
927
  const before = await stagedBlobs(root, files);
779
928
  try {
780
- await exec('git', ['add', '--', ...files], { cwd: root });
929
+ await git(root, ['add', '--', ...files]);
781
930
  } catch (error) {
782
931
  return { restaged: [], error: String(error.stderr ?? error.message ?? error).trim() };
783
932
  }
@@ -101,49 +101,16 @@ export const GATES = {
101
101
  },
102
102
  };
103
103
 
104
- export function getGate(name) {
105
- return GATES[name] ?? null;
106
- }
107
-
108
- /**
109
- * A gate a repo cannot run is a FAILURE, never a skip.
110
- *
111
- * This is the load-bearing decision of the whole dispatcher. A gate that
112
- * silently does nothing is indistinguishable from a gate that passed, and the
113
- * documented consequence in this estate is an agent reaching for
114
- * `--no-verify` - a repository removed its tests from pre-commit precisely
115
- * because they were slow, and slow-plus-silent is what teaches everyone that
116
- * hooks are advisory.
117
- */
118
- export function unresolved(name, { hasScript, binary, command, stack }) {
119
- const gate = getGate(name);
120
- if (gate === null) {
121
- return {
122
- gate: name,
123
- reason: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
124
- remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
125
- };
126
- }
127
- if (gate.kind === 'script' && !hasScript(gate.script)) {
128
- return {
129
- gate: name,
130
- reason: `no "${gate.script}" script in package.json`,
131
- remedy: `add the script, or remove "${name}" from ${MANIFEST_FILE} gates`,
132
- };
133
- }
134
- if (gate.kind === 'command' && !command) {
135
- return {
136
- gate: name,
137
- reason: `no command declared for this gate in ${MANIFEST_FILE}`,
138
- remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
139
- };
140
- }
141
- if (gate.kind === 'staged' && !binary) {
142
- return {
143
- gate: name,
144
- reason: `${gate.tools.join('/')} is not installed in node_modules/.bin`,
145
- remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
146
- };
104
+ export function getGate(name, commands = {}) {
105
+ const declared = GATES[name];
106
+ if (declared) return declared;
107
+ // Q70: a command the repository declared under gates.commands is a valid gate even when
108
+ // no central row knows it. The manifest is the source of truth for a repo's gates, and
109
+ // a name that resolves to a declared command must not read as unknown. Synthesized, not
110
+ // added to GATES: the central table stays the estate's gates, the record supplies the rest.
111
+ const cmd = commands?.[name];
112
+ if (typeof cmd === 'string' && cmd.trim() !== '') {
113
+ return { name, kind: 'command', adr: 'ADR-010', why: 'declared by the repository under gates.commands' };
147
114
  }
148
115
  return null;
149
116
  }
@@ -12,16 +12,11 @@
12
12
 
13
13
  import { access } from 'node:fs/promises';
14
14
  import { constants } from 'node:fs';
15
- import { join } from 'node:path';
15
+ import { extname, join } from 'node:path';
16
16
 
17
- import { FORMATTABLE, GATES } from './gates.mjs';
17
+ import { FORMATTABLE, GATES, gateNames } from './gates.mjs';
18
18
  import { MANIFEST_FILE } from '../name.mjs';
19
19
 
20
- /** Gate names the shared dispatcher knows, sorted. */
21
- export function knownGates() {
22
- return Object.keys(GATES).sort();
23
- }
24
-
25
20
  /** Is this path an executable file? */
26
21
  async function isExecutable(path) {
27
22
  try {
@@ -144,7 +139,7 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
144
139
  remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
145
140
  };
146
141
  }
147
- const files = staged.filter((f) => FORMATTABLE.has(extname(f)));
142
+ const files = staged.filter((f) => FORMATTABLE.has(extname(f).toLowerCase()));
148
143
  if (files.length === 0) {
149
144
  return {
150
145
  ok: true,
@@ -203,11 +198,6 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
203
198
  gate: name,
204
199
  kind: 'unknown',
205
200
  reason: `unknown gate kind "${gate.kind}"`,
206
- remedy: `this is a bug in simt-dev; known gates: ${knownGates().join(', ')}`,
201
+ remedy: `this is a bug in simt-dev; )}', ')}`,
207
202
  };
208
- }
209
-
210
- function extname(path) {
211
- const dot = path.lastIndexOf('.');
212
- return dot > 0 ? path.slice(dot).toLowerCase() : '';
213
203
  }
@@ -36,8 +36,11 @@ export const SHIM_MARKER = '# Generated by simt-dev. Do not edit.';
36
36
  /** Events the shared dispatcher knows how to run. */
37
37
  export const EVENTS = ['pre-commit', 'pre-push'];
38
38
 
39
- /** Directory the shims are written to inside a repo. */
40
- export const HOOKS_DIR = '.simt-hooks';
39
+ /** Directory the shims are written to inside a repo, inside the state directory (ADR-122). */
40
+ export const HOOKS_DIR = '.simt/hooks';
41
+
42
+ /** The top-level shim directory that preceded the move into the state directory (ADR-122). */
43
+ export const HOOKS_DIR_LEGACY = '.simt-hooks';
41
44
 
42
45
  /** Name of the git config key that points at the shim directory. */
43
46
  export const HOOKS_PATH_KEY = 'core.hooksPath';
@@ -19,17 +19,12 @@
19
19
  // ADR-012 dropped Windows, but nothing here is POSIX-specific; the worktree
20
20
  // handling is the only subtlety and it is the same on every platform.
21
21
 
22
- import { execFile } from 'node:child_process';
23
22
  import { readdir, stat } from 'node:fs/promises';
24
23
  import { isAbsolute, join, resolve } from 'node:path';
25
- import { promisify } from 'node:util';
26
24
 
27
- const exec = promisify(execFile);
25
+ import { gitText } from '../sys/git.mjs';
28
26
 
29
- async function git(root, args) {
30
- const { stdout } = await exec('git', args, { cwd: root });
31
- return stdout.trim();
32
- }
27
+ const git = (root, args) => gitText(root, args);
33
28
 
34
29
  /**
35
30
  * The directory git runs hooks from, and which of the two rules produced it.
package/src/identity.mjs CHANGED
@@ -24,14 +24,12 @@
24
24
  // one; a repository that already tracks it is left that way, because removing a
25
25
  // tracked file is not something an adopting command should do silently.
26
26
 
27
- import { execFile } from 'node:child_process';
28
27
  import { readFile, stat, writeFile } from 'node:fs/promises';
29
28
  import { join } from 'node:path';
30
- import { promisify } from 'node:util';
31
29
 
32
30
  import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
31
+ import { gitText } from './sys/git.mjs';
33
32
 
34
- const exec = promisify(execFile);
35
33
 
36
34
  /** The file, at the repository root. Named for what it is: a git config fragment. */
37
35
  export const IDENTITY_FILE = 'git.config';
@@ -50,14 +48,7 @@ export const INCLUDE_KEY = 'include.path';
50
48
  * @param {string[]} args
51
49
  * @returns {Promise<string|null>}
52
50
  */
53
- async function git(cwd, args) {
54
- try {
55
- const { stdout } = await exec('git', args, { cwd });
56
- return stdout.trim();
57
- } catch {
58
- return null;
59
- }
60
- }
51
+ const git = (cwd, args) => gitText(cwd, args, { allowFailure: true });
61
52
 
62
53
  /**
63
54
  * Who last committed here.
@@ -257,4 +248,32 @@ export async function ignoreIdentity(root, journal = null) {
257
248
  if (journal) await journaledWriteFile(root, journal, path, next);
258
249
  else await writeFile(path, next);
259
250
  return true;
260
- }
251
+ }
252
+ /**
253
+ * The `user.*` keys the repository's own config sets, which is the one place an
254
+ * identity can be overridden without a trace in the tracked tree.
255
+ *
256
+ * A repo-local `[user]` block wins over both the `git.config` include and the
257
+ * machine's own identity, so every commit made here inherits it silently - including
258
+ * the commits this tool makes on the reader's behalf (`slice finish`, `plan done`).
259
+ * That is not a hypothetical: 34 commits in this repository were authored as
260
+ * `t <t@t.t>` because `.git/config` carried that block and nothing reported it. The
261
+ * helper exists so `test/repo-identity.test.mjs` can fail loudly instead.
262
+ *
263
+ * `--local` rather than `--get`: the question is what THIS config adds, not what the
264
+ * resolved identity happens to be.
265
+ *
266
+ * @param {string} root
267
+ * @returns {Promise<{key: string, value: string}[]>}
268
+ */
269
+ export async function localIdentityOverrides(root) {
270
+ const out = await git(root, ['config', '--local', '--get-regexp', '^user\\.']);
271
+ if (!out) return [];
272
+ return out
273
+ .split('\n')
274
+ .filter(Boolean)
275
+ .map((line) => {
276
+ const space = line.indexOf(' ');
277
+ return { key: line.slice(0, space), value: line.slice(space + 1) };
278
+ });
279
+ }