simt-dev 0.2.2 → 0.3.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 (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  4. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  5. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  6. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  7. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  8. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  9. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  10. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  12. package/package.json +2 -1
  13. package/src/agenthooks/decide.mjs +123 -0
  14. package/src/agenthooks/policy.mjs +78 -0
  15. package/src/agenthooks/render.mjs +240 -0
  16. package/src/argv.mjs +36 -1
  17. package/src/bench/ablate.mjs +630 -0
  18. package/src/bench/adapters.json +149 -0
  19. package/src/bench/grade.mjs +229 -0
  20. package/src/bench/mine.mjs +811 -0
  21. package/src/bench/probes.mjs +1350 -0
  22. package/src/bench/report.mjs +899 -0
  23. package/src/bench/run.mjs +1002 -0
  24. package/src/commands/agenthook.mjs +282 -0
  25. package/src/commands/bench.mjs +359 -0
  26. package/src/commands/harness.mjs +156 -5
  27. package/src/commands/help.mjs +58 -1
  28. package/src/commands/index.mjs +25 -1
  29. package/src/commands/plan.mjs +99 -0
  30. package/src/commands/tokens.mjs +31 -0
  31. package/src/decisions/index.mjs +23 -5
  32. package/src/kernel/card.mjs +254 -0
  33. package/src/kernel/inferability.mjs +188 -0
  34. package/src/mcp/resources.mjs +157 -0
  35. package/src/mcp/server.mjs +37 -6
  36. package/src/mcp/tools.mjs +84 -0
  37. package/src/measure/context.mjs +177 -6
  38. package/src/measure/loaderView.mjs +435 -0
  39. package/src/measure/session.mjs +267 -0
  40. package/src/plan/index.mjs +650 -0
  41. package/src/plan/template.mjs +331 -0
  42. package/src/projection/loaders.mjs +689 -0
  43. package/src/projection/manifest.mjs +26 -3
  44. package/src/projection/rules.mjs +80 -5
  45. package/src/projection/surfaces.mjs +22 -8
  46. package/src/toolwords.mjs +69 -0
  47. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,240 @@
1
+ // One renderer per harness: the payload in, the decision out, and the config written.
2
+ //
3
+ // Four harnesses read a hook and no two agree on anything. The config path, the event
4
+ // name, the shape of the document, the tool name for a shell command, the key holding
5
+ // the command, and the JSON a hook answers with are all different, and none of them is
6
+ // the same fact. So this module owns the differences and `policy.mjs` owns none of
7
+ // them:
8
+ //
9
+ // claude .claude/settings.json PreToolUse {"hooks":{E:[{matcher,hooks:[h]}]}}
10
+ // codex .codex/hooks.json PreToolUse {"hooks":{E:[{matcher,hooks:[h]}]}}
11
+ // cursor .cursor/hooks.json preToolUse {"version":1,"hooks":{E:[h]}}
12
+ // antigravity .agents/hooks.json PreToolUse {"<name>":{E:[{matcher,hooks:[h]}]}}
13
+ //
14
+ // The paths are not written here. They come from the reader table (R14) because that is
15
+ // where each one was verified against its vendor page, and a second copy of a verified
16
+ // fact is a copy that drifts the first time a vendor moves the file.
17
+ //
18
+ // Two things are recorded per row that a reader of the code cannot get from the code:
19
+ // the page the shape was read from, and whether anything about the row is inferred.
20
+ // An inferred key is marked as inferred rather than presented as documented, because
21
+ // the failure that matters is not a missing policy — it is a policy that silently never
22
+ // fires in a harness whose payload we guessed wrong.
23
+
24
+ import { CLI } from '../name.mjs';
25
+ import { LOADERS } from '../projection/loaders.mjs';
26
+ import { EVENTS } from './policy.mjs';
27
+
28
+ /** The seconds a hook is allowed before the harness gives up on it. */
29
+ const TIMEOUT_SECONDS = 10;
30
+
31
+ /**
32
+ * The hook's own entry point, as it appears inside every harness's config.
33
+ *
34
+ * A repository that has this tool has its binary on PATH, and `--harness` is explicit
35
+ * rather than detected from the payload: a hook that guessed would answer in the wrong
36
+ * harness's dialect when a payload arrived in an unexpected shape, and the harness would
37
+ * read a decision it does not understand as no decision at all.
38
+ */
39
+ export function hookCommand(id) {
40
+ return `${CLI} agent-hook ${EVENTS[0]} --harness ${id}`;
41
+ }
42
+
43
+ /** The path a config file lives at, from the reader table rather than a second list. */
44
+ export function hookPath(id) {
45
+ const row = LOADERS.find((l) => l.id === id);
46
+ return row?.hooks?.path ?? null;
47
+ }
48
+
49
+ /** The reader rows that document a hooks file, and the ones that document none. */
50
+ export function hookRows() {
51
+ return LOADERS.map((l) => ({
52
+ id: l.id,
53
+ label: l.label,
54
+ path: l.hooks?.path ?? null,
55
+ note: l.hooks ? l.hooks.verified : (l.hooksNote ?? 'no hooks file is documented'),
56
+ }));
57
+ }
58
+
59
+ const ADAPTERS = {
60
+ claude: {
61
+ id: 'claude',
62
+ event: 'PreToolUse',
63
+ matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit',
64
+ denyExit: 2,
65
+ verified: 'docs.claude.com/en/docs/claude-code/hooks, read 2026-10-16',
66
+ root: (p) => p.cwd,
67
+ extract: (p) => ({
68
+ command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
69
+ writes: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(p.tool_name)
70
+ ? [p.tool_input?.file_path].filter(Boolean)
71
+ : [],
72
+ }),
73
+ deny: (message) => ({
74
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
75
+ }),
76
+ },
77
+ codex: {
78
+ id: 'codex',
79
+ event: 'PreToolUse',
80
+ matcher: 'Bash|apply_patch',
81
+ denyExit: 2,
82
+ verified: 'developers.openai.com/codex/hooks, read 2026-10-16',
83
+ // Codex is the one harness that will not run a hook until a person has approved the
84
+ // exact definition, keyed by its hash — so an install that does not say this leaves
85
+ // the repository with a hook that never fires and no way to tell why.
86
+ trust: 'Codex skips a hook nobody has trusted: review it with /hooks, or the entry stays inert.',
87
+ root: (p) => p.cwd,
88
+ extract: (p) => ({
89
+ command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
90
+ // apply_patch carries its targets inside the patch text, one header line per file.
91
+ writes:
92
+ p.tool_name === 'apply_patch'
93
+ ? [...String(p.tool_input?.command ?? '').matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm)].map((m) => m[1].trim())
94
+ : [],
95
+ }),
96
+ deny: (message) => ({
97
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
98
+ }),
99
+ },
100
+ cursor: {
101
+ id: 'cursor',
102
+ event: 'preToolUse',
103
+ matcher: 'Shell|Write|Edit',
104
+ denyExit: 2,
105
+ verified: 'cursor.com/docs/agent/hooks, read 2026-10-16',
106
+ root: (p) => p.working_directory ?? p.cwd,
107
+ extract: (p) => ({
108
+ command: p.tool_name === 'Shell' ? (p.tool_input?.command ?? null) : null,
109
+ writes: ['Write', 'Edit'].includes(p.tool_name)
110
+ ? [p.tool_input?.file_path ?? p.tool_input?.path].filter(Boolean)
111
+ : [],
112
+ }),
113
+ deny: (message) => ({ permission: 'deny', user_message: message, agent_message: message }),
114
+ },
115
+ antigravity: {
116
+ id: 'antigravity',
117
+ event: 'PreToolUse',
118
+ matcher: 'run_command|write_to_file|replace_file_content|multi_replace_file_content',
119
+ // The page documents the decision JSON and not an exit code, so the denial travels
120
+ // in the JSON only: a non-zero exit whose meaning the vendor has not stated is not
121
+ // a block, it is a guess that happens to be loud.
122
+ denyExit: 0,
123
+ verified: 'antigravity.google/docs/hooks, read 2026-10-16',
124
+ root: (p) => p.workspacePaths?.[0] ?? p.cwd,
125
+ extract: (p) => {
126
+ const call = p.toolCall ?? {};
127
+ const args = call.args ?? {};
128
+ return {
129
+ command: call.name === 'run_command' ? (args.CommandLine ?? null) : null,
130
+ writes: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(call.name)
131
+ ? [args.TargetFile].filter(Boolean)
132
+ : [],
133
+ };
134
+ },
135
+ deny: (message) => ({ decision: 'deny', reason: message }),
136
+ },
137
+ };
138
+
139
+ /** The four adapters, in the order the reader table lists them. */
140
+ export function adapters() {
141
+ return hookRows()
142
+ .filter((r) => r.path !== null)
143
+ .map((r) => ADAPTERS[r.id])
144
+ .filter(Boolean);
145
+ }
146
+
147
+ export function adapter(id) {
148
+ const row = ADAPTERS[id];
149
+ return row && hookPath(id) !== null ? row : null;
150
+ }
151
+
152
+ /** The facts a payload carries, and the root it names. An unknown shape yields none. */
153
+ export function factsFrom(row, payload) {
154
+ return { root: row.root(payload) ?? process.cwd(), ...row.extract(payload ?? {}) };
155
+ }
156
+
157
+ /** The document to print for a decision, or null when there is nothing to say. */
158
+ export function decisionJson(row, decision) {
159
+ if (decision.decision !== 'deny') return null;
160
+ return row.deny(decision.messages.join('\n'));
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // The config file: reading ours out of somebody else's JSON, and putting it back.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** The name antigravity's document keys its hooks by; its events live under a name. */
168
+ const NAMED_HOOK = 'simt-dev-policies';
169
+
170
+ /** Where a document keeps its event keys. Antigravity is the odd one: by name. */
171
+ function container(row, config, { create = false } = {}) {
172
+ if (row.id === 'antigravity') {
173
+ if (config[NAMED_HOOK] === undefined && create) config[NAMED_HOOK] = {};
174
+ return config[NAMED_HOOK] ?? null;
175
+ }
176
+ // Before `hooks`, so a file this tool creates reads the way the vendor's own example
177
+ // does rather than with the version key appended wherever it happened to land.
178
+ if (row.id === 'cursor' && config.version === undefined && create) config.version = 1;
179
+ if (config.hooks === undefined && create) config.hooks = {};
180
+ return config.hooks ?? null;
181
+ }
182
+
183
+ /** The group this tool owns for one event, in that harness's shape. */
184
+ function group(row) {
185
+ const command = hookCommand(row.id);
186
+ if (row.id === 'cursor') {
187
+ // failClosed is set because a hooks file this tool wrote should not silently
188
+ // degrade to fail-open on its own crash: the vendor's default turns a broken hook
189
+ // into an absent one, and the whole point of writing it is that it runs.
190
+ return { command, timeout: TIMEOUT_SECONDS, matcher: row.matcher, failClosed: true };
191
+ }
192
+ return { matcher: row.matcher, hooks: [{ type: 'command', command, timeout: TIMEOUT_SECONDS }] };
193
+ }
194
+
195
+ /** Whether a group is this tool's, judged by the command it runs and not by position. */
196
+ function ours(entry) {
197
+ return JSON.stringify(entry).includes(`${CLI} agent-hook`);
198
+ }
199
+
200
+ function entries(row, config) {
201
+ const box = container(row, config);
202
+ const list = box?.[row.event];
203
+ return Array.isArray(list) ? list : [];
204
+ }
205
+
206
+ /** Our entries in this document, for `status`; the document is not modified. */
207
+ export function installedIn(row, config) {
208
+ return entries(row, config).filter(ours);
209
+ }
210
+
211
+ /**
212
+ * The document with this tool's hook added, everything else left alone.
213
+ *
214
+ * Written as JSON with two spaces and a trailing newline because that is what a file in
215
+ * a repository reads like; a config that is rewritten with different indentation on
216
+ * every run is a diff nobody can review. Keys this tool does not own are preserved
217
+ * exactly, including their order, because the file belongs to the repository.
218
+ */
219
+ function withHook(row, config) {
220
+ const box = container(row, config, { create: true });
221
+ const kept = entries(row, config).filter((e) => !ours(e));
222
+ box[row.event] = [...kept, group(row)];
223
+ return config;
224
+ }
225
+
226
+ /** The text `install` would write, or the reason the existing file cannot be read. */
227
+ export function rendered(row, existing) {
228
+ if (existing === null) return { text: `${JSON.stringify(withHook(row, {}), null, 2)}\n`, action: 'created' };
229
+ let config;
230
+ try {
231
+ config = JSON.parse(existing);
232
+ } catch (e) {
233
+ return { error: `not valid JSON (${e.message}); left alone` };
234
+ }
235
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
236
+ return { error: 'not a JSON object; left alone' };
237
+ }
238
+ const text = `${JSON.stringify(withHook(row, config), null, 2)}\n`;
239
+ return { text, action: text === existing ? 'unchanged' : 'updated' };
240
+ }
package/src/argv.mjs CHANGED
@@ -51,6 +51,14 @@ export const BOOL_FLAGS = new Set([
51
51
  // command whose output changes because something was installed elsewhere is one a
52
52
  // reader stops trusting.
53
53
  'user',
54
+ // harness loaders: print the vendor facts (order, caps, imports, hooks and MCP
55
+ // config) without measuring a repository. The measurement is the other mode; this is
56
+ // the one that answers "what does this reader do", which does not depend on the repo.
57
+ 'table',
58
+ // sync: name every line the inferability filter dropped and the evidence behind it
59
+ // (plan 0003 R09). Off by default because a count is enough to act on; the per-line
60
+ // detail is what an author needs, and a reader is not an author.
61
+ 'explain',
54
62
  ]);
55
63
 
56
64
  export const VALUE_FLAGS = new Set([
@@ -71,6 +79,27 @@ export const VALUE_FLAGS = new Set([
71
79
  // silently applied an unnamed set of rules is the decoration this repository exists
72
80
  // to refuse. One is on offer today, so the command rejects any other by name.
73
81
  'spec',
82
+ // harness loaders: which reader to report. A value rather than a boolean because
83
+ // "--loader" would not say WHICH loader, and a report that silently applied to all
84
+ // of them would be decoration that misdescribes the artefact.
85
+ 'reader',
86
+ // harness loaders: measure as if the agent were standing here. The chain rows walk
87
+ // the project root down to the working directory, so this is what makes them mean
88
+ // anything beyond the root document.
89
+ 'at',
90
+ // tokens: split a local Claude Code or Codex session transcript into rules, file
91
+ // reads and tool output. Local paths only (ADR-075).
92
+ 'session',
93
+ // bench run / report: arms, harness adapter, model id, repetition count, regrade target and arm comparison.
94
+ 'arm',
95
+ 'harness',
96
+ 'model',
97
+ 'reps',
98
+ 'regrade',
99
+ 'compare',
100
+ // plan done: the test command that proves the slice. Plan 0003 R31.
101
+ 'test',
102
+ 'state',
74
103
  ]);
75
104
 
76
105
  /**
@@ -86,7 +115,7 @@ export const VALUE_FLAGS = new Set([
86
115
  * is a superset of the old behaviour, not a replacement: `--skill a,b` parsed before it
87
116
  * parses now.
88
117
  */
89
- export const REPEATABLE_FLAGS = new Set(['skill']);
118
+ export const REPEATABLE_FLAGS = new Set(['skill', 'arm', 'compare']);
90
119
 
91
120
  /**
92
121
  * @param {string[]} argv raw arguments, excluding node and the script path
@@ -140,6 +169,12 @@ export function parse(argv) {
140
169
  return { ok: false, code: usage(`--${raw} must be a non-negative integer, received ${value}`) };
141
170
  }
142
171
  flags[camel(raw)] = n;
172
+ } else if (raw === 'reps') {
173
+ const n = Number(value);
174
+ if (!Number.isInteger(n) || n < 1) {
175
+ return { ok: false, code: usage(`--reps must be a positive integer, received ${value}`) };
176
+ }
177
+ flags.reps = n;
143
178
  } else {
144
179
  const key = camel(raw);
145
180
  flags[key] = REPEATABLE_FLAGS.has(raw)