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