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
@@ -15,6 +15,35 @@ one is a mechanical procedure with a fixed order, because every step after it de
15
15
  on the one before: you cannot review a diff you have not staged, and you cannot claim
16
16
  evidence for a test you have not run.
17
17
 
18
+ ## Commit message
19
+
20
+ A message is read by the next agent before they are told what to do. It says what the
21
+ commit is, what it answers, and what it changed, so a reader never reconstructs that
22
+ from the diff.
23
+
24
+ The title is Conventional Commits: imperative, no trailing period, a short scope slug
25
+ that names the subsystem — not a ticket id.
26
+
27
+ feat(dispatcher): skip the test gate on a push that already passed
28
+
29
+ The body is plain sections in this order. Drop any section that has nothing to say; a
30
+ one-line commit needs none of them.
31
+
32
+ What: <one sentence — what this commit is>
33
+ Why: <what it answers — the task, defect, or decision that asked for it>
34
+ Before: <the state this replaces, briefly>
35
+ After: <the state it leaves, briefly>
36
+
37
+ "Before" and "After" are a short state summary, not a diff: enough to see what moved,
38
+ not enough to need the patch. Cite a decision record where one settles the change, and
39
+ never claim a test in the body that was not run on this tree.
40
+
41
+ The message ends with a trailer on its own line, after the body - attribution to the
42
+ tool, in the form git renders as a co-author. It is part of the message, not a
43
+ section, so it stays even when every section is dropped:
44
+
45
+ Co-Authored-By: Simt Dev <noreply@simt.space>
46
+
18
47
  ## Order
19
48
 
20
49
  1. **Run the focused test the change needs**, through the repository's own runner,
@@ -24,10 +53,8 @@ evidence for a test you have not run.
24
53
  alone. Report what you left alone; silence about it reads as "I reverted it".
25
54
  3. **Read the staged diff.** Not the working tree — the diff. It is what will be
26
55
  committed, and it is the last chance to notice a file that does not belong.
27
- 4. **Write the message.** Conventional Commits, imperative subject, no trailing period:
28
- `fix(gates): declare only gates a repo can run, and report the ones it cannot`.
29
- The subject says what changed; the body says why, and what was deliberately not
30
- changed.
56
+ 4. **Write the message in the format above.** The title names what changed; the body
57
+ says why, and what was deliberately not changed.
31
58
  5. **Commit.** `git commit`, non-interactive. Report the SHA.
32
59
  6. **Stop.**
33
60
 
@@ -44,6 +71,32 @@ Never use `--no-verify`. A hook that fails is telling you something about the ch
44
71
  skipping it turns a gate into a suggestion and the next person has no way to know it
45
72
  was skipped.
46
73
 
74
+ Once the push is approved, you may push directly without the pre-push gate re-running
75
+ the full suite when you already ran that suite green on the current state and changed
76
+ nothing since: the gate records every run against the tree it ran on, recognises that
77
+ evidence, and skips the re-run, reporting the skip and the record that earned it.
78
+ Cite the already-passing run as the pass — do not claim a re-run that did not happen.
79
+ (ADR-120)
80
+
81
+ ## Worktrees
82
+
83
+ A worktree is the unit of isolation for concurrent work. The rules that keep it honest:
84
+
85
+ - **One agent, one worktree, one slice.** Never direct two agents at the same files
86
+ concurrently; give each its own worktree and a slice whose files do not overlap.
87
+ - **Start from the record, not from memory.** `simt slice start <id>` creates the
88
+ worktree for the tracker row and returns its path; work in that path, not the main
89
+ checkout.
90
+ - **Finish where you started.** `simt slice finish <id>` runs the declared gates and
91
+ commits from the worktree; `simt plan done <id> <sha>` records the SHA in the main
92
+ checkout's tracker. A slice is not done when its worktree is green — it is done
93
+ when the tracker row is complete and the SHA is recorded.
94
+ - **Leave the worktree when the slice is done.** A finished worktree that still holds
95
+ a dirty tree is a claim about work that is not committed; remove it, and report what
96
+ you left behind if there was any.
97
+ - **Never rewrite history across worktrees.** A rebase, reset or amend in one worktree
98
+ changes the base every other worktree branched from; coordinate it, or do not do it.
99
+
47
100
  ## What never belongs in a commit
48
101
 
49
102
  - Failing, partial, or unrun work. A commit is a claim; a partial one is a false one.
@@ -21,8 +21,9 @@
21
21
  // records or a prices table price the tokens, total tokens otherwise. Ordering by a number
22
22
  // that does not exist for any run would be nondeterminism, not economics.
23
23
  import { classifyTask } from '../brief/kind.mjs';
24
- import { computeRunCost, isRunResolved, loadPrices, loadRunRecords } from '../bench/report.mjs';
25
- import { loadTasks } from '../bench/tasks.mjs';
24
+ import { computeRunCost, isRunResolved, loadPrices } from '../measure/metrics.mjs';
25
+ import { loadRunRecords } from '../measure/records.mjs';
26
+ import { loadTasks } from '../measure/tasks.mjs';
26
27
  import { CONFIG_DIR } from '../name.mjs';
27
28
 
28
29
  /**
@@ -60,7 +60,7 @@ export async function promptOptIn(root) {
60
60
  * a minute while a repository is walked — is how the hook gets uninstalled, and the
61
61
  * packet is not worth a prompt that cannot be typed.
62
62
  */
63
- export async function deliverPrompt(row, payload) {
63
+ export async function deliverPrompt(row, payload, { root: flagRoot } = {}) {
64
64
  const task = promptText(row, payload);
65
65
  if (task === null) return { skipped: 'the payload carries no prompt this harness documents' };
66
66
 
@@ -68,7 +68,10 @@ export async function deliverPrompt(row, payload) {
68
68
  // and resolved to the repository's top level the same way: the manifest, like the
69
69
  // lease file, is a repository-relative fact, and a prompt submitted from a
70
70
  // subdirectory must find it (review 0003 Q16).
71
- const root = repoRoot(factsFrom(row, payload).root);
71
+ // `--root` outranks the payload here as it does on the decision path: a packaged hook
72
+ // runs from the plugin directory, so the payload's cwd would name the wrong repository
73
+ // and find no manifest, which delivered nothing and looked like an opt-out.
74
+ const root = repoRoot(factsFrom(row, payload, { root: flagRoot }).root);
72
75
  const optIn = await promptOptIn(root);
73
76
  if (!optIn.optedIn) return { skipped: optIn.why };
74
77
 
@@ -48,6 +48,7 @@
48
48
 
49
49
  import { CLI } from '../name.mjs';
50
50
  import { LOADERS } from '../projection/loaders.mjs';
51
+ import { harnessHooks, harnessPayloads } from '../harness/records.mjs';
51
52
  import { renderOpencodePlugin } from './opencode.mjs';
52
53
  import { EVENTS } from './policy.mjs';
53
54
  import { AGENT_HOOK_SHIM } from './shim.mjs';
@@ -183,188 +184,93 @@ function patchFiles(text) {
183
184
  return files.filter((f) => f.path !== '');
184
185
  }
185
186
 
186
- const ADAPTERS = {
187
- claude: {
188
- id: 'claude',
189
- event: 'PreToolUse',
190
- matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit',
191
- denyExit: 2,
192
- verified: 'docs.claude.com/en/docs/claude-code/hooks, read 2026-10-16',
193
- root: (p) => p.cwd,
194
- extract: (p) => ({
195
- command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
196
- // NotebookEdit spells its target `notebook_path`, not `file_path`: a key list
197
- // that misses it is a lease the notebook can walk straight through (review
198
- // 0003 Q16). Edit details travel with the path so the generated-region
199
- // check can tell an edit outside the region from one inside it (Q17).
200
- writes: ['Edit', 'Write', 'MultiEdit'].includes(p.tool_name)
201
- ? [writeOf(p.tool_input?.file_path, p.tool_input)].filter(Boolean)
202
- : p.tool_name === 'NotebookEdit'
203
- ? [writeOf(p.tool_input?.notebook_path, p.tool_input)].filter(Boolean)
204
- : [],
205
- }),
206
- deny: (message) => ({
207
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
208
- }),
209
- // The delivery door. `additionalContext` is documented for this event and is
210
- // wrapped into the conversation at the point the hook fired, which is why the
211
- // packet goes here rather than into a system prompt: the cached prefix survives.
212
- prompt: {
213
- event: 'UserPromptSubmit',
214
- verified: 'code.claude.com/docs/en/hooks, read 2026-10-19',
215
- context: (text) => ({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }),
216
- },
217
- post: {
218
- event: 'PostToolUse',
219
- matcher: 'Bash',
220
- verified: 'code.claude.com/docs/en/hooks, read 2026-10-19',
221
- context: (text) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }),
222
- },
223
- },
224
- codex: {
225
- id: 'codex',
226
- event: 'PreToolUse',
227
- matcher: 'Bash|apply_patch',
228
- denyExit: 2,
229
- verified: 'developers.openai.com/codex/hooks, read 2026-10-16',
230
- // Codex is the one harness that will not run a hook until a person has approved the
231
- // exact definition, keyed by its hash — so an install that does not say this leaves
232
- // the repository with a hook that never fires and no way to tell why.
233
- trust: 'Codex skips a hook nobody has trusted: review it with /hooks, or the entry stays inert.',
234
- root: (p) => p.cwd,
235
- extract: (p) => ({
236
- command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
237
- // apply_patch carries its targets inside the patch text, one header line per file;
238
- // a move names its destination on the `+++` line after `*** Move File:`, and the
239
- // destination is the file the move writes (review 0003 Q16).
240
- writes:
241
- p.tool_name === 'apply_patch'
242
- ? patchWrites(String(p.tool_input?.command ?? ''))
243
- : [], // entries carry {path, patch} so the region check maps hunks (Q17)
244
- }),
245
- deny: (message) => ({
246
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
247
- }),
248
- prompt: {
249
- event: 'UserPromptSubmit',
250
- verified: 'developers.openai.com/codex/hooks, read 2026-10-19',
251
- context: (text) => ({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }),
252
- },
253
- post: {
254
- event: 'PostToolUse',
255
- matcher: 'Bash',
256
- verified: 'developers.openai.com/codex/hooks, read 2026-10-19',
257
- context: (text) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }),
258
- },
259
- },
260
- cursor: {
261
- id: 'cursor',
262
- event: 'preToolUse',
263
- matcher: 'Shell|Write|Edit',
264
- denyExit: 2,
265
- verified: 'cursor.com/docs/agent/hooks, read 2026-10-16',
266
- root: (p) => p.working_directory ?? p.cwd,
267
- extract: (p) => ({
268
- command: p.tool_name === 'Shell' ? (p.tool_input?.command ?? null) : null,
269
- writes: ['Write', 'Edit'].includes(p.tool_name)
270
- ? [writeOf(p.tool_input?.file_path ?? p.tool_input?.path, p.tool_input)].filter(Boolean)
271
- : [],
272
- }),
273
- deny: (message) => ({ permission: 'deny', user_message: message, agent_message: message }),
274
- // beforeSubmitPrompt is documented as continue/block and nothing else, so there is no
275
- // key to deliver a packet in. Writing the entry anyway would put a command that reads
276
- // a repository on the path of every prompt a person types, to say nothing.
277
- prompt: null,
278
- post: null,
279
- },
280
- antigravity: {
281
- id: 'antigravity',
282
- event: 'PreToolUse',
283
- matcher: 'run_command|write_to_file|replace_file_content|multi_replace_file_content',
284
- // The page documents the decision JSON and not an exit code, so the denial travels
285
- // in the JSON only: a non-zero exit whose meaning the vendor has not stated is not
286
- // a block, it is a guess that happens to be loud.
287
- denyExit: 0,
288
- verified: 'antigravity.google/docs/hooks, read 2026-10-16',
289
- root: (p) => p.workspacePaths?.[0] ?? p.cwd,
290
- extract: (p) => {
291
- const call = p.toolCall ?? {};
292
- const args = call.args ?? {};
293
- return {
294
- command: call.name === 'run_command' ? (args.CommandLine ?? null) : null,
295
- writes: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(call.name)
296
- ? [writeOf(args.TargetFile, args)].filter(Boolean)
297
- : [], // CodeContent/old/new travel with the path for the region check (Q17)
298
- };
299
- },
300
- deny: (message) => ({ decision: 'deny', reason: message }),
301
- // No prompt event is documented for this harness's hooks file, and a guessed event
302
- // name is a hook that never fires while its config says it does.
303
- prompt: null,
304
- post: null,
305
- },
306
- gemini: {
307
- id: 'gemini',
308
- event: 'BeforeTool',
309
- matcher: 'run_shell_command|write_file|replace',
310
- // The vendor's timeout unit is milliseconds (default 60000), not the seconds every
311
- // other row uses; a bare 10 here would be a ten-millisecond budget, which is a hook
312
- // that never finishes. The row carries its own value so the shared constant stays
313
- // honest for the four vendors that measure in seconds.
314
- timeout: 10000,
315
- // The reference documents the decision JSON and an exit-2 system block; the denial
316
- // travels in the JSON, as antigravity does.
317
- denyExit: 0,
318
- verified: 'geminicli.com/docs/hooks/reference/, read 2026-10-26',
319
- root: (p) => p.cwd,
320
- extract: (p) => ({
321
- command: p.tool_name === 'run_shell_command' ? (p.tool_input?.command ?? null) : null,
322
- writes: ['write_file', 'replace'].includes(p.tool_name)
323
- ? [writeOf(p.tool_input?.file_path, p.tool_input)].filter(Boolean)
187
+ // The payload a hook reads: where the tool name and its input sit, and the keys that
188
+ // name a command and a file. A record cannot carry a function, so the engine reads the
189
+ // record's keys and applies them: a harness that puts a field somewhere new is a new
190
+ // record, not a new branch here (Q71).
191
+ function pick(obj, keys) {
192
+ if (!obj || typeof obj !== 'object') return null;
193
+ for (const k of keys) {
194
+ const v = obj[k];
195
+ if (v !== undefined && v !== null) return v;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function extractOf(spec) {
201
+ return (p) => {
202
+ const payload = p ?? {};
203
+ // antigravity nests the call under `toolCall`; the rest name the tool at the top
204
+ // level. `toolNameKey` is present only on the nested shape, so its presence is
205
+ // the detector.
206
+ const nested = spec.toolNameKey !== undefined;
207
+ const call = nested ? (payload[spec.toolKey] ?? {}) : null;
208
+ const toolName = nested ? call[spec.toolNameKey] : payload[spec.toolKey];
209
+ const input = nested ? (call[spec.inputSubKey] ?? {}) : (payload[spec.inputKey] ?? {});
210
+ return {
211
+ command: (spec.shellTools ?? []).includes(toolName) ? (input[spec.commandKey] ?? null) : null,
212
+ // apply_patch carries its targets inside the patch text (writesFrom: "patch");
213
+ // every other write tool names its file through the path keys.
214
+ writes: (spec.writeTools ?? []).includes(toolName)
215
+ ? spec.writesFrom === 'patch'
216
+ ? patchWrites(String(input[spec.commandKey] ?? ''))
217
+ : [writeOf(pick(input, spec.pathOverrides?.[toolName] ?? spec.pathKeys), input)].filter(Boolean)
324
218
  : [],
325
- }),
326
- deny: (message) => ({ decision: 'deny', reason: message }),
327
- // BeforeAgent appends text to the prompt for this turn only — the same lifetime as a
328
- // brief. The documented output has no hookEventName field, unlike Claude's.
329
- prompt: {
330
- event: 'BeforeAgent',
331
- verified: 'geminicli.com/docs/hooks/reference/, read 2026-10-26',
332
- context: (text) => ({ hookSpecificOutput: { additionalContext: text } }),
333
- },
334
- // AfterTool appends to the tool result; its input carries tool_response, which is
335
- // what the lesson recall reads.
336
- post: {
337
- event: 'AfterTool',
338
- verified: 'geminicli.com/docs/hooks/reference/, read 2026-10-26',
339
- context: (text) => ({ hookSpecificOutput: { additionalContext: text } }),
340
- },
341
- },
342
- opencode: {
343
- id: 'opencode',
344
- event: 'execute.before',
345
- matcher: 'shell|write|edit',
346
- // The plugin is a file the tool owns whole, not a config entry to merge:
347
- // `file` routes `rendered` and `hookDocument` to its source (opencode.mjs).
348
- file: true,
349
- // A thrown error is the block; exit 2 is how the entry point signals it,
350
- // and the reason the plugin throws is what `deny` prints.
351
- denyExit: 2,
352
- verified: 'opencode.ai/v2/docs/build/plugins/server, read 2026-10-29',
353
- root: (p) => p.cwd,
354
- extract: (p) => ({
355
- command: p.tool === 'shell' ? (p.input?.command ?? null) : null,
356
- writes: ['write', 'edit'].includes(p.tool) ? [writeOf(p.input?.path, p.input)].filter(Boolean) : [],
357
- }),
358
- deny: (message) => ({ reason: message }),
359
- // session.hook('prompt') admits the prompt; the packet goes in front of it.
360
- prompt: {
361
- event: 'prompt',
362
- verified: 'opencode.ai/v2/docs/build/plugins/server, read 2026-10-29',
363
- context: (text) => ({ text }),
219
+ };
220
+ };
221
+ }
222
+
223
+ // The record's `rootKeys`: a list of accessor paths, first non-null wins. The caller
224
+ // applies its `?? process.cwd()` floor (factsFrom).
225
+ function rootOf(spec) {
226
+ return (p) => {
227
+ for (const path of spec.rootKeys ?? []) {
228
+ let v = p;
229
+ for (const seg of path) v = v?.[seg];
230
+ if (v !== undefined && v !== null) return v;
231
+ }
232
+ return null;
233
+ };
234
+ }
235
+
236
+ // The `__SIMT_TEXT__` sentinel in a record's deny and delivery templates: the text a
237
+ // decision or a delivery carries is substituted for it, and JSON.stringify downstream
238
+ // escapes whatever it carries.
239
+ const TEXT = '__SIMT_TEXT__';
240
+
241
+ function applyText(value, text) {
242
+ if (typeof value === 'string') return value === TEXT ? text : value;
243
+ if (Array.isArray(value)) return value.map((v) => applyText(v, text));
244
+ if (value && typeof value === 'object') {
245
+ const out = {};
246
+ for (const [k, v] of Object.entries(value)) out[k] = applyText(v, text);
247
+ return out;
248
+ }
249
+ return value;
250
+ }
251
+
252
+ // The hooks dialect per harness, as records (Q71): the data a config document and a
253
+ // decision are shaped by, with the page each shape was read from. What a record cannot
254
+ // carry is attached here — `root` and `extract` read the record's payload keys, and
255
+ // `deny`, `prompt.context` and `post.context` apply its templates. Module-internal on
256
+ // purpose: `adapters()` and `adapter()` are the only doors in, and both check the
257
+ // reader table, so an id with no hooks file is no adapter.
258
+ const HOOKS = harnessHooks();
259
+ const PAYLOADS = harnessPayloads();
260
+
261
+ const ADAPTERS = Object.fromEntries(
262
+ Object.entries(HOOKS).map(([id, h]) => [
263
+ id,
264
+ {
265
+ ...h,
266
+ root: rootOf(PAYLOADS[id]),
267
+ extract: extractOf(PAYLOADS[id]),
268
+ deny: (message) => applyText(h.deny, message),
269
+ ...(h.prompt ? { prompt: { ...h.prompt, context: (text) => applyText(h.prompt.delivery, text) } } : {}),
270
+ ...(h.post ? { post: { ...h.post, context: (text) => applyText(h.post.delivery, text) } } : {}),
364
271
  },
365
- post: null,
366
- },
367
- };
272
+ ]),
273
+ );
368
274
 
369
275
  /** The six adapters, in the order the reader table lists them. */
370
276
  export function adapters() {
@@ -4,7 +4,7 @@
4
4
  // harness's PATH. A harness config naming the bare `simt` binary therefore
5
5
  // runs nothing on Claude (a non-2 exit is silent: no enforcement) and blocks
6
6
  // every tool call on Cursor (its hook fails closed). The fix is a shim beside
7
- // the git shims, resolved the same way: `.simt-hooks/agent-hook` locates the
7
+ // the git shims, resolved the same way: `.simt/hooks/agent-hook` locates the
8
8
  // dispatcher with the shared `renderLocator` block and hands over, so every
9
9
  // harness config names the shim and never the bare binary (review 0003 Q21).
10
10
  //
@@ -25,9 +25,12 @@ import { mkdir, readFile } from 'node:fs/promises';
25
25
  import { dirname, join } from 'node:path';
26
26
 
27
27
  import { PACKAGE_ROOT } from '../workspace.mjs';
28
- import { splitFrontmatter } from '../memory/decide.mjs';
28
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
29
29
  import { LOADERS } from '../projection/loaders.mjs';
30
30
  import { journaledWriteFile } from '../journal.mjs';
31
+ import { applyPlan, change } from '../write/plan.mjs';
32
+ import { readBefore } from '../diff.mjs';
33
+ import { readIfExists } from '../util/fs.mjs';
31
34
 
32
35
  /** The one agent this tool ships, and the filename stem every harness shares. */
33
36
  const AGENT_FILE = 'simt-agent.md';
@@ -149,7 +152,7 @@ function parseSimpleFront(front) {
149
152
  */
150
153
  export async function loadAgent() {
151
154
  const raw = await readFile(join(PACKAGE_ROOT, 'agents', AGENT_FILE), 'utf8');
152
- const { front, body } = splitFrontmatter(raw);
155
+ const { front, body } = parseFrontmatter(raw);
153
156
  const meta = parseSimpleFront(front ?? '');
154
157
  if (!meta.name || !meta.description) {
155
158
  throw new Error(`${AGENT_FILE} must declare a name and a description in its frontmatter`);
@@ -213,15 +216,6 @@ export function rendered(adapterRow, agent, existing) {
213
216
  return { text, action: 'updated' };
214
217
  }
215
218
 
216
- /** Read a file as UTF-8, or null when it is not there. */
217
- async function readIfExists(path) {
218
- try {
219
- return await readFile(path, 'utf8');
220
- } catch {
221
- return null;
222
- }
223
- }
224
-
225
219
  /**
226
220
  * The agent files a repository should carry, one per harness that documents an
227
221
  * agents directory, plus the companion skill file. Reads each existing file and
@@ -252,13 +246,16 @@ export async function planAgents(root, { harness } = {}) {
252
246
  * Skips files that are already unchanged. Returns the paths written.
253
247
  */
254
248
  export async function writeAgentPlan(root, journal, plan) {
255
- const written = [];
256
- for (const p of plan.filter((x) => x.action !== 'unchanged')) {
257
- await mkdir(join(root, dirname(p.path)), { recursive: true });
258
- await journaledWriteFile(root, journal, join(root, p.path), p.text);
259
- written.push(p.path);
260
- }
261
- return written;
249
+ const changes = await Promise.all(
250
+ plan
251
+ .filter((x) => x.action !== 'unchanged')
252
+ .map(async (p) => {
253
+ const path = join(root, p.path);
254
+ return change({ path, before: await readBefore(path), after: p.text, owner: 'agents', reason: p.action });
255
+ }),
256
+ );
257
+ await applyPlan(root, changes, { mode: { apply: true }, journal, commit: false });
258
+ return plan.filter((x) => x.action !== 'unchanged').map((p) => p.path);
262
259
  }
263
260
 
264
261
  /**
package/src/api.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // The library surface: the read commands as data (review 0003 Q63).
2
+ //
3
+ // Every function here is the one the CLI and the MCP tools already call. Nothing in
4
+ // this file renders: a caller that wants text, JSON or an MCP result renders these
5
+ // results itself, which is the whole point - a UI, a harness and a terminal asking the
6
+ // same question must not get three answers, and they cannot disagree if they are
7
+ // reading one function's return value.
8
+ //
9
+ // The write half arrives through `run(command, input)`: a writer still renders its plan
10
+ // from the command module, because a person reviews that diff before it is applied
11
+ // (ADR-001), but a library caller now gets that plan back as text and documents instead
12
+ // of having it printed at a terminal it does not own. `run` resolves the same registry
13
+ // and calls the same handler `dispatch` does, so the two surfaces cannot answer
14
+ // differently.
15
+ //
16
+ // The exports map in package.json points `import 'simt-dev'` here, and keeps
17
+ // `./src/*` because the plugin runtime and the harness shims import deep paths.
18
+ // Removing that subpath would break them to make the surface look tidier.
19
+
20
+ export { run, renderText, renderJson } from './run.mjs';
21
+ export { checkRepo, checkTargets, checkTarget, frozenFindings } from './commands/sync.mjs';
22
+ export { diagnose, serialisedFindings } from './commands/doctor.mjs';
23
+ export { rulesCatalogue, rulesWhyFacts } from './commands/rules.mjs';
24
+ export { skillsBriefFacts, skillsFindFacts, skillsShowFacts, skillsWhyFacts } from './commands/skills.mjs';
25
+ export { assembleBrief } from './brief/assemble.mjs';
26
+ export { loadRegistry } from './graph/load.mjs';
27
+ export { discover } from './compare/discover.mjs';
28
+ export { buildRows, resolveConcurrency, summarise } from './compare/row.mjs';
29
+ export { planNext } from './plan/index.mjs';
30
+ export { buildPrBody } from './prbody.mjs';
31
+ export { federationAccess, readFederation } from './federation/config.mjs';
package/src/argv.mjs CHANGED
@@ -26,6 +26,11 @@ import { EXIT } from './output/index.mjs';
26
26
  // mean something, which it could not while three accepted flags had no description.
27
27
 
28
28
  export const BOOL_FLAGS = new Set([
29
+ // bench ablate: compare across harness, model and experiment groups. The command
30
+ // read `flags.crossGroup` while the parser accepted no such flag, so the override
31
+ // existed only in the error message that told the reader to pass it (review 0003
32
+ // Q62, found by the spec-vs-parser drift test).
33
+ 'cross-group',
29
34
  'json',
30
35
  'all',
31
36
  'dry-run',
@@ -39,6 +44,14 @@ export const BOOL_FLAGS = new Set([
39
44
  'pretty',
40
45
  'accept-conflicts',
41
46
  'backup',
47
+ // init: opt out of the agent files and the agent-hooks in one flag. The parser's
48
+ // `no-` convention spells the flag as a negation of the positive `agents`, so the
49
+ // positive name is what belongs here and `init` reads `flags.agents === false` -
50
+ // the user-facing flag stays `--no-agents`. They are the two halves of the same
51
+ // decision - which harnesses the adoption speaks to - and the standalone agents /
52
+ // agent-hooks install commands remain for a repo that wants them later. Off by
53
+ // default because the adoption writes them by default.
54
+ 'agents',
42
55
  'list',
43
56
  // tokens: measure every repository under the root, not just the one. Plan 0003 R06
44
57
  // names this flag `--workspace`, and that name has been a VALUE flag since plan
@@ -89,6 +102,10 @@ export const BOOL_FLAGS = new Set([
89
102
  // bench.json marked unmeasured, and a lint that failed it on arrival would make
90
103
  // every new pack red before its author wrote the rule (plan 0003 R45).
91
104
  'require-measured',
105
+ // bench mine: restore the old "reflect exactly this history" semantics by
106
+ // deleting task files the current history does not reproduce. Off by default
107
+ // because mining is append-only (review 0003 Q48).
108
+ 'replace',
92
109
  // distill: propose the summary from the configured local chat model instead of
93
110
  // the deterministic outline. Off by default: no model in the default path (P1),
94
111
  // and a model that is not asked for should not be opened against (ADR-090).
@@ -166,6 +183,9 @@ export const VALUE_FLAGS = new Set([
166
183
  // lease: how long the claim lasts, in seconds. A value, because "a lease is short"
167
184
  // is not a number any reader can act on, and an expiry nobody chose is an expiry
168
185
  // nobody can plan around (plan 0003 R54, ADR-101).
186
+ // lessons capture: how long a captured candidate stays eligible. A value, because a
187
+ // default would be a deadline nobody chose (review 0003 Q62, found by the drift test).
188
+ 'expires',
169
189
  'ttl',
170
190
  // map: outline (paths), names (exported symbols) or signatures (the line).
171
191
  // A boolean would not say which of the three the caller wanted.
@@ -184,6 +204,10 @@ export const VALUE_FLAGS = new Set([
184
204
  // name only the author's branch knows, and the default (origin/main, then main)
185
205
  // is a guess the report states out loud rather than a question it asks.
186
206
  'base',
207
+ // slice start: the id for a prompt slice, overriding the slug derived from the
208
+ // prompt text. A value, because the derived slug is a guess a reader may want to
209
+ // pin, and a flag without a value would not say what to name the slice.
210
+ 'id',
187
211
  ]);
188
212
 
189
213
  /**
@@ -229,6 +253,11 @@ export function parse(argv) {
229
253
  const flags = {};
230
254
  const positional = [];
231
255
  let passthrough = false;
256
+ // `--` is consumed as a separator, but its presence is a fact a command may need:
257
+ // `slice start -- "<text>"` reads the prompt as everything after the marker, and a
258
+ // positional that merely happens to sit there is a different command. Carried as a
259
+ // flag so it reaches the command the way every other parsed fact does.
260
+ let dashdash = false;
232
261
 
233
262
  for (let i = 0; i < argv.length; i += 1) {
234
263
  const arg = argv[i];
@@ -239,6 +268,7 @@ export function parse(argv) {
239
268
  }
240
269
  if (arg === '--') {
241
270
  passthrough = true;
271
+ dashdash = true;
242
272
  continue;
243
273
  }
244
274
 
@@ -281,15 +311,30 @@ export function parse(argv) {
281
311
  flags.reps = n;
282
312
  } else {
283
313
  const key = camel(raw);
284
- flags[key] = REPEATABLE_FLAGS.has(raw)
285
- ? [...(Array.isArray(flags[key]) ? flags[key] : []), value]
286
- : value;
314
+ if (REPEATABLE_FLAGS.has(raw)) {
315
+ flags[key] = [...(Array.isArray(flags[key]) ? flags[key] : []), value];
316
+ } else if (flags[key] !== undefined) {
317
+ // `--root a --root b` kept `b`, so half of what the caller asked for vanished
318
+ // with no error and no way to tell which half. Only a flag declared repeatable
319
+ // accumulates (review 0003 Q62); a second value for any other is a usage error.
320
+ return {
321
+ ok: false,
322
+ error: usage(
323
+ `--${raw} was given twice; it takes one value (repeatable: ${[...REPEATABLE_FLAGS]
324
+ .map((name) => `--${name}`)
325
+ .join(', ')})`,
326
+ ),
327
+ };
328
+ } else {
329
+ flags[key] = value;
330
+ }
287
331
  }
288
332
  continue;
289
333
  }
290
334
  return { ok: false, error: usage(`unknown flag --${raw}`) };
291
335
  }
292
336
 
337
+ if (dashdash) flags.dashdash = true;
293
338
  return { ok: true, value: { flags, positional } };
294
339
  }
295
340
 
@@ -19,15 +19,14 @@ import { plan } from '../projection/project.mjs';
19
19
  import { isEligible, loadRules, readDeclared, ruleValues, select } from '../projection/rules.mjs';
20
20
  import { resolveSurfaces, rulePath, SURFACES } from '../projection/surfaces.mjs';
21
21
  import { fingerprint } from '../resolve/fingerprint.mjs';
22
+ import { evaluateRunRecord, loadPrices } from '../measure/metrics.mjs';
23
+ import { loadRunRecords } from '../measure/records.mjs';
22
24
  import {
23
25
  DEFAULT_BOOTSTRAP_SAMPLES,
24
26
  DEFAULT_BOOTSTRAP_SEED,
25
27
  compareArms,
26
- evaluateRunRecord,
27
28
  holmAdjust,
28
- loadPrices,
29
- loadRunRecords,
30
- } from './report.mjs';
29
+ } from '../measure/stats.mjs';
31
30
 
32
31
  export const DEFAULT_CANARY_SIZE = 10;
33
32
  export const DEL_R_TOLERANCE = 0.01;