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
@@ -20,6 +20,7 @@
20
20
  // they can ask about.
21
21
 
22
22
  import { CLI } from '../name.mjs';
23
+ import { HOOKS_DIR, HOOKS_PATH_KEY } from '../hooks/shim.mjs';
23
24
 
24
25
  /**
25
26
  * The events an agent-time hook can answer, normalised across harnesses.
@@ -35,12 +36,29 @@ export const EVENTS = ['pre-tool-use', 'prompt-submit', 'post-tool-use'];
35
36
  /** The environment variable that lifts named policies for one session. */
36
37
  export const ALLOW_ENV = 'SIMT_ALLOW';
37
38
 
39
+ /**
40
+ * The environment variable that names the session a claim was filed under and the
41
+ * session an edit is asked for (R54).
42
+ *
43
+ * One name in both places on purpose: a denial is only explainable if the session the
44
+ * hook protected is the same name `simt lease` recorded, and two spellings would drift
45
+ * into a refusal nobody can reconcile with a claim that exists.
46
+ */
47
+ export const SESSION_ENV = 'SIMT_SESSION';
48
+
38
49
  /**
39
50
  * One row per policy.
40
51
  *
41
- * `inspects` names the fact the matcher is handed, and `match` the condition. The
42
- * `generated` row has no matcher because what it asks — does this file hold bytes this
43
- * tool generates — is answered by reading the file, not by the text of a payload.
52
+ * `inspects` names the fact the matcher is handed, and `match` the condition. A command
53
+ * row carries two matchers because the facts come in two states: `match` reads the
54
+ * normalised command - the shell tokenised, wrappers unwrapped, each git invocation
55
+ * reduced to subcommand and options (ADR-104) - and `regex` is the fallback that runs
56
+ * when the command cannot be tokenised at all, so a parse failure never weakens a
57
+ * policy. Facts that were never normalised - a test that hands `decide` a bare command
58
+ * string - also take the fallback, which is why it stays in the table rather than
59
+ * beside the tokenizer. The `generated` row has no matcher because what it asks — does
60
+ * this file hold bytes this tool generates — is answered by reading the file, not by
61
+ * the text of a payload.
44
62
  *
45
63
  * Order matters where two policies match one command: `git push --force` is both a
46
64
  * push and a rewrite, so both are reported and both have to be lifted before it runs.
@@ -51,7 +69,8 @@ const POLICIES = [
51
69
  adr: 'ADR-001',
52
70
  event: 'pre-tool-use',
53
71
  inspects: 'command',
54
- match: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
72
+ match: (facts) => facts.shell.facts.some((f) => f.kind === 'git' && (f.sub === 'push' || f.sub === 'send-pack')),
73
+ regex: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
55
74
  message: `Push needs explicit approval (ADR-001); ask the user.`,
56
75
  },
57
76
  {
@@ -59,12 +78,50 @@ const POLICIES = [
59
78
  adr: 'ADR-017',
60
79
  event: 'pre-tool-use',
61
80
  inspects: 'command',
62
- match:
81
+ match: (facts) =>
82
+ facts.shell.facts.some((f) => {
83
+ if (f.kind !== 'git') return false;
84
+ if (f.opts.has('--no-verify')) return true;
85
+ if (f.sub === 'commit' && (f.opts.has('--amend') || f.opts.has('n'))) return true; // -n is --no-verify
86
+ if (f.sub === 'reset' && f.opts.has('--hard')) return true;
87
+ if (f.sub === 'rebase') return true;
88
+ if (f.sub === 'pull' && f.opts.has('--rebase')) return true; // pull --rebase rebases
89
+ return (
90
+ (f.sub === 'push' || f.sub === 'send-pack') &&
91
+ (f.opts.has('--force') || f.opts.has('f') || f.refspecs.some((r) => r.startsWith('+')))
92
+ );
93
+ }),
94
+ regex:
63
95
  /--no-verify|git\s+commit\b[^;&|]*\s--amend|git\s+reset\s+--hard|git\s+rebase\b|git\s+push\b[^;&|]*(?:--force\b|\s-f\b)/,
64
96
  message:
65
97
  `Never amend, rebase, reset --hard, force-push or --no-verify unless asked; ` +
66
98
  `each is the escape that turns the declared gates into a suggestion (ADR-017).`,
67
99
  },
100
+ {
101
+ id: 'hooks-disable',
102
+ adr: 'ADR-104',
103
+ event: 'pre-tool-use',
104
+ inspects: 'command',
105
+ // No regex fallback: before ADR-104 this policy did not exist, so a command that
106
+ // cannot be tokenised is judged exactly as it was judged before this row was written.
107
+ regex: null,
108
+ match: (facts) => {
109
+ const dir = (a) => a.replace(/^\.\/+/, '').replace(/\/+$/, '');
110
+ const key = HOOKS_PATH_KEY.toLowerCase(); // config keys are case-insensitive in git
111
+ return facts.shell.facts.some((f) => {
112
+ if (f.kind === 'git') {
113
+ if (f.globals.c.some((s) => s.toLowerCase().startsWith(`${key}=`))) return true;
114
+ if (f.sub === 'config' && f.refspecs.some((r) => r.toLowerCase() === key || r.toLowerCase().startsWith(`${key}.`))) return true;
115
+ return false;
116
+ }
117
+ if (f.kind === 'rm') return f.args.some((a) => dir(a) === HOOKS_DIR || dir(a).startsWith(`${HOOKS_DIR}/`));
118
+ return false;
119
+ });
120
+ },
121
+ message:
122
+ `That removes or reroutes the git hooks this repository's gates run in, so the gates would stop ` +
123
+ `running without anyone saying so; if it is deliberate, run \`${CLI} hooks install --yes\` afterwards (ADR-104).`,
124
+ },
68
125
  {
69
126
  id: 'generated',
70
127
  adr: 'ADR-036',
@@ -75,6 +132,25 @@ const POLICIES = [
75
132
  `That file carries bytes this tool generates, so an edit there is overwritten on the next ` +
76
133
  `write; edit the source the region comes from, then run \`${CLI} sync --yes\` (ADR-036).`,
77
134
  },
135
+ {
136
+ id: 'lease',
137
+ adr: 'ADR-101',
138
+ event: 'pre-tool-use',
139
+ inspects: 'lease',
140
+ match: null,
141
+ message: `That path is leased by another session (ADR-101); wait for its TTL or ask the holder to release it.`,
142
+ // The only row whose message is not a constant: what the blocked session needs to
143
+ // hear — which path, whose, until when — is read from the claim file at decision
144
+ // time, and a static row cannot carry a timestamp it has never seen.
145
+ detail: (facts, env) => {
146
+ if (facts.leaseError) return `${facts.leaseError}; fix or remove it so the claims are readable`;
147
+ const mine = env[SESSION_ENV] ?? '';
148
+ const held = (facts.writes ?? [])
149
+ .filter((w) => w.lease && w.lease.session !== mine)
150
+ .map((w) => `${w.path} held by ${w.lease.session} until ${new Date(w.lease.expiresAt).toISOString()}`);
151
+ return held.length > 0 ? `held: ${held.join('; ')}` : '';
152
+ },
153
+ },
78
154
  ];
79
155
 
80
156
  /** The policy ids, in table order: the vocabulary an override is checked against. */
@@ -26,6 +26,7 @@ import { readManifest } from '../projection/manifest.mjs';
26
26
  import { DEFAULT_BRIEF_BYTES, PROMPT_CAP_BYTES } from '../brief/packet.mjs';
27
27
  import { assembleBrief } from '../brief/assemble.mjs';
28
28
  import { deliveryJson, factsFrom, promptText } from './render.mjs';
29
+ import { repoRoot } from '../repo-root.mjs';
29
30
 
30
31
  /**
31
32
  * Whether this repository asked for a delivered packet, and the ceiling it asked for.
@@ -63,8 +64,11 @@ export async function deliverPrompt(row, payload) {
63
64
  const task = promptText(row, payload);
64
65
  if (task === null) return { skipped: 'the payload carries no prompt this harness documents' };
65
66
 
66
- // The root is the same dialect fact for both events, so it is read the same way.
67
- const root = factsFrom(row, payload).root;
67
+ // The root is the same dialect fact for both events, so it is read the same way —
68
+ // and resolved to the repository's top level the same way: the manifest, like the
69
+ // lease file, is a repository-relative fact, and a prompt submitted from a
70
+ // subdirectory must find it (review 0003 Q16).
71
+ const root = repoRoot(factsFrom(row, payload).root);
68
72
  const optIn = await promptOptIn(root);
69
73
  if (!optIn.optedIn) return { skipped: optIn.why };
70
74
 
@@ -1,6 +1,6 @@
1
1
  // One renderer per harness: the payload in, the decision out, and the config written.
2
2
  //
3
- // Five harnesses read a hook and no two agree on anything. The config path, the event
3
+ // Six harnesses read a hook and no two agree on anything. The config path, the event
4
4
  // name, the shape of the document, the tool name for a shell command, the key holding
5
5
  // the command, and the JSON a hook answers with are all different, and none of them is
6
6
  // the same fact. So this module owns the differences and `policy.mjs` owns none of
@@ -11,9 +11,10 @@
11
11
  // cursor .cursor/hooks.json preToolUse {"version":1,"hooks":{E:[h]}}
12
12
  // antigravity .agents/hooks.json PreToolUse {"<name>":{E:[{matcher,hooks:[h]}]}}
13
13
  // gemini .gemini/settings.json BeforeTool {"hooks":{E:[{matcher,hooks:[h]}]}}
14
+ // opencode .opencode/plugins/ execute.before a JS plugin the tool owns whole
14
15
  //
15
16
  // The prompt-submit event (R29) is a second table, and it is shorter because the vendors
16
- // are: three of the five document a prompt event that can put text into the model's context,
17
+ // are: four of the six document a prompt event that can put text into the model's context,
17
18
  // and a delivery is the only thing this tool does there. None takes a matcher — all
18
19
  // document that field for tool events, and a matcher a harness ignores is a matcher the
19
20
  // next person believes is filtering something.
@@ -21,6 +22,7 @@
21
22
  // claude .claude/settings.json UserPromptSubmit hookSpecificOutput.additionalContext
22
23
  // codex .codex/hooks.json UserPromptSubmit hookSpecificOutput.additionalContext
23
24
  // gemini .gemini/settings.json BeforeAgent hookSpecificOutput.additionalContext
25
+ // opencode .opencode/plugins/ prompt event.prompt.text, prepended
24
26
  // cursor .cursor/hooks.json beforeSubmitPrompt CANNOT INJECT: block-only
25
27
  // antigravity .agents/hooks.json (no prompt event documented)
26
28
  //
@@ -46,7 +48,9 @@
46
48
 
47
49
  import { CLI } from '../name.mjs';
48
50
  import { LOADERS } from '../projection/loaders.mjs';
51
+ import { renderOpencodePlugin } from './opencode.mjs';
49
52
  import { EVENTS } from './policy.mjs';
53
+ import { AGENT_HOOK_SHIM } from './shim.mjs';
50
54
 
51
55
  /** The seconds a hook is allowed before the harness gives up on it. */
52
56
  const TIMEOUT_SECONDS = 10;
@@ -54,13 +58,15 @@ const TIMEOUT_SECONDS = 10;
54
58
  /**
55
59
  * The hook's own entry point, as it appears inside every harness's config.
56
60
  *
57
- * A repository that has this tool has its binary on PATH, and `--harness` is explicit
58
- * rather than detected from the payload: a hook that guessed would answer in the wrong
59
- * harness's dialect when a payload arrived in an unexpected shape, and the harness would
61
+ * The shim, never the bare binary: a harness does not share the shell's PATH,
62
+ * so a config naming `simt` runs nothing on Claude and blocks everything on
63
+ * Cursor (review 0003 Q21). `--harness` stays explicit rather than detected
64
+ * from the payload: a hook that guessed would answer in the wrong harness's
65
+ * dialect when a payload arrived in an unexpected shape, and the harness would
60
66
  * read a decision it does not understand as no decision at all.
61
67
  */
62
68
  export function hookCommand(id, event = EVENTS[0]) {
63
- return `${CLI} agent-hook ${event} --harness ${id}`;
69
+ return `${AGENT_HOOK_SHIM} ${event} --harness ${id}`;
64
70
  }
65
71
 
66
72
  /** The path a config file lives at, from the reader table rather than a second list. */
@@ -79,6 +85,104 @@ export function hookRows() {
79
85
  }));
80
86
  }
81
87
 
88
+ /**
89
+ * The files an apply_patch body writes, one per header line.
90
+ *
91
+ * `Update`/`Add`/`Delete` name their file on the header itself. A move is the
92
+ * one header whose target is not on its own line: `*** Move File: <old>` is
93
+ * followed by the destination on the `+++` line, and a body that spells the
94
+ * destination as `*** Move to: <new>` says it outright. A move with neither
95
+ * spelling still writes its source out of the way, so the source is the floor
96
+ * a move guards.
97
+ */
98
+ function asString(value) {
99
+ return typeof value === 'string' && value !== '' ? value : null;
100
+ }
101
+
102
+ function editFields(input = {}) {
103
+ const edit = {};
104
+ const oldString = asString(input.old_string ?? input.oldString ?? input.OldText ?? input.old_text);
105
+ const newString = asString(input.new_string ?? input.newString ?? input.NewText ?? input.new_text);
106
+ const content = asString(input.content ?? input.CodeContent ?? input.file_content ?? input.new_source ?? input.newSource);
107
+ if (oldString !== null) edit.old_string = oldString;
108
+ if (newString !== null) edit.new_string = newString;
109
+ if (content !== null) edit.content = content;
110
+ if (Array.isArray(input.edits)) {
111
+ const edits = input.edits
112
+ .filter((e) => e && typeof e === 'object')
113
+ .map((e) => ({
114
+ old_string: asString(e.old_string ?? e.oldString),
115
+ new_string: asString(e.new_string ?? e.newString),
116
+ }))
117
+ .filter((e) => e.old_string !== null || e.new_string !== null);
118
+ if (edits.length > 0) edit.edits = edits;
119
+ }
120
+ return edit;
121
+ }
122
+
123
+ function writeOf(path, input) {
124
+ if (!path) return null;
125
+ return { path, ...editFields(input ?? {}) };
126
+ }
127
+
128
+ /**
129
+ * The files an apply_patch body writes, one per header line.
130
+ *
131
+ * `Update`/`Add`/`Delete` name their file on the header itself. A move is the
132
+ * one header whose target is not on its own line: `*** Move File: <old>` is
133
+ * followed by the destination on the `+++` line, and a body that spells the
134
+ * destination as `*** Move to: <new>` says it outright. A move with neither
135
+ * spelling still writes its source out of the way, so the source is the floor
136
+ * a move guards.
137
+ *
138
+ * Each entry keeps its hunk text: without it the generated-region check cannot
139
+ * tell an edit outside the region from one inside it (review 0003 Q17), and
140
+ * every patch would deny as though it touched the region.
141
+ */
142
+ function patchWrites(text) {
143
+ return patchFiles(text).map((f) => ({ path: f.path, patch: f.hunks }));
144
+ }
145
+
146
+ function patchFiles(text) {
147
+ const lines = String(text ?? '').split('\n');
148
+ const files = [];
149
+ let current = null;
150
+ const push = () => {
151
+ if (current !== null) files.push(current);
152
+ current = null;
153
+ };
154
+ for (let i = 0; i < lines.length; i += 1) {
155
+ const line = lines[i].trim();
156
+ const direct = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/.exec(line);
157
+ if (direct !== null) {
158
+ push();
159
+ current = { path: direct[1].trim(), hunks: '' };
160
+ continue;
161
+ }
162
+ const moveTo = /^\*\*\* Move to: (.+)$/.exec(line);
163
+ if (moveTo !== null) {
164
+ push();
165
+ current = { path: moveTo[1].trim(), hunks: '' };
166
+ continue;
167
+ }
168
+ const moveFile = /^\*\*\* Move File: (.+)$/.exec(line);
169
+ if (moveFile !== null) {
170
+ push();
171
+ const next = lines[i + 1]?.trim();
172
+ const dest = next !== undefined && next.startsWith('+++ ') ? next.slice(4).trim() : moveFile[1].trim();
173
+ current = { path: dest, hunks: '' };
174
+ continue;
175
+ }
176
+ if (/^\*\*\* (Begin Patch|End Patch)/.test(line)) {
177
+ if (line.includes('End Patch')) push();
178
+ continue;
179
+ }
180
+ if (current !== null) current.hunks += `${lines[i]}\n`;
181
+ }
182
+ push();
183
+ return files.filter((f) => f.path !== '');
184
+ }
185
+
82
186
  const ADAPTERS = {
83
187
  claude: {
84
188
  id: 'claude',
@@ -89,9 +193,15 @@ const ADAPTERS = {
89
193
  root: (p) => p.cwd,
90
194
  extract: (p) => ({
91
195
  command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
92
- writes: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(p.tool_name)
93
- ? [p.tool_input?.file_path].filter(Boolean)
94
- : [],
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
+ : [],
95
205
  }),
96
206
  deny: (message) => ({
97
207
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
@@ -124,11 +234,13 @@ const ADAPTERS = {
124
234
  root: (p) => p.cwd,
125
235
  extract: (p) => ({
126
236
  command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
127
- // apply_patch carries its targets inside the patch text, one header line per file.
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).
128
240
  writes:
129
241
  p.tool_name === 'apply_patch'
130
- ? [...String(p.tool_input?.command ?? '').matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm)].map((m) => m[1].trim())
131
- : [],
242
+ ? patchWrites(String(p.tool_input?.command ?? ''))
243
+ : [], // entries carry {path, patch} so the region check maps hunks (Q17)
132
244
  }),
133
245
  deny: (message) => ({
134
246
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
@@ -155,7 +267,7 @@ const ADAPTERS = {
155
267
  extract: (p) => ({
156
268
  command: p.tool_name === 'Shell' ? (p.tool_input?.command ?? null) : null,
157
269
  writes: ['Write', 'Edit'].includes(p.tool_name)
158
- ? [p.tool_input?.file_path ?? p.tool_input?.path].filter(Boolean)
270
+ ? [writeOf(p.tool_input?.file_path ?? p.tool_input?.path, p.tool_input)].filter(Boolean)
159
271
  : [],
160
272
  }),
161
273
  deny: (message) => ({ permission: 'deny', user_message: message, agent_message: message }),
@@ -181,8 +293,8 @@ const ADAPTERS = {
181
293
  return {
182
294
  command: call.name === 'run_command' ? (args.CommandLine ?? null) : null,
183
295
  writes: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(call.name)
184
- ? [args.TargetFile].filter(Boolean)
185
- : [],
296
+ ? [writeOf(args.TargetFile, args)].filter(Boolean)
297
+ : [], // CodeContent/old/new travel with the path for the region check (Q17)
186
298
  };
187
299
  },
188
300
  deny: (message) => ({ decision: 'deny', reason: message }),
@@ -207,7 +319,9 @@ const ADAPTERS = {
207
319
  root: (p) => p.cwd,
208
320
  extract: (p) => ({
209
321
  command: p.tool_name === 'run_shell_command' ? (p.tool_input?.command ?? null) : null,
210
- writes: ['write_file', 'replace'].includes(p.tool_name) ? [p.tool_input?.file_path].filter(Boolean) : [],
322
+ writes: ['write_file', 'replace'].includes(p.tool_name)
323
+ ? [writeOf(p.tool_input?.file_path, p.tool_input)].filter(Boolean)
324
+ : [],
211
325
  }),
212
326
  deny: (message) => ({ decision: 'deny', reason: message }),
213
327
  // BeforeAgent appends text to the prompt for this turn only — the same lifetime as a
@@ -225,9 +339,34 @@ const ADAPTERS = {
225
339
  context: (text) => ({ hookSpecificOutput: { additionalContext: text } }),
226
340
  },
227
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 }),
364
+ },
365
+ post: null,
366
+ },
228
367
  };
229
368
 
230
- /** The five adapters, in the order the reader table lists them. */
369
+ /** The six adapters, in the order the reader table lists them. */
231
370
  export function adapters() {
232
371
  return hookRows()
233
372
  .filter((r) => r.path !== null)
@@ -240,6 +379,17 @@ export function adapter(id) {
240
379
  return row && hookPath(id) !== null ? row : null;
241
380
  }
242
381
 
382
+ /**
383
+ * The session a payload claims, when it names one. Harness hook processes rarely
384
+ * carry `SIMT_SESSION`, so an agent is session `''` and is blocked by its own
385
+ * lease — the payload's own session id is the fallback (review 0003 Q20).
386
+ */
387
+ export function payloadSession(payload) {
388
+ if (!payload || typeof payload !== 'object') return null;
389
+ const direct = payload.session_id ?? payload.sessionId ?? payload.session?.id ?? payload.tool_input?.session_id ?? null;
390
+ return typeof direct === 'string' && direct.trim() !== '' ? direct : null;
391
+ }
392
+
243
393
  /**
244
394
  * The facts a payload carries, and the root it names. An unknown shape yields none.
245
395
  *
@@ -252,7 +402,11 @@ export function adapter(id) {
252
402
  */
253
403
  export function factsFrom(row, payload, { root } = {}) {
254
404
  const override = typeof root === 'string' && root.trim() !== '' ? root : null;
255
- return { root: override ?? row.root(payload) ?? process.cwd(), ...row.extract(payload ?? {}) };
405
+ return {
406
+ root: override ?? row.root(payload) ?? process.cwd(),
407
+ session: payloadSession(payload),
408
+ ...row.extract(payload ?? {}),
409
+ };
256
410
  }
257
411
 
258
412
  /** The document to print for a decision, or null when there is nothing to say. */
@@ -352,6 +506,9 @@ function group(row, event = EVENTS[0], command = hookCommand(row.id, event)) {
352
506
  // failClosed is set because a hooks file this tool wrote should not silently
353
507
  // degrade to fail-open on its own crash: the vendor's default turns a broken hook
354
508
  // into an absent one, and the whole point of writing it is that it runs.
509
+ // It is set only because the command is the repo shim (review 0003 Q21):
510
+ // failClosed on a bare-PATH binary would block every tool call on the machines
511
+ // where the binary is not installed, which is exactly the machines the shim exists for.
355
512
  return { command, timeout, matcher: row.matcher, failClosed: true };
356
513
  }
357
514
  return { matcher: row.matcher, hooks: [{ type: 'command', command, timeout }] };
@@ -359,7 +516,10 @@ function group(row, event = EVENTS[0], command = hookCommand(row.id, event)) {
359
516
 
360
517
  /** Whether a group is this tool's, judged by the command it runs and not by position. */
361
518
  function ours(entry) {
362
- return JSON.stringify(entry).includes(`${CLI} agent-hook`);
519
+ const text = JSON.stringify(entry);
520
+ // The shim spelling first; the bare-binary spelling is the pre-shim install,
521
+ // still recognised (and migrated on the next install) for one release.
522
+ return text.includes(AGENT_HOOK_SHIM) || text.includes(`${CLI} agent-hook`);
363
523
  }
364
524
 
365
525
  function entries(row, config, event = EVENTS[0]) {
@@ -399,6 +559,12 @@ function withHook(row, config, { prompt = false, post = false, command } = {}) {
399
559
 
400
560
  /** The text `install` would write, or the reason the existing file cannot be read. */
401
561
  export function rendered(row, existing, options = {}) {
562
+ if (row.file) {
563
+ // The tool owns this file whole, so there is nothing to merge: the text is
564
+ // the plugin source and `updated` means only that the bytes differ.
565
+ const text = renderOpencodePlugin({ command: options.command ?? hookCommand(row.id), prompt: options.prompt });
566
+ return { text, action: existing === null ? 'created' : text === existing ? 'unchanged' : 'updated' };
567
+ }
402
568
  if (existing === null) return { text: `${JSON.stringify(withHook(row, {}, options), null, 2)}\n`, action: 'created' };
403
569
  let config;
404
570
  try {
@@ -422,6 +588,7 @@ export function rendered(row, existing, options = {}) {
422
588
  export function hookDocument(id, { command, prompt = false, post = false } = {}) {
423
589
  const row = adapter(id);
424
590
  if (!row) return null;
591
+ if (row.file) return renderOpencodePlugin({ command: command ?? hookCommand(id), prompt });
425
592
  const config = withHook(row, {}, { prompt, post, command });
426
593
  return `${JSON.stringify(config, null, 2)}\n`;
427
594
  }