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.
- package/CHANGELOG.md +37 -0
- package/bin/simt-dev.mjs +21 -2
- 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/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +36 -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/harness.mjs +156 -5
- package/src/commands/help.mjs +58 -1
- package/src/commands/index.mjs +25 -1
- package/src/commands/plan.mjs +99 -0
- package/src/commands/tokens.mjs +31 -0
- package/src/decisions/index.mjs +23 -5
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +177 -6
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -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 +26 -3
- package/src/projection/rules.mjs +80 -5
- package/src/projection/surfaces.mjs +22 -8
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# ADR-079 — The rules are enforced at the tool call, from one policy table every harness renders
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-16 · filed as R15 (plan 0003)
|
|
4
|
+
|
|
5
|
+
Affects: `src/agenthooks/policy.mjs`, `src/agenthooks/decide.mjs`, `src/agenthooks/render.mjs`,
|
|
6
|
+
`src/commands/agenthook.mjs`, `src/commands/index.mjs`, `src/commands/help.mjs`,
|
|
7
|
+
`bin/simt-dev.mjs`, `test/agenthooks.test.mjs`
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The rules this tool projects reach an agent as text, and text is advice. Three of them are
|
|
12
|
+
broken by habit rather than by disagreement — a `git push`, a `--no-verify`, an edit inside a
|
|
13
|
+
region that is regenerated — and the reader breaking one is an agent that never sees the
|
|
14
|
+
commit that would have caught it. R15 turns those three into a decision at the tool call.
|
|
15
|
+
|
|
16
|
+
Four harnesses document a pre-tool hook that receives JSON on stdin and answers with a
|
|
17
|
+
decision (each page read 2026-10-16):
|
|
18
|
+
|
|
19
|
+
| Reader | File | What it does with a decision |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| Claude Code | `.claude/settings.json` | `hookSpecificOutput.permissionDecision: "deny"`, exit 2 also blocks |
|
|
22
|
+
| Cursor | `.cursor/hooks.json` | `{"permission": "deny"}`; exit 2 blocks; a permission hook blocks on invalid JSON even when `failClosed` is false |
|
|
23
|
+
| Codex | `.codex/hooks.json` | the same `hookSpecificOutput` shape as Claude Code, or the older `{"decision": "block"}` |
|
|
24
|
+
| Antigravity | `.agents/hooks.json` | `{"decision": "deny", "reason"}`; the page documents no exit code |
|
|
25
|
+
|
|
26
|
+
They agree on the mechanism and on nothing else. The path differs; Antigravity's file is keyed
|
|
27
|
+
by a hook *name* rather than by event; the event is `PreToolUse` or `preToolUse`; the tool name
|
|
28
|
+
for a shell call is `Bash`, `Shell`, `Bash` or `run_command`; the command sits in
|
|
29
|
+
`tool_input.command` for three of them and in `toolCall.args.CommandLine` for the fourth; the
|
|
30
|
+
write tool is `Edit`/`Write`, `apply_patch` (whose paths are inside the patch text),
|
|
31
|
+
`Write`/`Edit`, and `write_to_file`/`replace_file_content`; and the decision document is four
|
|
32
|
+
different shapes.
|
|
33
|
+
|
|
34
|
+
The plan was written on 2026-10-06 and named `.gemini/settings.json`. The reader table R14
|
|
35
|
+
shipped already carries the correction — the Antigravity page documents
|
|
36
|
+
`<workspace>/.agents/hooks.json` — so this record follows the table and not the plan. The table
|
|
37
|
+
is also the source of the four config paths here: a second list of the same paths is the copy
|
|
38
|
+
ADR-035 exists to prevent.
|
|
39
|
+
|
|
40
|
+
Measured on this machine (node v26, twenty runs each, every figure from a node parent that
|
|
41
|
+
pays the same fork the harness pays): `node -e 0` costs **85.4 ms** at p50 and **91.9 ms** at
|
|
42
|
+
p95, so the runtime's own start is 92% of the plan's budget before a line of this tool runs.
|
|
43
|
+
Loading the reader table adds 2.8 ms at p50 and the region detector 4.9 ms; the whole hook
|
|
44
|
+
path is **97.4 ms p50 / 101.6 ms p95**, down from 127.6 / 139.0 before `bin/` dispatched
|
|
45
|
+
`agent-hook` without loading the registry.
|
|
46
|
+
|
|
47
|
+
So the plan's criterion of a p95 under 100 ms is **not met end to end**, and a sample of
|
|
48
|
+
twenty straddling the line is a claim about this machine rather than about the tool. What
|
|
49
|
+
this slice owns is the part above the runtime, and that part is stated exactly: the decision
|
|
50
|
+
is **0.0023 ms** at p95, and the entry point loads no manifest, no fingerprint, no workspace
|
|
51
|
+
and no command registry — the two modules it takes from `src/projection/` (`markers.mjs` to
|
|
52
|
+
ask whether a file already holds a region, `loaders.mjs` for the reader table's paths) are
|
|
53
|
+
leaves that read nothing at import.
|
|
54
|
+
|
|
55
|
+
## Rulings
|
|
56
|
+
|
|
57
|
+
**1. One policy table, and one renderer per harness.** `policy.mjs` holds the rows and matches
|
|
58
|
+
them against *facts* — a shell command string, and the files a write touches — so a policy never
|
|
59
|
+
knows which harness asked. Each renderer owns one harness's payload-to-facts mapping, its
|
|
60
|
+
configuration shape and its decision document. The table is data; the dialects are code.
|
|
61
|
+
|
|
62
|
+
**2. Three policies ship: `push` (ADR-001), `rewrite` (ADR-017) and `generated` (ADR-036).**
|
|
63
|
+
Each names the record it enforces and the override that lifts it, in the message the reader
|
|
64
|
+
sees. `push` quotes the projected rule that cites ADR-001; `rewrite` cites the record whose
|
|
65
|
+
own Context is the `--no-verify` spiral a broken gate teaches; `generated` cites the record
|
|
66
|
+
that made the region note ("Do not edit by hand") ship into every consumer repository, and its
|
|
67
|
+
message names the command that regenerates.
|
|
68
|
+
|
|
69
|
+
**3. Every matching policy is reported, not the first.** `git push --force` matches two, and
|
|
70
|
+
the denial carries both messages and both records. A first-match rule would let
|
|
71
|
+
`SIMT_ALLOW=push` carry a force-push through on an override that never mentioned rewriting
|
|
72
|
+
history, which is the one thing an override must not do.
|
|
73
|
+
|
|
74
|
+
**4. `SIMT_ALLOW` lifts exactly the policies it names, and says so.** A lifted policy is
|
|
75
|
+
reported as lifted, so a session that granted something can be read back out of its own output.
|
|
76
|
+
A name that is not a policy is reported as unknown and changes no decision: a typo in an
|
|
77
|
+
override must not silently disable a policy, and it must not block either.
|
|
78
|
+
|
|
79
|
+
**5. Exit 2 means "block", and only a denial uses it.** A usage error — a missing `--harness`,
|
|
80
|
+
an unknown event — exits 1. Every harness reads 2 as a refusal, so a configuration mistake that
|
|
81
|
+
exited 2 would block every tool call in the session, which is a worse failure than the one the
|
|
82
|
+
hook is there to prevent.
|
|
83
|
+
|
|
84
|
+
**6. No facts is not a violation.** An unrecognised payload, an empty stdin or unparseable JSON
|
|
85
|
+
produces no decision: exit 0, nothing on stdout. A hook that blocked on a payload it did not
|
|
86
|
+
understand would turn every tool call into a wall, and the harness's own permission model still
|
|
87
|
+
applies. This is the deliberate direction of the failure, and it is why the facts a policy
|
|
88
|
+
needs are read from named fields rather than guessed.
|
|
89
|
+
|
|
90
|
+
**7. A missing hook is a state; an unreadable config is a defect.** `agent-hooks status` exits 0
|
|
91
|
+
when a harness has no entry and 1 when its config cannot be read, or parses to something that is
|
|
92
|
+
not an object. A harness's settings file is the harness's file, kept for reasons that have
|
|
93
|
+
nothing to do with this tool, so not being in it is a choice; a file this tool cannot parse is
|
|
94
|
+
one it must not write into, in either surface.
|
|
95
|
+
|
|
96
|
+
**8. `agent-hook` has no envelope of its own.** Its stdout is the dialect of the harness that
|
|
97
|
+
called it — no `ok`, no `schemaVersion`. It is the one command whose contract is four
|
|
98
|
+
documents instead of one, and the reason a JSON contract test does not enumerate it.
|
|
99
|
+
|
|
100
|
+
**9. The entry point loads neither the registry nor anything that reads the repository.** `bin/simt-dev.mjs`
|
|
101
|
+
dispatches the exact spelling the renderers write, and only that: `--help` still imports the
|
|
102
|
+
registry, so help remains the single list of commands. Nothing above the decision resolves a
|
|
103
|
+
manifest, fingerprints a tree or walks a workspace, because all three would put the cost of
|
|
104
|
+
answering "what is this repository" on every tool call. The two modules the path does take
|
|
105
|
+
from `src/projection/` are leaves that read nothing at import, and the test asserts the
|
|
106
|
+
modules that would read the repository are absent from the import list rather than asserting
|
|
107
|
+
a duration.
|
|
108
|
+
|
|
109
|
+
**10. Install merges, and identifies its own entry by the command it wrote.** Every other key
|
|
110
|
+
and every foreign handler survives; the entry is found by the command string, because JSON has
|
|
111
|
+
no comment to carry a marker. A config this tool cannot parse is left alone and reported, and
|
|
112
|
+
nothing else is written for that run.
|
|
113
|
+
|
|
114
|
+
**11. The readers this tool does not write are named.** `agent-hooks status` and `install`
|
|
115
|
+
report Copilot and the DeepSeek harness as "no hooks file is documented" and OpenCode as
|
|
116
|
+
"not ours" (its hooks are plugins), with the table's own note. A command that writes four files
|
|
117
|
+
and stays silent about the fifth reader in the repository reads as "that one is covered too".
|
|
118
|
+
|
|
119
|
+
## Consequences
|
|
120
|
+
|
|
121
|
+
**What has not landed.** Three policies the plan lists are not here: formatting and linting a
|
|
122
|
+
file after an edit, refusing reads of secrets (`.env*`, `*.pem`, `*.keystore`), and the Stop
|
|
123
|
+
policy that blocks once when code changed with no ledger entry for the tree — the last waits on
|
|
124
|
+
R17, the evidence ledger. Plan section 16 step 5 scopes this slice to exactly the three that
|
|
125
|
+
shipped. A policy that cannot name its evidence cannot name its remedy, which is why the
|
|
126
|
+
ledger one waits.
|
|
127
|
+
|
|
128
|
+
**The acceptance criterion's headless run has not been run, and cannot be from here.** R15's
|
|
129
|
+
accept says a headless Claude Code session on probe R05 shows the block. The probes and their
|
|
130
|
+
graders exist and the decision was verified per harness against fixtures; the run itself needs
|
|
131
|
+
a harness, and none is installed on this machine (`claude`, `codex`, `cursor-agent`, `gemini`
|
|
132
|
+
and `aider` are all absent from `PATH`, which is the check that says so rather than a memory of
|
|
133
|
+
one). So this record does not claim it, and the plan's note for the row names it as the one
|
|
134
|
+
piece of R15's accept that is outstanding.
|
|
135
|
+
|
|
136
|
+
**One field was inferred rather than read.** Cursor's page was read to line 1087 of 1490, which
|
|
137
|
+
does not include its write tool's input key, so its adapter reads `file_path ?? path`. If Cursor
|
|
138
|
+
names it otherwise, a generated-region edit through that harness is not caught — the failure is
|
|
139
|
+
silent, which is why it is written down here rather than left in a comment.
|
|
140
|
+
|
|
141
|
+
**Codex's trust step is printed, not performed.** Codex records trust against the hook's current
|
|
142
|
+
hash, so a hook nobody has trusted stays inert: `install` names the `/hooks` review and
|
|
143
|
+
`status` repeats it. Nothing this tool can do from a shell substitutes for it.
|
|
144
|
+
|
|
145
|
+
**The hook name Antigravity keys on is `simt-dev-policies`.** Antigravity's file is a map of
|
|
146
|
+
named hooks, so the entry needs a name; the project name is the one string already exempt from
|
|
147
|
+
the estate-name guard.
|
|
148
|
+
|
|
149
|
+
## Rejected
|
|
150
|
+
|
|
151
|
+
- **One shared config written for every harness.** They disagree on the path, the top-level
|
|
152
|
+
shape, the event name, the matcher, the tool name, the command field, the write tool and the
|
|
153
|
+
decision document. A shared file would be a fifth dialect none of them reads.
|
|
154
|
+
- **Blocking on a payload the hook did not understand.** Fail-closed is the dispatcher's rule for
|
|
155
|
+
a gate it was asked to run; it is the wrong rule for a hook that runs on every tool call.
|
|
156
|
+
- **Exit 2 for a usage error.** It means "block" to every harness.
|
|
157
|
+
- **A `stop` policy that blocks without a ledger.** The evidence it would need is R17's.
|
|
158
|
+
- **An `ok`/`schemaVersion` envelope on `agent-hook`.** The harness parses its own dialect.
|
|
159
|
+
- **Reading the manifest for the policy decision.** The rules are the tool's, not the
|
|
160
|
+
repository's gates; a hook that required adoption would not run in a repository that has not
|
|
161
|
+
been adopted, which is where the rules matter most.
|
|
162
|
+
- **Guessing a payload shape it cannot recognise.** See ruling 6.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ADR-080 — The tool's own names are not repositories, and neither reader decides by shape
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-16 · settles what `rules why` calls a repository
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`ESTATE_NAME` matched `simt-<anything>` that was not `simt-dev`, on the reasoning that
|
|
8
|
+
every other name sharing the prefix is a repository in the estate these records were
|
|
9
|
+
written in. The reasoning is sound about the estate and false about this tool, which
|
|
10
|
+
prints its own prefixed names in its own records: `simt-priority`, `simt-applies`,
|
|
11
|
+
`simt-inferable-when`, `simt-hooks`.
|
|
12
|
+
|
|
13
|
+
Measured over the corpus before anything changed, 21 of the 33 records that named
|
|
14
|
+
anything named a name of ours, across 9 records; 7 of those named nothing else:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
ADR-018 simt-hooks ADR-047 simt-applies
|
|
18
|
+
ADR-022 simt-alwaysApply, simt-applies, simt-fallback, simt-gates, simt-id,
|
|
19
|
+
simt-key, simt-opt-in, simt-owned, simt-substitutions, simt-target, simt-title
|
|
20
|
+
ADR-026 simt-package-ecosystem ADR-056 simt-hooks
|
|
21
|
+
ADR-040 simt-hooks ADR-062 simt-applies
|
|
22
|
+
ADR-073 simt-priority ADR-078 simt-inferable-when, simt-priority
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Two harms, and the second is the one that matters. `writtenAgainst` listed them under a
|
|
26
|
+
heading that says the record was written against those repositories, which is a false
|
|
27
|
+
claim about evidence — the one thing a decision record is for. Worse, `redactEstate`
|
|
28
|
+
replaced them in the prose, so a reader opening a record through `rules why` read
|
|
29
|
+
`` `a repository: hard | command | convention | style` `` where the record says
|
|
30
|
+
`simt-priority: hard | command | convention | style`. The command whose whole job is to
|
|
31
|
+
explain a citation printed a sentence with no subject.
|
|
32
|
+
|
|
33
|
+
The answer was already in this repository and in the wrong half of it: the shipped-text
|
|
34
|
+
guard derived `TOOL_OWN_WORDS` from `mdc.mjs` and `rules.mjs`, exactly to avoid calling
|
|
35
|
+
`simt-priority` a repository. The reader of the same corpus did not, so the guard passed
|
|
36
|
+
while the command that ships the corpus misdescribed it. ADR-079's own record is the third
|
|
37
|
+
instance of the shape: `simt-inferable-when` was reported as a surveyed repository.
|
|
38
|
+
|
|
39
|
+
## Rulings
|
|
40
|
+
|
|
41
|
+
**1. The tool's own prefixed names live in `src/toolwords.mjs`, derived from the modules
|
|
42
|
+
that own them.** `KNOWN_KEYS` from `mdc.mjs`, `PROFILE_KEYS` from `rules.mjs`, the
|
|
43
|
+
inferability marker from `kernel/inferability.mjs`, and the three names this tool writes
|
|
44
|
+
on disk (`simt-dev`, `simt-dev-check`, `simt-hooks`). Derived, not listed (ADR-022): a
|
|
45
|
+
second copy of a vocabulary is a copy that drifts and then forgives.
|
|
46
|
+
|
|
47
|
+
**2. Both readers read that one list.** The decision reader and the shipped-text guard
|
|
48
|
+
answer the same question — is this a repository or a name of ours? — and answering it
|
|
49
|
+
twice is how the two answers came apart. `test/shipped.test.mjs` now imports
|
|
50
|
+
`TOOL_WORDS` instead of deriving its own.
|
|
51
|
+
|
|
52
|
+
**3. A record whose subject is the prefix names no repository.** `PREFIX_ARGUING` maps
|
|
53
|
+
two files (ADR-022, ADR-026) to the reason each spells the prefix, and both readers use
|
|
54
|
+
it. A vocabulary cannot settle these: a hypothetical key in an argument and a repository
|
|
55
|
+
name are the same shape. The guard's staleness test is unchanged — an exemption that
|
|
56
|
+
stops being needed still fails.
|
|
57
|
+
|
|
58
|
+
**4. Redaction keeps the tool's names and replaces the estate's.**
|
|
59
|
+
`redactEstate('simt-priority')` is `simt-priority`; `redactEstate` of an estate-shaped
|
|
60
|
+
name is `a repository`. The rule is about what the reader cannot act on, and a key of the
|
|
61
|
+
tool they are running is not that.
|
|
62
|
+
|
|
63
|
+
**5. Detection stays shape-based and strict.** `test/leaks.mjs` keeps `ESTATE_NAME` and
|
|
64
|
+
does not consult the vocabulary. Detection may be stricter than rendering without harm: a
|
|
65
|
+
word of ours that a surface never should have shipped still fails, and ADR-080 adds no
|
|
66
|
+
false negative. This asymmetry is deliberate and is the reason the list did not become a
|
|
67
|
+
filter inside the pattern.
|
|
68
|
+
|
|
69
|
+
**6. `writtenAgainst` still names the estate.** 26 of 80 records list `repo-NN` labels and
|
|
70
|
+
keep listing them, every label one of those; the field's purpose is unchanged and the
|
|
71
|
+
reader still learns which parts of a record are not about them.
|
|
72
|
+
|
|
73
|
+
## Consequences
|
|
74
|
+
|
|
75
|
+
- 21 misdescribed reads are gone and no record names a name of ours; `simt rules why ADR-073 --json` reports
|
|
76
|
+
`writtenAgainst: []` and the record is served with `simt-priority` intact.
|
|
77
|
+
- `src/toolwords.mjs` imports `mdc.mjs`, `rules.mjs` and `kernel/inferability.mjs`, so the
|
|
78
|
+
decision reader loads the projection stack. `rules why` is interactive and pays ~5 ms;
|
|
79
|
+
no import in that chain has import-time side effects, which is what makes it safe to add
|
|
80
|
+
to a module the MCP server also loads.
|
|
81
|
+
- The guard gained an import and lost a derivation. Its own comment about where the
|
|
82
|
+
vocabulary comes from is now in `src/`, next to what it describes.
|
|
83
|
+
- An estate repository whose name does not begin `simt-` is still undetectable, as
|
|
84
|
+
`ESTATE_NAME` says, and this record does not change that.
|
|
85
|
+
|
|
86
|
+
## Rejected
|
|
87
|
+
|
|
88
|
+
- **Keeping the shape and documenting the exception.** The defect was not the pattern's
|
|
89
|
+
comment; it was that the pattern was the only definition.
|
|
90
|
+
- **Exempting the words instead of the records.** ADR-022's `simt-gates` and a repository
|
|
91
|
+
sharing that shape are indistinguishable, so a word list would either over-exempt a
|
|
92
|
+
repository or under-exempt the argument.
|
|
93
|
+
- **A second list inside `src/decisions/index.mjs`.** The guard already had one, and the
|
|
94
|
+
two drifting apart is the defect this record is about.
|
|
95
|
+
- **Filtering inside `ESTATE_NAME` itself with a lookahead built from the vocabulary.**
|
|
96
|
+
Detection and rendering want different answers on purpose (ruling 5), and a shared
|
|
97
|
+
pattern would have silently coupled them.
|
|
98
|
+
- **Renaming `writtenAgainst` to something vaguer.** The field is right; the shape it
|
|
99
|
+
matched was wrong.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ADR-081 — A tracker is read through the repository's template
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-16 · rulings 1 and 2 superseded by ADR-082
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`plan next` has to open the directory a repository actually keeps its plans in, and it has to
|
|
8
|
+
recognise the columns that repository actually writes. Both were fixed: `docs/plans`,
|
|
9
|
+
`docs/reviews`, a column named State, and the word `review` in a dependency. That is this
|
|
10
|
+
package's own layout. A reader whose plans live somewhere else, or whose tracker says Done
|
|
11
|
+
in a column named Progress, would be told they had no plan.
|
|
12
|
+
|
|
13
|
+
The selection cannot live in the manifest. `init` rewrites that file from the fields it owns,
|
|
14
|
+
and a repository that has not adopted the tool has no manifest. A plan template has to work
|
|
15
|
+
in both, and it has to survive a re-init.
|
|
16
|
+
|
|
17
|
+
## Rulings
|
|
18
|
+
|
|
19
|
+
1. **The format is a template, and the one delivered with the tool is named simt-dev.** It
|
|
20
|
+
reads `docs/plans` and `docs/reviews`, binds columns by the headers it lists, and treats
|
|
21
|
+
`done` as complete. A repository that writes nothing gets that template.
|
|
22
|
+
2. **A repository replaces it with `simt-plan.json` at its root.** The file overlays the
|
|
23
|
+
delivered template: a section it names replaces that section, and a section it omits stays.
|
|
24
|
+
Directories, column headers, state words, which of those words unblocks a dependency, and
|
|
25
|
+
the words a dependency uses to name another document are all in that file.
|
|
26
|
+
3. **A template that cannot be read is an error.** Falling back to the delivered template
|
|
27
|
+
would index the wrong directories and could mark the wrong row done. A directory path has
|
|
28
|
+
to stay inside the repository.
|
|
29
|
+
4. **`plan done` still previews unless `--yes`.** The template decides which word is written
|
|
30
|
+
for the completed state. It does not decide that a row may change unseen.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- `simt plan status` names the template and the directories it read.
|
|
35
|
+
- Changing the delivered template changes what every repository without `simt-plan.json` is
|
|
36
|
+
indexed as. The file is the format; the parser does not keep a second copy of the headers.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ADR-082 — The tracker override is a manifest key, and init keeps what it did not write
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-16 · supersedes ADR-081 rulings 1 and 2
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR-081 put the tracker format in a file named for this package, and put a repository's
|
|
8
|
+
override in a second file at the root. The name collided with the package and with the
|
|
9
|
+
manifest's old filename. The second file existed because `init` rebuilt the manifest from
|
|
10
|
+
the keys it knew and dropped the rest, so a `plans` key would not have survived a re-init.
|
|
11
|
+
|
|
12
|
+
A repository that has adopted already has one config file. A second one, present only when
|
|
13
|
+
the tracker differs from the delivered format, is a file most readers will not know to open.
|
|
14
|
+
|
|
15
|
+
## Rulings
|
|
16
|
+
|
|
17
|
+
1. **The delivered template is named `tracker`.** It is `templates/plan/tracker.json`. It
|
|
18
|
+
reads `docs/plans` and `docs/reviews`. A repository that sets nothing gets that template.
|
|
19
|
+
2. **A repository overrides it with the `plans` key of its manifest.** The key overlays the
|
|
20
|
+
delivered template. A section it names replaces that section, and a section it omits
|
|
21
|
+
stays. The manifest's own comment says so, because that comment is the text a reader of
|
|
22
|
+
the file sees.
|
|
23
|
+
3. **`init` keeps every key it does not write.** Owned keys still refresh, in the order the
|
|
24
|
+
writer emits them. A key absent from that object is copied after them, in the order the
|
|
25
|
+
file had those keys. That is what makes ruling 2 survivable: a re-init no longer deletes
|
|
26
|
+
`plans`, or any other key a repository added.
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
- `simt plan` reads `plans` from `simt.json`, and from `simt-dev.json` while that name is
|
|
31
|
+
still the manifest on disk.
|
|
32
|
+
- A manifest written by `init` carries the explanation of `plans` in its comment. The
|
|
33
|
+
comment is generated, so the next `init` or `sync` refreshes it.
|
|
34
|
+
- ADR-081 rulings 3 and 4 stand: a template that cannot be read is an error, and `plan done`
|
|
35
|
+
still previews unless `--yes`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simt-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared agent rules, skills and git hooks for a repository: one command adopts a repo, and every gate it declares is probed before it is written.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Said Elimam",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"bin",
|
|
21
21
|
"src",
|
|
22
22
|
"rules",
|
|
23
|
+
"templates",
|
|
23
24
|
"docs/decisions",
|
|
24
25
|
"CHANGELOG.md"
|
|
25
26
|
],
|
|
@@ -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
|
+
}
|