simt-dev 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. 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
  17. 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
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. 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
  22. 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
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -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.
@@ -10,11 +10,9 @@ simt-priority: convention
10
10
  simt-scope: ["app.json", "metro.config.js", "ios/**", "android/**"]
11
11
  ---
12
12
 
13
- # Stack profile: React Native
14
-
15
- Applies to: `react-native` — a `react-native` or `expo` dependency. A repository whose
16
- UI is rendered in a browser gets the `react-web` profile instead; see that profile for
17
- the rules this one does not repeat.
13
+ # Stack profile: React NativeApplies to: `react-native` — a `react-native` or `expo` dependency. A repository whose
14
+ UI is rendered in a browser follows the browser rules instead; nothing here assumes a
15
+ DOM, a stylesheet cascade, or URL-shared state.
18
16
 
19
17
  ## What does not apply here
20
18
 
@@ -17,8 +17,10 @@ UI is rendered in a browser.
17
17
 
18
18
  ## Types
19
19
 
20
- - `strict` plus the full strictness set listed in the Workers profile. Generated API DTOs only;
21
- never hand-write a duplicate of a type the API already produces.
20
+ - `strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
21
+ `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noImplicitReturns`,
22
+ `isolatedModules`. Generated API DTOs only; never hand-write a duplicate of a
23
+ type the API already produces.
22
24
  - No `any`, no unsafe casts, no non-null boundary bypasses, no floating promises, no
23
25
  `console.*`.
24
26
 
@@ -39,10 +41,9 @@ UI is rendered in a browser.
39
41
 
40
42
  ## RTL and localisation
41
43
 
42
- - Arabic mirrors application chrome only. North, coordinate order, geographic pan and zoom,
43
- map symbols, media and universal status meaning do not mirror.
44
- - Every user-visible string exists in every supported locale in the same change. Adding a
45
- locale touches tests, which assert locale parity.
44
+ - RTL locales mirror application chrome only. North, coordinate order, geographic pan and
45
+ zoom, map symbols, media and universal status meaning do not mirror.
46
+ - Every user-visible string exists in every supported locale in the same change.
46
47
 
47
48
  ## Accessibility
48
49
 
@@ -58,6 +59,5 @@ UI is rendered in a browser.
58
59
  - Assert behaviour, not implementation. Never weaken an assertion, an accessibility check or a
59
60
  fixture to match current behaviour.
60
61
  - No arbitrary sleeps. No `.only`. No committed `.skip`. No flaky reruns.
61
- - Inject a sentinel token, UID, text, gender, coordinate, URL and private media value, and
62
- prove its absence from unauthorised UI, storage, cache, telemetry, URL history and
63
- artifacts.
62
+ - Inject sentinel values (a token, an id, a coordinate, a URL) and prove their absence
63
+ from unauthorised UI, storage, cache, telemetry, URL history and artifacts.
@@ -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
 
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // Pure, so the whole decision is testable without a harness: hand it the facts a
4
4
  // renderer would extract and an environment, and it answers. The filesystem is
5
- // consulted in exactly one place — `generatedBytes`, which asks whether a file being
6
- // written to holds bytes this tool generates — because that is the one fact a payload
7
- // cannot carry.
5
+ // consulted in `factsFor` for exactly the facts a payload cannot carry — whether a
6
+ // file holds bytes this tool generates, who has leased it (ADR-101), and what the
7
+ // author's git aliases expand to (ADR-104) — and nothing else decides from disk.
8
8
  //
9
9
  // Two properties are deliberate and both are about an author standing still:
10
10
  //
@@ -19,11 +19,15 @@
19
19
  // understand this" into "blocked" makes the hook a false-positive machine, and a hook
20
20
  // that blocks real work is uninstalled the same afternoon.
21
21
 
22
+ import { spawnSync } from 'node:child_process';
22
23
  import { readFile } from 'node:fs/promises';
23
24
  import { join, relative, resolve } from 'node:path';
24
25
 
25
- import { parse } from '../projection/markers.mjs';
26
- import { ALLOW_ENV, EVENTS, POLICY_IDS, policiesFor } from './policy.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';
29
+ import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV, policiesFor } from './policy.mjs';
30
+ import { applyAliases, commandFacts, GIT_BUILTINS } from './shell.mjs';
27
31
 
28
32
  /**
29
33
  * The policies this session has lifted, and the names in `SIMT_ALLOW` that are not
@@ -42,13 +46,44 @@ export function overrides(env = {}) {
42
46
  };
43
47
  }
44
48
 
45
- /** Whether one row applies to the facts. A row with no matcher reads the facts itself. */
46
- function applies(row, facts) {
49
+ /**
50
+ * Whether one row applies to the facts. A row with no matcher reads the facts itself.
51
+ *
52
+ * `env` is only consulted by `lease`, which is the one row asking who is doing this
53
+ * rather than what is being done — and the one row with a fact the payload never
54
+ * carries: an unreadable claim file denies a write, but a shell command with no file
55
+ * fact is not an edit (ADR-101 ruling 4).
56
+ *
57
+ * A command row is judged on the normalised facts when they exist: the shell tokenised,
58
+ * wrappers unwrapped, each git invocation reduced to subcommand and options (ADR-104).
59
+ * When the command could not be tokenised, or the facts were never normalised at all —
60
+ * a test that hands `decide` a bare command string — the row's regex runs instead, so
61
+ * a parse failure and a pre-normalised fact are both judged the way they were before
62
+ * the normaliser existed.
63
+ */
64
+ function applies(row, facts, env) {
47
65
  if (row.inspects === 'command') {
48
- return typeof facts.command === 'string' && row.match !== null && row.match.test(facts.command);
66
+ if (typeof facts.command !== 'string') return false;
67
+ if (facts.shell !== null && facts.shell !== undefined && facts.shell.parsed === true) {
68
+ return row.match(facts);
69
+ }
70
+ return row.regex !== null && row.regex.test(facts.command);
49
71
  }
50
72
  if (row.inspects === 'write') {
51
- 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
+ );
80
+ }
81
+ if (row.inspects === 'lease') {
82
+ const writes = facts.writes ?? [];
83
+ if (writes.length === 0) return false;
84
+ if (facts.leaseError) return true;
85
+ const session = typeof env[SESSION_ENV] === 'string' ? env[SESSION_ENV] : '';
86
+ return writes.some((w) => w.lease && w.lease.session !== session);
52
87
  }
53
88
  return false;
54
89
  }
@@ -62,7 +97,7 @@ function applies(row, facts) {
62
97
  */
63
98
  export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
64
99
  const { ids: lifted, unknown } = overrides(env);
65
- const matched = policiesFor(event).filter((p) => applies(p, facts));
100
+ const matched = policiesFor(event).filter((p) => applies(p, facts, env));
66
101
  const blocked = matched.filter((p) => !lifted.has(p.id));
67
102
  return {
68
103
  event,
@@ -70,7 +105,14 @@ export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
70
105
  blocked: blocked.map((p) => p.id),
71
106
  lifted: matched.filter((p) => lifted.has(p.id)).map((p) => p.id),
72
107
  unknown,
73
- messages: blocked.map((p) => `${p.message} (override: ${ALLOW_ENV}=${p.id})`),
108
+ // A row may carry a `detail` for the parts of its message that are only knowable
109
+ // at decision time (the lease holder's name and expiry, ADR-101 ruling 5). Every
110
+ // row without one renders exactly as before, override hint included.
111
+ messages: blocked.map((p) => {
112
+ const detail = typeof p.detail === 'function' ? p.detail(facts, env) : null;
113
+ const body = detail ? `${p.message} ${detail}` : p.message;
114
+ return `${body} (override: ${ALLOW_ENV}=${p.id})`;
115
+ }),
74
116
  adrs: blocked.map((p) => p.adr),
75
117
  };
76
118
  }
@@ -103,13 +145,164 @@ async function submodules(root) {
103
145
  * editing its source, a submodule by committing in the other repository.
104
146
  */
105
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) {
106
245
  const abs = resolve(root, file);
107
246
  const text = await readFile(abs, 'utf8').catch(() => null);
108
- if (text !== null && holdsRegion(text)) return 'region';
247
+ if (text !== null && holdsRegion(text)) {
248
+ return { path: file, generated: 'region', touches: touchesRegion(text, edit ?? {}) };
249
+ }
109
250
  const rel = relative(resolve(root), abs);
110
251
  if (!rel.startsWith('..')) {
111
252
  const inside = await submodules(root);
112
- 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
+ }
256
+ }
257
+ return { path: file, generated: null, touches: false };
258
+ }
259
+
260
+ /**
261
+ * The author's git aliases, name to value, as `git config` spells them.
262
+ *
263
+ * One spawn for the whole command, and only when a fact needs it: the lookup reads the
264
+ * configuration the agent's own `git p` would read, and a git that is missing or cannot
265
+ * answer yields no aliases, which leaves the facts exactly where the pre-lookup policy
266
+ * would have found them.
267
+ */
268
+ function gitAliases(root) {
269
+ const result = spawnSync('git', ['-C', root, 'config', '--get-regexp', '^alias\\.'], {
270
+ encoding: 'utf8',
271
+ timeout: 1000,
272
+ });
273
+ const aliases = new Map();
274
+ if (result.status !== 0) return aliases;
275
+ for (const line of String(result.stdout ?? '').split('\n')) {
276
+ const m = /^alias\.(\S+)\s+(.*)$/.exec(line);
277
+ if (m !== null) aliases.set(m[1], m[2]);
278
+ }
279
+ return aliases;
280
+ }
281
+
282
+ /**
283
+ * The normalised command facts the table asks about: the shell tokenised, wrappers
284
+ * unwrapped, each git or rm invocation reduced to what it runs.
285
+ *
286
+ * A subcommand git does not ship may be the author's alias, and what it expands to is
287
+ * read from their git configuration - the one step in this path that leaves the
288
+ * module. A command that cannot be tokenised is marked, not dropped: the policies fall
289
+ * back to their regex rather than seeing nothing (ADR-104).
290
+ */
291
+ async function normalizeCommand(root, command) {
292
+ const parsed = commandFacts(command); // non-null: `command` is a string here
293
+ if (parsed === null || !parsed.parsed) return { parsed: false };
294
+ if (parsed.facts.some((f) => f.kind === 'git' && !GIT_BUILTINS.has(f.sub))) {
295
+ parsed.facts = applyAliases(parsed.facts, gitAliases(root));
296
+ }
297
+ return parsed;
298
+ }
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 };
113
306
  }
114
307
  return null;
115
308
  }
@@ -117,7 +310,37 @@ export async function generatedBytes(root, file) {
117
310
  /** Turn a renderer's extraction into the facts the table asks about. */
118
311
  export async function factsFor(root, { command = null, writes = [] } = {}) {
119
312
  const resolved = await Promise.all(
120
- 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
+ }),
121
319
  );
122
- return { command, writes: resolved };
320
+ // Shell facts, read only when the payload carried a command: the same "pay for what
321
+ // you use" rule as the lease facts below, which a payload with no command cannot touch.
322
+ const shell = typeof command === 'string' ? await normalizeCommand(root, command) : null;
323
+ // Lease facts, read only when the payload named a file to write: the claim file is
324
+ // irrelevant to a shell command, and a payload with no file pays nothing for a
325
+ // feature it cannot touch. Expired claims are dropped here because the hook runs
326
+ // now, so a TTL needs no sweeper; ownership is left whole because `decide` is the
327
+ // one that knows which session is asking (ADR-101 ruling 3).
328
+ if (resolved.length === 0) return { command, writes: resolved, leaseError: null, shell };
329
+ const read = await readLeases(root);
330
+ if (read.error !== null) return { command, writes: resolved, leaseError: read.error, shell };
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
+ }
341
+ for (const w of resolved) {
342
+ const rel = canonicalLeasePath(root, w.path, { ignoreCase });
343
+ w.lease = rel === null ? null : live.get(rel) ?? null;
344
+ }
345
+ return { command, writes: resolved, leaseError: null, shell };
123
346
  }
@@ -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
+ }