simt-dev 0.6.3 → 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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -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
+ }
@@ -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