simt-dev 0.2.1 → 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.
- package/CHANGELOG.md +239 -0
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What a policy decides, given facts and one session's overrides.
|
|
2
|
+
//
|
|
3
|
+
// Pure, so the whole decision is testable without a harness: hand it the facts a
|
|
4
|
+
// renderer would extract and an environment, and it answers. The filesystem is
|
|
5
|
+
// consulted in exactly one place — `generatedBytes`, which asks whether a file being
|
|
6
|
+
// written to holds bytes this tool generates — because that is the one fact a payload
|
|
7
|
+
// cannot carry.
|
|
8
|
+
//
|
|
9
|
+
// Two properties are deliberate and both are about an author standing still:
|
|
10
|
+
//
|
|
11
|
+
// Every matching policy is reported, not the first. `git push --force` is a push and a
|
|
12
|
+
// rewrite; a build that stopped at the first match would let `SIMT_ALLOW=push` lift the
|
|
13
|
+
// approval rule and carry a force-push through, because the rewrite rule was never
|
|
14
|
+
// consulted. Lifting a policy lifts that policy.
|
|
15
|
+
//
|
|
16
|
+
// No fact is not a violation. A payload whose shape we did not read — a harness that
|
|
17
|
+
// renamed a tool, an agent writing through some tool we have never seen — yields no
|
|
18
|
+
// command and no path, and the decision is to say nothing. Turning "we do not
|
|
19
|
+
// understand this" into "blocked" makes the hook a false-positive machine, and a hook
|
|
20
|
+
// that blocks real work is uninstalled the same afternoon.
|
|
21
|
+
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { join, relative, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { parse } from '../projection/markers.mjs';
|
|
26
|
+
import { ALLOW_ENV, EVENTS, POLICY_IDS, policiesFor } from './policy.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The policies this session has lifted, and the names in `SIMT_ALLOW` that are not
|
|
30
|
+
* policies at all.
|
|
31
|
+
*
|
|
32
|
+
* An unknown name is reported rather than ignored: `SIMT_ALLOW=push,rewrit` typed once
|
|
33
|
+
* and relied on would otherwise look like a lift that did nothing, and the author would
|
|
34
|
+
* conclude the hook was broken rather than the spelling.
|
|
35
|
+
*/
|
|
36
|
+
export function overrides(env = {}) {
|
|
37
|
+
const raw = env[ALLOW_ENV];
|
|
38
|
+
const named = typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
39
|
+
return {
|
|
40
|
+
ids: new Set(named.filter((n) => POLICY_IDS.includes(n))),
|
|
41
|
+
unknown: named.filter((n) => !POLICY_IDS.includes(n)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether one row applies to the facts. A row with no matcher reads the facts itself. */
|
|
46
|
+
function applies(row, facts) {
|
|
47
|
+
if (row.inspects === 'command') {
|
|
48
|
+
return typeof facts.command === 'string' && row.match !== null && row.match.test(facts.command);
|
|
49
|
+
}
|
|
50
|
+
if (row.inspects === 'write') {
|
|
51
|
+
return (facts.writes ?? []).some((w) => w.generated !== null && w.generated !== undefined);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The decision for one event.
|
|
58
|
+
*
|
|
59
|
+
* `blocked` and `lifted` are ids, not rows, because the caller prints them and an id is
|
|
60
|
+
* what a reader types into `SIMT_ALLOW`. The messages carry their own override hint, so
|
|
61
|
+
* a denial is actionable without the reader knowing the variable exists.
|
|
62
|
+
*/
|
|
63
|
+
export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
|
|
64
|
+
const { ids: lifted, unknown } = overrides(env);
|
|
65
|
+
const matched = policiesFor(event).filter((p) => applies(p, facts));
|
|
66
|
+
const blocked = matched.filter((p) => !lifted.has(p.id));
|
|
67
|
+
return {
|
|
68
|
+
event,
|
|
69
|
+
decision: blocked.length > 0 ? 'deny' : 'allow',
|
|
70
|
+
blocked: blocked.map((p) => p.id),
|
|
71
|
+
lifted: matched.filter((p) => lifted.has(p.id)).map((p) => p.id),
|
|
72
|
+
unknown,
|
|
73
|
+
messages: blocked.map((p) => `${p.message} (override: ${ALLOW_ENV}=${p.id})`),
|
|
74
|
+
adrs: blocked.map((p) => p.adr),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether this file's bytes are generated: a region this tool writes, or a submodule. */
|
|
79
|
+
function holdsRegion(text) {
|
|
80
|
+
// Both marker styles, because the region is written in whichever one the file's own
|
|
81
|
+
// syntax allows — `<!-- simt:begin -->` in markdown, `# simt:begin` in an
|
|
82
|
+
// .editorconfig. A malformed region is still a region: a begin marker with no end is
|
|
83
|
+
// the file somebody has already been editing by hand.
|
|
84
|
+
return parse(text).status !== 'absent' || parse(text, 'hash').status !== 'absent';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The submodule paths `.gitmodules` declares, as repository-relative directories. */
|
|
88
|
+
async function submodules(root) {
|
|
89
|
+
const text = await readFile(join(root, '.gitmodules'), 'utf8').catch(() => null);
|
|
90
|
+
if (text === null) return [];
|
|
91
|
+
return text
|
|
92
|
+
.split('\n')
|
|
93
|
+
.map((line) => /^\s*path\s*=\s*(.+?)\s*$/.exec(line))
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((m) => m[1].replace(/\/+$/, ''));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why this path is not the author's to write, or null when it is.
|
|
100
|
+
*
|
|
101
|
+
* The reason is returned rather than a boolean because the two answers are different
|
|
102
|
+
* facts and the author of a fix needs to know which one applies: a region is repaired by
|
|
103
|
+
* editing its source, a submodule by committing in the other repository.
|
|
104
|
+
*/
|
|
105
|
+
export async function generatedBytes(root, file) {
|
|
106
|
+
const abs = resolve(root, file);
|
|
107
|
+
const text = await readFile(abs, 'utf8').catch(() => null);
|
|
108
|
+
if (text !== null && holdsRegion(text)) return 'region';
|
|
109
|
+
const rel = relative(resolve(root), abs);
|
|
110
|
+
if (!rel.startsWith('..')) {
|
|
111
|
+
const inside = await submodules(root);
|
|
112
|
+
if (inside.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) return 'submodule';
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Turn a renderer's extraction into the facts the table asks about. */
|
|
118
|
+
export async function factsFor(root, { command = null, writes = [] } = {}) {
|
|
119
|
+
const resolved = await Promise.all(
|
|
120
|
+
writes.filter(Boolean).map(async (path) => ({ path, generated: await generatedBytes(root, path) })),
|
|
121
|
+
);
|
|
122
|
+
return { command, writes: resolved };
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The policies an agent-time hook enforces, in one table.
|
|
2
|
+
//
|
|
3
|
+
// One table and four harnesses. The renderers in ./render.mjs map a harness's own
|
|
4
|
+
// payload onto the facts named here and a decision back onto that harness's own JSON,
|
|
5
|
+
// so a policy is written once and cannot be enforced in one harness and forgotten in
|
|
6
|
+
// another — which is the failure the four vendor documents make easy: they agree on
|
|
7
|
+
// nothing, not the config path, not the shape, not the name of the tool that runs a
|
|
8
|
+
// shell command (Bash, Shell, run_command), not the key holding the command, not the
|
|
9
|
+
// JSON the hook answers with. Every one of those differences lives in the renderer
|
|
10
|
+
// beside the vendor page it was read from; the table only knows facts.
|
|
11
|
+
//
|
|
12
|
+
// The facts are deliberately few: the shell command an agent is about to run, and the
|
|
13
|
+
// path it is about to write. A payload the renderer cannot recognise yields no facts,
|
|
14
|
+
// and no fact is not a violation — guessing from a shape we did not read would block
|
|
15
|
+
// real work in a harness whose payload changed under us.
|
|
16
|
+
//
|
|
17
|
+
// The messages are the bytes an author reads while being stopped, so each one names
|
|
18
|
+
// the rule, the record that settled it, and the override that lifts it. An override
|
|
19
|
+
// nobody is told about is a policy the author learns to route around instead of one
|
|
20
|
+
// they can ask about.
|
|
21
|
+
|
|
22
|
+
import { CLI } from '../name.mjs';
|
|
23
|
+
|
|
24
|
+
/** The events an agent-time hook can answer, normalised across harnesses. */
|
|
25
|
+
export const EVENTS = ['pre-tool-use'];
|
|
26
|
+
|
|
27
|
+
/** The environment variable that lifts named policies for one session. */
|
|
28
|
+
export const ALLOW_ENV = 'SIMT_ALLOW';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One row per policy.
|
|
32
|
+
*
|
|
33
|
+
* `inspects` names the fact the matcher is handed, and `match` the condition. The
|
|
34
|
+
* `generated` row has no matcher because what it asks — does this file hold bytes this
|
|
35
|
+
* tool generates — is answered by reading the file, not by the text of a payload.
|
|
36
|
+
*
|
|
37
|
+
* Order matters where two policies match one command: `git push --force` is both a
|
|
38
|
+
* push and a rewrite, so both are reported and both have to be lifted before it runs.
|
|
39
|
+
*/
|
|
40
|
+
const POLICIES = [
|
|
41
|
+
{
|
|
42
|
+
id: 'push',
|
|
43
|
+
adr: 'ADR-001',
|
|
44
|
+
event: 'pre-tool-use',
|
|
45
|
+
inspects: 'command',
|
|
46
|
+
match: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
|
|
47
|
+
message: `Push needs explicit approval (ADR-001); ask the user.`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'rewrite',
|
|
51
|
+
adr: 'ADR-017',
|
|
52
|
+
event: 'pre-tool-use',
|
|
53
|
+
inspects: 'command',
|
|
54
|
+
match:
|
|
55
|
+
/--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)/,
|
|
56
|
+
message:
|
|
57
|
+
`Never amend, rebase, reset --hard, force-push or --no-verify unless asked; ` +
|
|
58
|
+
`each is the escape that turns the declared gates into a suggestion (ADR-017).`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'generated',
|
|
62
|
+
adr: 'ADR-036',
|
|
63
|
+
event: 'pre-tool-use',
|
|
64
|
+
inspects: 'write',
|
|
65
|
+
match: null,
|
|
66
|
+
message:
|
|
67
|
+
`That file carries bytes this tool generates, so an edit there is overwritten on the next ` +
|
|
68
|
+
`write; edit the source the region comes from, then run \`${CLI} sync --yes\` (ADR-036).`,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** The policy ids, in table order: the vocabulary an override is checked against. */
|
|
73
|
+
export const POLICY_IDS = POLICIES.map((p) => p.id);
|
|
74
|
+
|
|
75
|
+
/** The rows for one event, so a renderer and the table cannot disagree about events. */
|
|
76
|
+
export function policiesFor(event) {
|
|
77
|
+
return POLICIES.filter((p) => p.event === event);
|
|
78
|
+
}
|
|
@@ -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
|
@@ -34,9 +34,31 @@ export const BOOL_FLAGS = new Set([
|
|
|
34
34
|
'plan',
|
|
35
35
|
'help',
|
|
36
36
|
'version',
|
|
37
|
+
// JSON output: indent it. Off by default because a pipe is not a person, and the
|
|
38
|
+
// bytes an agent pays for indentation are bytes it never reads.
|
|
39
|
+
'pretty',
|
|
37
40
|
'accept-conflicts',
|
|
38
41
|
'backup',
|
|
39
42
|
'list',
|
|
43
|
+
// tokens: measure every repository under the root, not just the one. Plan 0003 R06
|
|
44
|
+
// names this flag `--workspace`, and that name has been a VALUE flag since plan
|
|
45
|
+
// 0002 - it is where the skill sources live. One flag with two meanings is the
|
|
46
|
+
// silent-ignore defect this file's header refuses, so the meter's switch is named
|
|
47
|
+
// for what it selects.
|
|
48
|
+
'repos',
|
|
49
|
+
// harness detect: also report the directories in the user's home. Off by default
|
|
50
|
+
// because the home directory belongs to the reader rather than the repository, and a
|
|
51
|
+
// command whose output changes because something was installed elsewhere is one a
|
|
52
|
+
// reader stops trusting.
|
|
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',
|
|
40
62
|
]);
|
|
41
63
|
|
|
42
64
|
export const VALUE_FLAGS = new Set([
|
|
@@ -52,6 +74,32 @@ export const VALUE_FLAGS = new Set([
|
|
|
52
74
|
'variant',
|
|
53
75
|
'from',
|
|
54
76
|
'surfaces',
|
|
77
|
+
// graph lint: which specification to check the skills against. A value rather than a
|
|
78
|
+
// boolean because "lint against the spec" would not say WHICH spec, and a lint that
|
|
79
|
+
// silently applied an unnamed set of rules is the decoration this repository exists
|
|
80
|
+
// to refuse. One is on offer today, so the command rejects any other by name.
|
|
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',
|
|
55
103
|
]);
|
|
56
104
|
|
|
57
105
|
/**
|
|
@@ -67,7 +115,7 @@ export const VALUE_FLAGS = new Set([
|
|
|
67
115
|
* is a superset of the old behaviour, not a replacement: `--skill a,b` parsed before it
|
|
68
116
|
* parses now.
|
|
69
117
|
*/
|
|
70
|
-
export const REPEATABLE_FLAGS = new Set(['skill']);
|
|
118
|
+
export const REPEATABLE_FLAGS = new Set(['skill', 'arm', 'compare']);
|
|
71
119
|
|
|
72
120
|
/**
|
|
73
121
|
* @param {string[]} argv raw arguments, excluding node and the script path
|
|
@@ -121,6 +169,12 @@ export function parse(argv) {
|
|
|
121
169
|
return { ok: false, code: usage(`--${raw} must be a non-negative integer, received ${value}`) };
|
|
122
170
|
}
|
|
123
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;
|
|
124
178
|
} else {
|
|
125
179
|
const key = camel(raw);
|
|
126
180
|
flags[key] = REPEATABLE_FLAGS.has(raw)
|