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
@@ -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.
@@ -10,6 +10,8 @@ simt-substitutions:
10
10
  describe: how this repo actually runs one named test file
11
11
  - key: exampleTestPath
12
12
  default: path/to/the.changed.test.ts
13
+ default-has-go-mod: path/to/the_changed_test.go
14
+ default-has-cargo-toml: tests/the_changed.rs
13
15
  describe: one real test file for this repo, used only as the named example
14
16
  - key: pathKinds
15
17
  default: feature/contract/route
@@ -12,9 +12,9 @@ simt-scope: ["AndroidManifest.xml", "**/build.gradle", "**/build.gradle.kts", "*
12
12
 
13
13
  # Stack profile: Android
14
14
 
15
- Applies to: `has-android-manifest` — some module has a
16
- `src/main/AndroidManifest.xml`. A JVM backend with no Android module does not get
17
- this profile; see `jvm`.
15
+ Applies to: Android modules — some module has a `src/main/AndroidManifest.xml`.
16
+ A JVM backend with no Android module does not get this profile; see `simt rules
17
+ show jvm`.
18
18
 
19
19
  ## Build
20
20
 
@@ -25,8 +25,8 @@ Known traps to assume until verified otherwise:
25
25
 
26
26
  ## Memory
27
27
 
28
- - Memory budgets are hard limits, not targets, and they are the repo's declared gate
29
- (`simt-dev.json`). Zero allocation in `onUpdate`; no allocation in a draw callback.
28
+ - Memory budgets are hard limits, not targets, and they are the repo's declared gate in its
29
+ manifest (`{manifest}`). Zero allocation in `onUpdate`; no allocation in a draw callback.
30
30
  - A memory check that "currently exits 1 by design" is not a gate — it is noise that teaches
31
31
  everyone to ignore it. Declare the budget or drop the check.
32
32
 
@@ -8,7 +8,7 @@ simt-scope: ["pubspec.yaml", "**/*.dart"]
8
8
 
9
9
  # Stack profile: Dart
10
10
 
11
- Applies to: `has-pubspec` — a `pubspec.yaml`. A Flutter project is a Dart project
11
+ Applies to: a committed `pubspec.yaml`. A Flutter project is a Dart project
12
12
  with the `flutter` SDK dependency; the Flutter-specific rules live with the tools
13
13
  that build it, not here.
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["deno.json", "deno.jsonc", "**/*.ts"]
8
8
 
9
9
  # Stack profile: Deno
10
10
 
11
- Applies to: `has-deno-config` — a `deno.json` or `deno.jsonc`.
11
+ Applies to: a committed `deno.json` or `deno.jsonc`.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["*.csproj", "*.sln", "*.fsproj"]
8
8
 
9
9
  # Stack profile: .NET
10
10
 
11
- Applies to: `has-nuget` — a `.csproj` or solution.
11
+ Applies to: a committed `.csproj` or solution.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["mix.exs", "lib/**/*.ex"]
8
8
 
9
9
  # Stack profile: Elixir
10
10
 
11
- Applies to: `has-mix` — a `mix.exs`.
11
+ Applies to: a committed `mix.exs`.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["go.mod", "**/*.go"]
8
8
 
9
9
  # Stack profile: Go
10
10
 
11
- Applies to: `has-go-mod` — a module.
11
+ Applies to: Go modules — a committed `go.mod`.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -12,8 +12,9 @@ simt-scope: ["build.gradle", "build.gradle.kts", "pom.xml", "settings.gradle", "
12
12
 
13
13
  # Stack profile: JVM backend
14
14
 
15
- Applies to: `gradle`, `has-maven-pom`, `kotlin`, `java` — a JVM build. The Android
16
- rules live in the `android` profile, which selects on `has-android-manifest`.
15
+ Applies to: a JVM build — `gradle`, a committed `pom.xml`, Kotlin or Java. The
16
+ Android rules live in the `android` profile (`simt rules show android`), which
17
+ selects when an `AndroidManifest.xml` is present.
17
18
 
18
19
  ## Build
19
20
 
@@ -42,6 +42,6 @@ Applies to: `node`, `node-web`, `typescript` when no more specific profile match
42
42
  ## Tests
43
43
 
44
44
  - `vitest`, `jest` or `node --test` — one runner per repo, declared, not inherited.
45
- - Focused selection is the default invocation. A full suite runs against the triggers in
46
- `simt-dev.json` (ADR-003).
45
+ - Focused selection is the default invocation. A full suite runs against the triggers in the
46
+ repository's manifest (`{manifest}`) (ADR-003).
47
47
  - No snapshot-only acceptance: a snapshot proves the code did not change, not that it is right.
@@ -8,14 +8,15 @@ simt-scope: ["pyproject.toml", "requirements.txt", "setup.py", "*.py"]
8
8
 
9
9
  # Stack profile: Python
10
10
 
11
- Applies to: `python`. The environment rules — how to invoke tools, and which lockfile
12
- owns them — are in the `uv` profile, which selects on `has-uv-lock`.
11
+ Applies to: Python — `pyproject.toml`, `requirements.txt` or `setup.py`. The
12
+ environment rules — how to invoke tools, and which lockfile owns them — are in the
13
+ `uv` profile (`simt rules show uv`), which selects on a committed `uv.lock`.
13
14
 
14
15
  ## Invocation
15
16
 
16
17
  - Run through the repository's own runner: `uv run` where a `uv.lock` is committed,
17
18
  `poetry run` where a `poetry.lock` is, and the repository's documented wrapper
18
- otherwise. See the `uv` profile for the form that must not be used.
19
+ otherwise. See `simt rules show uv` for the form that must not be used.
19
20
  - Never invoke a tool from whatever environment the shell happens to have. It will
20
21
  usually be a different copy of the package than the one under test, and the failure
21
22
  it produces looks like a bug in the code.
@@ -30,9 +31,9 @@ owns them — are in the `uv` profile, which selects on `has-uv-lock`.
30
31
 
31
32
  ## Tests
32
33
 
33
- - `fail_under` is set per repo in `simt-dev.json` (ADR-006). Where a repo requires
34
- 100 %, treat it as a behavioural gate: an uncovered path is either a missing test
35
- or dead code, and both are defects.
34
+ - `fail_under` is set per repo in the repository's manifest (`{manifest}`) (ADR-006).
35
+ Where a repo requires 100 %, treat it as a behavioural gate: an uncovered path is
36
+ either a missing test or dead code, and both are defects.
36
37
  - No `pytest.skip` without a pre-existing written justification.
37
38
  - Test the narrowest layer that proves the behaviour; reach for an integration test
38
39
  only when the behaviour is genuinely cross-system.
@@ -8,7 +8,7 @@ simt-scope: ["Gemfile", "**/*.rb"]
8
8
 
9
9
  # Stack profile: Ruby
10
10
 
11
- Applies to: `has-gemfile` — a Bundled application or gem.
11
+ Applies to: a committed `Gemfile` — a Bundled application or gem.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["Cargo.toml", "src/**/*.rs"]
8
8
 
9
9
  # Stack profile: Rust
10
10
 
11
- Applies to: `has-cargo-toml` — a Cargo workspace or crate.
11
+ Applies to: a committed `Cargo.toml` — a Cargo workspace or crate.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -8,7 +8,7 @@ simt-scope: ["Package.swift", "**/*.swift"]
8
8
 
9
9
  # Stack profile: Swift
10
10
 
11
- Applies to: `has-swift-package` — a Swift package.
11
+ Applies to: a committed `Package.swift` — a Swift package.
12
12
 
13
13
  ## Build and gates
14
14
 
@@ -14,8 +14,8 @@ simt-fallback: true
14
14
 
15
15
  # Stack profile: TypeScript
16
16
 
17
- Applies to: `typescript`, together with the `node` profile when no more specific
18
- profile matches.
17
+ Applies to: `tsconfig.json` is committed with a TypeScript dependency. A repository
18
+ with no more specific profile receives the `node` rules alongside it.
19
19
 
20
20
  ## Types
21
21
 
@@ -11,8 +11,9 @@ simt-scope: ["uv.lock", "pyproject.toml"]
11
11
 
12
12
  # Stack profile: uv
13
13
 
14
- Applies to: `has-uv-lock` — a `uv.lock` is committed, here or in a workspace package.
15
- The language rules are in the `python` profile, which every Python repository gets.
14
+ Applies to: a committed `uv.lock`, here or in a workspace package. The language rules
15
+ are in the `python` profile (`simt rules show python`), which every Python repository
16
+ gets.
16
17
 
17
18
  ## Invocation
18
19
 
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: simt-commands
3
+ description: Command reference for simt-dev: which commands are mutating vs read-only, and a scenario-to-command mapping for the agent.
4
+ ---
5
+
6
+ ## Dry run
7
+
8
+ Most simt-dev commands that write to the repository default to **dry-run**:
9
+ they print what they would change and exit without writing. Add `--yes` to
10
+ apply. Read the dry-run output first — it is the plan — then re-run with
11
+ `--yes`. A dry-run that shows an unexpected file is a reason to stop, not to
12
+ add `--yes` and hope.
13
+
14
+ ### Mutating commands (need `--yes` to write)
15
+
16
+ `init`, `sync`, `plan done`, `slice start`, `slice finish`, `slice abandon`,
17
+ `agents install`, `agent-hooks install`, `hooks install`, `doctor --fix`,
18
+ `ignore`, `ci`, `shared install`, `lessons review`, `decide`, `undo`,
19
+ `skills install`, `bench mine|run|ablate|probes|variants`, `pack new|publish`,
20
+ `lease`
21
+
22
+ ### Read-only commands (no flag needed)
23
+
24
+ `brief`, `why`, `tests`, `gate`, `impact`, `map`, `check`, `status`,
25
+ `plan status|next`, `rules *`, `agents status`, `agent-hooks status`,
26
+ `hooks status`, `evidence`, `pr-body`, `explain`, `graph lint`,
27
+ `skills index|brief|show|why|find`, `index`, `review`, `tokens`,
28
+ `harness *`, `compare`, `distill`, `bench report|critic`, `pack lint`,
29
+ `mcp`, `completions`, `lessons list|capture`
30
+
31
+ ## Command reference
32
+
33
+ Pick the command by what you need, not by what you remember:
34
+
35
+ | You need to… | Command | Notes |
36
+ |---|---|---|
37
+ | Understand a task before exploring | `simt brief "<task>"` | Kind, commands, files, tests, decisions |
38
+ | Know what governs a file | `simt why <file>` | Decisions, rules, skills, gates, lessons |
39
+ | Find tests for a file | `simt tests [path]` | Import, conventional, or coverage |
40
+ | Find tests a change reaches | `simt gate [path...]` | `--changed` reads the diff |
41
+ | See who a change affects | `simt impact <file\|symbol>` | Importers, tests, contracts |
42
+ | Find files a task touches | `simt map --task "<text>"` | `--level outline\|names\|signatures` |
43
+ | Read tracker state | `simt plan status` | Counts per tracker file |
44
+ | Get the next slice | `simt plan next [<id>]` | Smallest ready slice, ~1 KB |
45
+ | Mark a slice done | `simt plan done <id> <sha> --test "<cmd>" --yes` | Dry-run without `--yes` |
46
+ | Start a slice in a worktree | `simt slice start <id> --yes` | Branch `slice/<id>`, brief beside it |
47
+ | Finish a slice | `simt slice finish <id> --yes` | Gates, commit, record SHA |
48
+ | Abandon a slice | `simt slice abandon <id> --yes` | Removes worktree, returns row |
49
+ | Check the gate | `simt check` | Projection drift, manifest, policy |
50
+ | Regenerate projection | `simt sync --yes` | Idempotent; `--check` for drift only |
51
+ | Explain a finding | `simt explain <CODE>` | Decision, fix, trigger |
52
+ | Read a rule | `simt rules show <id>` | One rule body |
53
+ | Read a decision | `simt rules why <ADR-NNN>` | `--all` for every ruling |
54
+ | Scaffold an ADR | `simt decide [<title>] --yes` | Next number, skeleton, frontmatter |
55
+ | Adopt a repo | `simt init [path] --yes` | `--backup` tags the pre-adoption commit |
56
+ | Install the agent | `simt agents install --yes` | `--harness <id>` for one |
57
+ | Check agent status | `simt agents status` | Present, absent, drift, no-file |
58
+ | Install hooks | `simt hooks install --yes` | Shared dispatcher |
59
+ | Run a hook event | `simt hooks run <event>` | As git would |
60
+ | Install agent hooks | `simt agent-hooks install --yes` | Per-harness policies |
61
+ | Diagnose config | `simt doctor [--fix]` | `--fix` applies safe remedies |
62
+ | Pin a skill | `simt skills install <name> --yes` | `--variant` for multi-variant |
63
+ | Search skills | `simt skills find <query>` | Capability and keyword |
64
+ | Rebuild the index | `simt index` | From git blob hashes |
65
+ | Get PR body | `simt pr-body [--base <ref>]` | Evidence, decisions, gate state |
66
+ | Check evidence | `simt evidence` | Runs recorded against the tree |
67
+ | Review the staged diff | `simt review` | ADR-008 verdict skeleton |
68
+ | Undo last writes | `simt undo --yes` | Only if every file is untouched |
69
+ | Claim files for a session | `simt lease [<paths>...] --yes` | TTL; pre-edit hook refuses |
70
+ | Measure token cost | `simt tokens` | Bytes a reader loads at session start |
@@ -98,7 +98,7 @@ export async function loadHistory(root) {
98
98
  task: record.task,
99
99
  model: record.model,
100
100
  resolved: isRunResolved(record),
101
- cost: computeRunCost(record, prices),
101
+ cost: computeRunCost(record, prices).usd,
102
102
  tokens: tokensOf(record.usage),
103
103
  toolCalls: Number(record?.usage?.toolCalls ?? record?.usage?.tool_calls ?? 0),
104
104
  files: Number(descriptor?.gold?.files ?? record?.diff?.files ?? 0),
@@ -159,8 +159,10 @@ export function advise(features, history) {
159
159
  // the pick is allowed to stand on it.
160
160
  const ladderValues = new Map();
161
161
  for (const run of runs) {
162
+ const v = measure === 'cost' ? run.cost : run.tokens;
163
+ if (v === null) continue; // an un-costed run cannot ground a cost floor
162
164
  const values = ladderValues.get(run.model) ?? [];
163
- values.push(measure === 'cost' ? run.cost : run.tokens);
165
+ values.push(v);
164
166
  ladderValues.set(run.model, values);
165
167
  }
166
168
  const ladderNames = [...ladderValues.keys()].sort(
@@ -170,8 +172,10 @@ export function advise(features, history) {
170
172
  const resolvedByModel = new Map();
171
173
  for (const run of pool) {
172
174
  if (!run.resolved) continue;
175
+ const v = measure === 'cost' ? run.cost : run.tokens;
176
+ if (v === null) continue;
173
177
  const values = resolvedByModel.get(run.model) ?? [];
174
- values.push(measure === 'cost' ? run.cost : run.tokens);
178
+ values.push(v);
175
179
  resolvedByModel.set(run.model, values);
176
180
  }
177
181
 
@@ -23,8 +23,9 @@ import { spawnSync } from 'node:child_process';
23
23
  import { readFile } from 'node:fs/promises';
24
24
  import { join, relative, resolve } from 'node:path';
25
25
 
26
- import { parse } from '../projection/markers.mjs';
27
- import { activeLeases, leasePathFor, readLeases } from '../lease.mjs';
26
+ import { lineRangeOf, parse, regionSpans } from '../projection/markers.mjs';
27
+ import { activeLeases, canonicalLeasePath, readLeases } from '../lease.mjs';
28
+ import { coreIgnoreCase } from '../repo-root.mjs';
28
29
  import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV, policiesFor } from './policy.mjs';
29
30
  import { applyAliases, commandFacts, GIT_BUILTINS } from './shell.mjs';
30
31
 
@@ -69,7 +70,13 @@ function applies(row, facts, env) {
69
70
  return row.regex !== null && row.regex.test(facts.command);
70
71
  }
71
72
  if (row.inspects === 'write') {
72
- return (facts.writes ?? []).some((w) => w.generated !== null && w.generated !== undefined);
73
+ // A file carrying a region denies only an edit that touches it: an Edit outside
74
+ // the markers, a Write that keeps the region bytes, or a patch whose hunks miss
75
+ // the span is the author's own text (review 0003 Q17). No edit detail means the
76
+ // location is unknown, which still denies.
77
+ return (facts.writes ?? []).some(
78
+ (w) => w.generated !== null && w.generated !== undefined && w.touches !== false,
79
+ );
73
80
  }
74
81
  if (row.inspects === 'lease') {
75
82
  const writes = facts.writes ?? [];
@@ -138,15 +145,116 @@ async function submodules(root) {
138
145
  * editing its source, a submodule by committing in the other repository.
139
146
  */
140
147
  export async function generatedBytes(root, file) {
148
+ const fact = await generatedFact(root, file, null);
149
+ return fact.generated;
150
+ }
151
+
152
+ /** Split an `old_string` into its occurrences, each as a line range. */
153
+ function occurrences(text, needle) {
154
+ if (typeof needle !== 'string' || needle === '') return [];
155
+ const ranges = [];
156
+ let from = 0;
157
+ for (;;) {
158
+ const index = text.indexOf(needle, from);
159
+ if (index === -1) return ranges;
160
+ ranges.push(lineRangeOf(text, index, needle.length));
161
+ from = index + Math.max(1, needle.length);
162
+ if (ranges.length > 64) return ranges;
163
+ }
164
+ }
165
+
166
+ function rangesOverlap(a, b) {
167
+ return a.start <= b.end && a.end >= b.start;
168
+ }
169
+
170
+ /** Line ranges an apply_patch body rewrites, per `@@ -a,b +c,d @@` headers. */
171
+ function patchRanges(patch) {
172
+ const ranges = [];
173
+ let numbered = false;
174
+ let unnumbered = false;
175
+ for (const line of String(patch ?? '').split('\n')) {
176
+ const m = /^@@\s*(?:-(\d+)(?:,(\d+))?\s*\+(\d+)(?:,(\d+))?)?\s*@@/.exec(line.trim());
177
+ if (m === null) continue;
178
+ if (m[1] === undefined) {
179
+ unnumbered = true;
180
+ continue;
181
+ }
182
+ numbered = true;
183
+ const start = Number(m[1]);
184
+ const count = m[2] === undefined ? 1 : Number(m[2]);
185
+ ranges.push({ start, end: start + Math.max(count, 1) - 1 });
186
+ }
187
+ return { ranges, numbered, unnumbered };
188
+ }
189
+
190
+ /**
191
+ * Whether an edit touches the generated region in `current`.
192
+ *
193
+ * `edit` is what a renderer extracted beside the path: `old_string`/`edits` for an
194
+ * in-place replacement, `content` for a whole-file Write, `patch` for an
195
+ * apply_patch body. Unknown shapes deny: without a location the check cannot prove
196
+ * the edit is outside. A malformed region also denies, because its span is unknown.
197
+ */
198
+ function touchesRegion(current, edit = {}) {
199
+ const { malformed, spans } = regionSpans(current);
200
+ if (spans.length === 0) return false;
201
+ if (malformed) return true;
202
+ const needles = [];
203
+ if (typeof edit?.old_string === 'string') needles.push(edit.old_string);
204
+ for (const e of edit?.edits ?? []) {
205
+ if (e && typeof e.old_string === 'string') needles.push(e.old_string);
206
+ }
207
+ if (needles.length > 0) {
208
+ // An `old_string` the file does not contain is an edit that changes nothing:
209
+ // no overlap, so no denial. Every occurrence is checked because the tool
210
+ // replaces all of them or the first, depending on the harness, and either way
211
+ // one overlap is enough to touch the region.
212
+ return needles.some((needle) => {
213
+ const found = occurrences(current, needle);
214
+ if (found.length === 0) return false;
215
+ return found.some((range) => spans.some((span) => rangesOverlap(range, span)));
216
+ });
217
+ }
218
+ if (typeof edit?.content === 'string') {
219
+ // A Write replaces the whole file: it touches the region only when the region
220
+ // bytes change. Each style present before must still be present with the same
221
+ // body afterwards.
222
+ for (const style of ['html', 'hash']) {
223
+ const before = parse(current, style);
224
+ if (before.status !== 'ok') continue;
225
+ const after = parse(edit.content, style);
226
+ if (after.status !== 'ok' || after.body !== before.body) return true;
227
+ }
228
+ return false;
229
+ }
230
+ if (typeof edit?.patch === 'string') {
231
+ const { ranges, numbered } = patchRanges(edit.patch);
232
+ if (!numbered) return true;
233
+ return ranges.some((range) => spans.some((span) => rangesOverlap(range, span)));
234
+ }
235
+ return true;
236
+ }
237
+
238
+ /**
239
+ * Why this path is not the author's to write, with whether the edit touches it.
240
+ *
241
+ * `edit` is the renderer's detail beside the path (possibly empty). Submodule
242
+ * writes always touch: another repository owns every byte under its directory.
243
+ */
244
+ async function generatedFact(root, file, edit = null) {
141
245
  const abs = resolve(root, file);
142
246
  const text = await readFile(abs, 'utf8').catch(() => null);
143
- if (text !== null && holdsRegion(text)) return 'region';
247
+ if (text !== null && holdsRegion(text)) {
248
+ return { path: file, generated: 'region', touches: touchesRegion(text, edit ?? {}) };
249
+ }
144
250
  const rel = relative(resolve(root), abs);
145
251
  if (!rel.startsWith('..')) {
146
252
  const inside = await submodules(root);
147
- if (inside.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) return 'submodule';
253
+ if (inside.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) {
254
+ return { path: file, generated: 'submodule', touches: true };
255
+ }
148
256
  }
149
- return null;
257
+ return { path: file, generated: null, touches: false };
150
258
  }
151
259
 
152
260
  /**
@@ -189,10 +297,25 @@ async function normalizeCommand(root, command) {
189
297
  return parsed;
190
298
  }
191
299
 
300
+ /** A write entry is a path string or a `{path, ...edit}` row from a renderer. */
301
+ function splitWrite(entry) {
302
+ if (typeof entry === 'string') return { path: entry, edit: {} };
303
+ if (entry && typeof entry === 'object' && typeof entry.path === 'string') {
304
+ const { path, ...edit } = entry;
305
+ return { path, edit };
306
+ }
307
+ return null;
308
+ }
309
+
192
310
  /** Turn a renderer's extraction into the facts the table asks about. */
193
311
  export async function factsFor(root, { command = null, writes = [] } = {}) {
194
312
  const resolved = await Promise.all(
195
- writes.filter(Boolean).map(async (path) => ({ path, generated: await generatedBytes(root, path) })),
313
+ writes.filter(Boolean).map(async (entry) => {
314
+ const split = splitWrite(entry);
315
+ if (split === null) return { path: String(entry), generated: null, touches: false };
316
+ const fact = await generatedFact(root, split.path, split.edit);
317
+ return { ...fact, edit: split.edit };
318
+ }),
196
319
  );
197
320
  // Shell facts, read only when the payload carried a command: the same "pay for what
198
321
  // you use" rule as the lease facts below, which a payload with no command cannot touch.
@@ -205,9 +328,18 @@ export async function factsFor(root, { command = null, writes = [] } = {}) {
205
328
  if (resolved.length === 0) return { command, writes: resolved, leaseError: null, shell };
206
329
  const read = await readLeases(root);
207
330
  if (read.error !== null) return { command, writes: resolved, leaseError: read.error, shell };
208
- const live = new Map(activeLeases(read).map((l) => [l.path, l]));
331
+ // Both sides of the comparison in the one spelling a claim and an edit can
332
+ // both reach: symlinks resolved, case folded when the repository's git
333
+ // compares case-insensitively. A claim made for `src/keep.mjs` must meet an
334
+ // edit that spells the same file `src/Keep.mjs` on a volume where those are
335
+ // one file, and an edit that reaches it through a symlinked directory.
336
+ const ignoreCase = coreIgnoreCase(root);
337
+ const live = new Map();
338
+ for (const l of activeLeases(read)) {
339
+ live.set(canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path, l);
340
+ }
209
341
  for (const w of resolved) {
210
- const rel = leasePathFor(root, w.path);
342
+ const rel = canonicalLeasePath(root, w.path, { ignoreCase });
211
343
  w.lease = rel === null ? null : live.get(rel) ?? null;
212
344
  }
213
345
  return { command, writes: resolved, leaseError: null, shell };
@@ -0,0 +1,72 @@
1
+ // The OpenCode v2 plugin this tool installs where the other harnesses get a
2
+ // config entry (opencode.ai/v2/docs/build/plugins/server, read 2026-10-29).
3
+ //
4
+ // OpenCode has no hooks file to merge into: a plugin under `.opencode/plugins/`
5
+ // is loaded as-is, so the tool owns this file's whole bytes and `rendered`
6
+ // compares them. The plugin is a thin pipe: it forwards each tool call to the
7
+ // same `agent-hook` entry point every other harness reaches, and translates
8
+ // the answer — a denial (exit 2, `{"reason": …}` on stdout) into a thrown
9
+ // error, which is how OpenCode blocks a tool call and shows the reason to the
10
+ // model. Any other outcome lets the call through: a broken policy must not
11
+ // lock a session, the same fail-open rule the shim follows.
12
+
13
+ /**
14
+ * The plugin source for one repository.
15
+ *
16
+ * @param {{command: string, prompt?: boolean}} opts `command` is the
17
+ * `agent-hook` entry point as one line (`<binary> pre-tool-use --harness
18
+ * opencode`); the event word is replaced per call, so the binary and the
19
+ * trailing flags must each be single words. `prompt` adds the session hook
20
+ * that prepends the delivery packet to every prompt.
21
+ */
22
+ export function renderOpencodePlugin({ command, prompt = false } = {}) {
23
+ const promptHook = prompt
24
+ ? `
25
+ await ctx.session.hook('prompt', async (event) => {
26
+ const result = ask('prompt-submit', { prompt: event.prompt.text, cwd: root });
27
+ if (result.status !== 0 || !result.stdout) return;
28
+ try {
29
+ const packet = JSON.parse(result.stdout);
30
+ if (packet && typeof packet.text === 'string' && packet.text) {
31
+ event.prompt.text = \`\${packet.text}\\n\\n\${event.prompt.text}\`;
32
+ }
33
+ } catch {
34
+ // a packet we cannot parse is no packet
35
+ }
36
+ });
37
+ `
38
+ : '';
39
+ return `// Generated by simt-dev — do not edit; reinstall with:
40
+ // simt agent-hooks install --yes
41
+
42
+ import { spawnSync } from 'node:child_process';
43
+
44
+ // The entry point, one word per argument. The event is the first word after
45
+ // the binary and is replaced per call; everything after it stands for every
46
+ // event, so neither may contain a space.
47
+ const [BIN, , ...REST] = ${JSON.stringify(command)}.split(' ');
48
+
49
+ export default {
50
+ id: 'simt-dev',
51
+ async setup(ctx) {
52
+ const root = ctx.location.directory;
53
+ const ask = (event, payload) =>
54
+ spawnSync(BIN, [event, ...REST], { input: JSON.stringify(payload), encoding: 'utf8', cwd: root });
55
+
56
+ await ctx.tool.hook('execute.before', async (event) => {
57
+ if (!['shell', 'write', 'edit'].includes(event.tool)) return;
58
+ const result = ask('pre-tool-use', { tool: event.tool, input: event.input, cwd: root });
59
+ if (result.status !== 2) return;
60
+ let reason = 'simt-dev blocked the tool call';
61
+ try {
62
+ const doc = JSON.parse(result.stdout);
63
+ if (doc && typeof doc.reason === 'string' && doc.reason) reason = doc.reason;
64
+ } catch {
65
+ // the default stands
66
+ }
67
+ throw new Error(reason);
68
+ });
69
+ ${promptHook} },
70
+ };
71
+ `;
72
+ }