simt-dev 0.2.0 → 0.2.1
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 +173 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,172 @@ All notable changes to this package are recorded here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.1] — 2026-10-13
|
|
10
|
+
|
|
11
|
+
### `init` writes in place; the backup tag and adopt branch are opt-in
|
|
12
|
+
|
|
13
|
+
`simt init --yes` used to always tag the pre-adoption commit and branch onto a dedicated
|
|
14
|
+
adopt branch before writing. The safety argument was sound, but consumers kept reporting
|
|
15
|
+
the branch switch as a surprise — a well-announced surprise is still a surprise.
|
|
16
|
+
|
|
17
|
+
The default now writes in place on the branch you already have: nothing is tagged, nothing
|
|
18
|
+
is branched, and the run says so in an `in place` line. The reviewable diff survives —
|
|
19
|
+
`git diff` shows the whole adoption, and discarding it is a checkout and a clean.
|
|
20
|
+
|
|
21
|
+
The old behaviour moves behind `--backup`: `simt init --yes --backup` tags the pre-adoption
|
|
22
|
+
commit and branches as before, for readers who want the named undo path. ADR-063 records
|
|
23
|
+
the decision and the one behaviour it invalidates: consumers who scripted against the old
|
|
24
|
+
default add `--backup`.
|
|
25
|
+
|
|
26
|
+
### A fresh adoption seeds the gates it can run — and only those
|
|
27
|
+
|
|
28
|
+
The eight common ecosystems outside the JavaScript world — Rust, Go, .NET, Swift, Deno,
|
|
29
|
+
Ruby, Dart and Elixir — were recognised by the fingerprint and served by nothing: no
|
|
30
|
+
profile selected them, no gate was proposed for them. A fresh Rust repository received the
|
|
31
|
+
core essay and a manifest that runs nothing, while the ecosystems that emit stack tags got
|
|
32
|
+
the full treatment.
|
|
33
|
+
|
|
34
|
+
Each now has a profile that selects on its own `has-*` fact, so one marker file reaches
|
|
35
|
+
both the rules the repository reads and the gates its commits run. Maven's profile predates
|
|
36
|
+
this; its row is gates-only, wrapper-first, like `gradlew`.
|
|
37
|
+
|
|
38
|
+
`simt init` now seeds the canonical commands for a detected ecosystem — `cargo fmt
|
|
39
|
+
--check`, `go vet ./...`, `dotnet test`, and the rest of the table — into the manifest's
|
|
40
|
+
gates, but only where a probe found the tool: a binary on PATH, or a file in the repository
|
|
41
|
+
for the Maven wrapper. A marker says the ecosystem is here; the probe says its program can
|
|
42
|
+
run. A declared gate that cannot run fails closed on every commit, so the seed fires only
|
|
43
|
+
when both are true — and where the probe fails, the gate is dropped with a remedy that
|
|
44
|
+
names the install instead of blocking the first commit.
|
|
45
|
+
|
|
46
|
+
A seed is a default: a command the repository already declared is never re-seeded, and an
|
|
47
|
+
npm runner keeps its own scripts, because `package.json` is the repository's own answer to
|
|
48
|
+
"how do I test and format". ADR-062 rules the shape of all of it.
|
|
49
|
+
|
|
50
|
+
### The resolver is measured against a gold corpus
|
|
51
|
+
|
|
52
|
+
`simt skills eval` scores the skill resolver against a corpus of real tasks × real
|
|
53
|
+
repositories, each case carrying a gold list of the skills it should select and a list
|
|
54
|
+
of the ones it must not. It reports precision and recall, prints a PASS/FAIL line per
|
|
55
|
+
case naming the skills it missed or selected that it should not have, and exits
|
|
56
|
+
non-zero on a regression — so it can be run from CI the way the gate is run.
|
|
57
|
+
|
|
58
|
+
The scoring lives in `src/resolve/eval.mjs`, which the command and
|
|
59
|
+
`test/eval.test.mjs` both call, so a test cannot drift from what the command measures.
|
|
60
|
+
The corpus lives under `test/` because test data does not ship in the package: an
|
|
61
|
+
installed copy names the path it would have read and says so instead of scoring the
|
|
62
|
+
author's.
|
|
63
|
+
|
|
64
|
+
### The skill registry is workspace data; the package ships none of it
|
|
65
|
+
|
|
66
|
+
The npm tarball and the wheel carried `graph/skills.graph.json` — the directory
|
|
67
|
+
of skill sources for one workspace, naming the repositories whose bodies it
|
|
68
|
+
resolves. Every installed copy served that catalogue to readers who have none
|
|
69
|
+
of those skills, and `skills install` pointed at paths that exist only in the
|
|
70
|
+
workspace that wrote them. ADR-061 rules the registry out of the published set:
|
|
71
|
+
it is data about one workspace, not content of the package.
|
|
72
|
+
|
|
73
|
+
The default registry path now resolves in tiers, and every tier is file
|
|
74
|
+
existence plus configuration — nothing a pull request cannot diff:
|
|
75
|
+
|
|
76
|
+
1. an explicit path, the positional of `simt graph lint`,
|
|
77
|
+
2. the package's own `graph/skills.graph.json` — present in a source checkout,
|
|
78
|
+
absent from an installed copy,
|
|
79
|
+
3. the member of a configured workspace that holds one — naming the workspace
|
|
80
|
+
it came from, and refusing when several members hold one, because picking
|
|
81
|
+
would be a guess.
|
|
82
|
+
|
|
83
|
+
An installed copy in a workspace with no registry says so, and names the three
|
|
84
|
+
ways to configure one (`--workspace`, `SIMT_DEV_WORKSPACE`, the user config
|
|
85
|
+
file) instead of failing on a path it does not have. `simt graph lint`'s
|
|
86
|
+
no-registry remedy names the path explicitly, because its default is the
|
|
87
|
+
registry it lints.
|
|
88
|
+
|
|
89
|
+
The provenance digest stops walking `graph/` for the same reason it no longer
|
|
90
|
+
leaves the package: the digest covers what a published copy ships, and the
|
|
91
|
+
registry is no longer among it.
|
|
92
|
+
|
|
93
|
+
### No published file may name a repository from this estate
|
|
94
|
+
|
|
95
|
+
The decision records ship in all three channels — npm, the wheel and the sdist — so a
|
|
96
|
+
private repository name written into one is published to three registries. That happened:
|
|
97
|
+
the records were pseudonymised once, across 28 files, and nothing stopped the next one. The
|
|
98
|
+
sweep's own commit says why, and it is the right reason: the redaction it used runs on the
|
|
99
|
+
way *out*, at print time, and the bytes on disk are what get published.
|
|
100
|
+
|
|
101
|
+
`test/shipped.test.mjs` now fails on an estate name anywhere in a published file, and
|
|
102
|
+
asserts its own scope against `package.json` in **both** directions — a directory added to
|
|
103
|
+
`files` without being guarded fails, and so does one guarded that nothing publishes, because
|
|
104
|
+
a guard nobody can fail is one somebody eventually deletes. It is scoped to the published
|
|
105
|
+
set rather than a hand-written list for the same reason: the sweep took 28 files to undo by
|
|
106
|
+
hand, and the guard should not need undoing at all.
|
|
107
|
+
|
|
108
|
+
Names are not removed, they are described. The records that use a repository as *evidence* —
|
|
109
|
+
a table of how three repositories answer the same question — carry stable `repo-NN` labels,
|
|
110
|
+
so the comparison survives. Everything else is better off naming what the repository *is*:
|
|
111
|
+
"a Gradle module with no wrapper", "a repository with no package.json". That reads to
|
|
112
|
+
someone outside the estate without a lookup table they do not have.
|
|
113
|
+
|
|
114
|
+
The one place estate names remain is `docs/reviews/`, which is not published by either
|
|
115
|
+
channel. A review whose subject is fourteen repositories cannot pseudonymise them without
|
|
116
|
+
becoming a document about `repo-01` that never existed.
|
|
117
|
+
|
|
118
|
+
### Four shipped strings named a command this package does not install
|
|
119
|
+
|
|
120
|
+
`0.2.0` installs one binary, `simt`, and `package.json`'s `bin` map says so. Four
|
|
121
|
+
places still said `simt-dev`, which a reader types and is met with `unknown command`:
|
|
122
|
+
|
|
123
|
+
- the default remedy on every bad invocation, `run: simt-dev --help` — so the advice
|
|
124
|
+
printed after a bad command could not be followed
|
|
125
|
+
- the usage line, `usage: simt-dev <command>`, sitting above a command list that spelled
|
|
126
|
+
the same tool `simt`
|
|
127
|
+
- `.github/dependabot.yml`'s header, which said `Yours from here: simt-dev` while the
|
|
128
|
+
gate beside it said `Yours from here: simt`
|
|
129
|
+
- the hook shim, whose own resolution list said `simt-dev on PATH` directly above the
|
|
130
|
+
`command -v simt` that does the looking
|
|
131
|
+
|
|
132
|
+
`test/naming.test.mjs` was supposed to prevent this and did not: its rule required a
|
|
133
|
+
verb after the project name, and none of the four is followed by one. It now also
|
|
134
|
+
catches a flag, a `<placeholder>`, the end of a line, and a program name in a
|
|
135
|
+
resolution list — while still accepting the sentences where the project is the
|
|
136
|
+
subject, which is the spelling that is correct. The two `.github` templates get their
|
|
137
|
+
own assertion, because the fourth of these was not a wrong word in a sentence; it was
|
|
138
|
+
two templates disagreeing with each other in the same position.
|
|
139
|
+
|
|
140
|
+
### Fixed
|
|
141
|
+
|
|
142
|
+
- `hooks install` removes a shim whose gate was dropped from the manifest instead of leaving it to run nothing. A repository that removed its last gate was refused outright, with a remedy telling it to put the gate back; only un-routing git was left to the reader. install also no longer chains a hook to itself.
|
|
143
|
+
|
|
144
|
+
- **Dead exports and duplicated frontmatter, with a test that stops them returning (S21).**
|
|
145
|
+
Eight functions were exported and called by nothing (`gateKind`, `gateWhy`, `gateAdr`,
|
|
146
|
+
`resetWorkspace`, `tracksIdentity`, `isTty`, `surfaceFrontmatter`, `workspaceOrNull`),
|
|
147
|
+
three `__internals` testing seams had no importer, and thirteen more bindings were
|
|
148
|
+
exported while being used only inside their own file — `isExecutable` among them, which
|
|
149
|
+
a first pass through this list wrongly recorded as dead and which its own callers
|
|
150
|
+
proved otherwise. `KNOWN_SUBS` was a hand-kept second copy of the dispatch table, and
|
|
151
|
+
had already drifted: it carried `doctor`, a bare command the group lookup can never
|
|
152
|
+
reach. `mdcFrontmatter` was a second construction of frontmatter the writer now derives
|
|
153
|
+
itself, with a test asserting the copy rather than the behaviour.
|
|
154
|
+
|
|
155
|
+
- **"Did you mean" offered the shortest fragment first, and a misspelt skill was
|
|
156
|
+
suggested for a correctly spelled one (S22).** `nearMatches` had no test at all.
|
|
157
|
+
Both directions of substring scored 0 — the same score as an exact match — so the
|
|
158
|
+
alphabetical tiebreak decided the order, and `skills graphs` suggested `graph`
|
|
159
|
+
before `graphs`. Worse, the containment test had no length floor, so `test` matched
|
|
160
|
+
the candidate `e` and the message read `did you mean e, test-writer?`, which an
|
|
161
|
+
agent has every reason to install. A prefix now scores 1 rather than 0, a fragment
|
|
162
|
+
counts only when it is at least half the query, and the edit bound scales with the
|
|
163
|
+
query's length — three edits is a typo in `slice-commmit` and is most of a
|
|
164
|
+
four-character query.
|
|
165
|
+
|
|
166
|
+
- **The conflict scanner skipped two of the five rule surfaces, and one pointer file
|
|
167
|
+
(S22).** `rulePaths` enumerated filenames by hand, so `.claude/rules/` and
|
|
168
|
+
`.agents/rules/` — added to the surface table after the list was written — were
|
|
169
|
+
never scanned, and a hand-written rule contradicting a generated one there was
|
|
170
|
+
invisible. A conflict detector that only looks where it was pointed looks nowhere
|
|
171
|
+
new. The scan list is now derived from the surface table and the pointer table.
|
|
172
|
+
|
|
7
173
|
## [0.2.0] — 2026-10-11
|
|
8
174
|
|
|
9
175
|
### Publishable from PyPI and Homebrew as well as npm
|
|
@@ -48,17 +214,17 @@ recommending. It chained on the event *vocabulary* — anything that was not
|
|
|
48
214
|
`pre-commit` or `pre-push` — when the question worth asking is whether **this
|
|
49
215
|
repository** declares a gate for that event.
|
|
50
216
|
|
|
51
|
-
The difference is
|
|
217
|
+
The difference is a repository whose `pre-push` is `git-lfs`. `pre-push` is an
|
|
52
218
|
event this tool gates in general, so `0.1.0` displaced that hook; and because
|
|
53
|
-
|
|
54
|
-
git would have found no `pre-push` hook at all. LFS objects would have
|
|
55
|
-
being uploaded, with nothing to notice it — the exact silent failure
|
|
56
|
-
exists to prevent, reached by fixing the wrong half of it.
|
|
219
|
+
that repository declares no `pre-push` gate there was no shim for the event
|
|
220
|
+
either, so git would have found no `pre-push` hook at all. LFS objects would have
|
|
221
|
+
stopped being uploaded, with nothing to notice it — the exact silent failure
|
|
222
|
+
chaining exists to prevent, reached by fixing the wrong half of it.
|
|
57
223
|
|
|
58
224
|
`0.1.1` asks the manifest. An event the repo declares a gate for is left to
|
|
59
225
|
`gates.commands`, where the hook runs as a gate and its verdict appears in the
|
|
60
|
-
report; every other foreign hook is chained. That also picks up
|
|
61
|
-
|
|
226
|
+
report; every other foreign hook is chained. That also picks up a static
|
|
227
|
+
site's Lighthouse pre-push, which declares no gates at all.
|
|
62
228
|
*(ADR-018, as amended)*
|
|
63
229
|
|
|
64
230
|
Upgrade with `npm i -D simt-dev@0.1.1`, then `simt hooks install --yes`. Any
|
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ Requires **Node >= 22.20.0** (the PyPI install can supply it, see below).
|
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
# pinned, per repository - what CI and a teammate both get
|
|
26
|
-
npm i -D simt-dev@0.2.
|
|
26
|
+
npm i -D simt-dev@0.2.1
|
|
27
27
|
|
|
28
28
|
# or globally, if you would rather not add a dependency
|
|
29
|
-
npm i -g simt-dev@0.2.
|
|
29
|
+
npm i -g simt-dev@0.2.1
|
|
30
30
|
|
|
31
31
|
# or with no install at all, for a one-off
|
|
32
|
-
npx -y simt-dev@0.2.
|
|
32
|
+
npx -y simt-dev@0.2.1 check
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
### From PyPI
|
|
@@ -81,7 +81,7 @@ simt compare # what changed across every repository that adopted it
|
|
|
81
81
|
| Path | What |
|
|
82
82
|
| --- | --- |
|
|
83
83
|
| `AGENTS.md` | the projection, between `<!-- simt:begin -->` and `<!-- simt:end -->` (renamed from `simt-dev:`; the old markers are still read, and a region migrates on its next write) |
|
|
84
|
-
| `simt.json` | your manifest: which gates to declare, which substitutions to fill
|
|
84
|
+
| `simt.json` | your manifest: which gates to declare, which substitutions to fill |
|
|
85
85
|
| `.cursor/rules/*.mdc`, `.claude/rules/*.md`, `.agents/rules/*.md`, `.github/instructions/*.instructions.md` | one file per rule, written only for the harnesses that read their own directory |
|
|
86
86
|
| `.editorconfig`, `.gitattributes` | baseline regions |
|
|
87
87
|
| `.simt-hooks/` and `core.hooksPath` | the shared dispatcher, fail-closed |
|
|
@@ -72,6 +72,33 @@ One manifest. Three answers. The one I would have shipped if I had only run `che
|
|
|
72
72
|
every file (`ENOTDIR`) and classified every Gradle repository in the estate as having no
|
|
73
73
|
runner at all — a wrong answer from an evidence reader, which is worse than no answer
|
|
74
74
|
because the caller cannot tell them apart.
|
|
75
|
+
8. **A declared command is a shell command line, not an argv.** *(S17, 2026-10-12)*
|
|
76
|
+
|
|
77
|
+
It was split on whitespace and handed to `execFile` as an argv. That made a declaration
|
|
78
|
+
a repository would plausibly write run the wrong thing rather than fail: a flag whose
|
|
79
|
+
value contains a space became two arguments, and the quotes in a compound line were
|
|
80
|
+
passed to the program as literal characters. There was no way to express a quoted
|
|
81
|
+
argument at all, and nothing said so.
|
|
82
|
+
|
|
83
|
+
So `gates.commands[name]` is one line, run through `sh -c`, and everything the
|
|
84
|
+
repository wrote in it survives — quotes, pipes, `&&`, a path with a space. This is the
|
|
85
|
+
same line the repository would type into its own shell, which is the only definition of
|
|
86
|
+
"what this repository means by that" that does not require a parser here.
|
|
87
|
+
|
|
88
|
+
**The trust boundary is the repository, and it is not a sandbox.** The line comes from
|
|
89
|
+
the repository's own manifest, which it can read and change, and it runs on its author's
|
|
90
|
+
own machine when that author commits. `sh -c` adds no privilege: the same person who
|
|
91
|
+
wrote the line could run it. What `sh -c` must not do is apply to anything this tool
|
|
92
|
+
resolved itself — a formatter path out of `node_modules/.bin` stays an argv, because
|
|
93
|
+
handing it to a shell would change who decided its arguments for no gain. The two
|
|
94
|
+
sources are separate branches in the dispatcher rather than a flag, so the distinction
|
|
95
|
+
cannot be forgotten by a caller that does not know it exists.
|
|
96
|
+
|
|
97
|
+
**Consequence for a repository that relied on the split.** A declaration with no quotes
|
|
98
|
+
behaves identically, because whitespace-splitting a shell word that needed no quoting
|
|
99
|
+
is the same thing the shell does. Every use of `gates.commands` in this estate declares
|
|
100
|
+
a bare path and is unaffected; the change only makes declarations *possible* that were
|
|
101
|
+
previously impossible rather than making any of them wrong.
|
|
75
102
|
|
|
76
103
|
## Consequences
|
|
77
104
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# ADR-059 — The version a repository runs is the one it pinned, and the gate refuses otherwise
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-12 · affects all repos
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR-010 ruled that a tool which is genuinely not a project dependency may be invoked directly,
|
|
8
|
+
**provided its version is pinned**. For a repository with a `package.json`, simt-dev is that
|
|
9
|
+
dependency, and npm does the pinning: it installs the version in `package.json` into
|
|
10
|
+
`node_modules`, and a stale copy is a stale lockfile that `npm ci` reproduces.
|
|
11
|
+
|
|
12
|
+
For a repository **without** npm — a Gradle build, a Connect IQ project — there is no
|
|
13
|
+
`node_modules` to pin in. simt-dev is a global install, and nothing pins it. The version on
|
|
14
|
+
`PATH` is whatever the last `npm i -g` left there, and it can be anything. The gate that runs
|
|
15
|
+
on commit is the one in that binary. If it is not the version the repository adopted, the gate
|
|
16
|
+
that runs locally is not the one CI runs: a green tick from a check that ran the wrong version
|
|
17
|
+
is the one outcome worse than a red build, because it is a pass that never happened.
|
|
18
|
+
|
|
19
|
+
The same gap sat in the remedy. The shim and the consumer workflow assumed npm everywhere —
|
|
20
|
+
`npm i -D simt-dev@<v>` and `npm ci`. In a Gradle repository that command installs into a
|
|
21
|
+
`package.json` the repository will never have, so the remedy cannot succeed and the CI step
|
|
22
|
+
cannot start. The install and the remedy each needed to know the kind of repository they were
|
|
23
|
+
in, and neither did.
|
|
24
|
+
|
|
25
|
+
## Ruling
|
|
26
|
+
|
|
27
|
+
1. **The manifest records the version a repository adopted.** `projection` carries
|
|
28
|
+
`version` alongside the existing `simtDevSha`, written at adoption and refreshed by
|
|
29
|
+
`upgrade`. A node_modules copy is pinned by npm itself, so this field matters only where
|
|
30
|
+
npm is not the pin — but it is written everywhere, because a manifest that records the
|
|
31
|
+
version in one shape of repository and not another is two manifests.
|
|
32
|
+
|
|
33
|
+
2. **The gate compares the running binary against that pin, and refuses on a mismatch.** The
|
|
34
|
+
comparison is in the dispatcher (a failed `version` gate) and in `check` (a blocking
|
|
35
|
+
finding), not in the shim's rendered bytes: the dispatcher is where every gate already runs,
|
|
36
|
+
and a comparison duplicated into bytes a formatter might rewrite would drift from the one
|
|
37
|
+
that decides. It is placed after the empty-gate check on purpose — a repository that declares
|
|
38
|
+
no gate has nothing the version could get wrong, and failing it would block commits in repos
|
|
39
|
+
that deliberately declare none.
|
|
40
|
+
|
|
41
|
+
3. **The install and the remedy are per kind.** Where a `package.json` exists, simt-dev is pinned
|
|
42
|
+
as a devDependency and the remedy is `npm i -D simt-dev@<v>`. Where there is no npm, it is a
|
|
43
|
+
pinned global install and the remedy is `npm i -g simt-dev@<v>` (or `export SIMT_DEV_BIN=…`).
|
|
44
|
+
The consumer CI workflow makes the same choice by kind — `npm ci` where the package is a
|
|
45
|
+
declared dependency, the pinned global install otherwise — so the gate that runs in CI is the
|
|
46
|
+
one the machine remedy names, and a check cannot pass in one place and fail to start in the
|
|
47
|
+
other.
|
|
48
|
+
|
|
49
|
+
## Consequences
|
|
50
|
+
|
|
51
|
+
- A stale global binary can no longer produce a green tick from a check that ran a different gate
|
|
52
|
+
than CI does. The mismatch is a refusal with the one command that fixes it, and the remedy and
|
|
53
|
+
the CI step agree on which version to install.
|
|
54
|
+
- A repository adopted before the field existed — a manifest with `simtDevSha` but no
|
|
55
|
+
`projection.version` — is **skipped, not failed**. There is nothing to compare against, and
|
|
56
|
+
failing every pre-0.2 repository the moment this check ships would turn the gate red for a
|
|
57
|
+
reason it cannot fix. `compare` reports that missing field as a stale manifest (ADR-045's
|
|
58
|
+
defect from the far side), so it is visible and repairable rather than silently absent.
|
|
59
|
+
- The version a repository runs is now an invariant the gate enforces, not a comment: the pin in
|
|
60
|
+
the manifest and the binary on `PATH` must agree, or the commit does not happen.
|
|
61
|
+
|
|
62
|
+
## Rejected
|
|
63
|
+
|
|
64
|
+
- **Shell out to `simt --version` in the shim.** The dispatcher runs from the binary that
|
|
65
|
+
resolved it, so reading the running package's version is the same answer without a subprocess,
|
|
66
|
+
and it is correct whether the binary came from `node_modules`, a global install, or a source
|
|
67
|
+
checkout. A subprocess would answer only for the binary that started it.
|
|
68
|
+
- **Fail a repository with no recorded pin.** There is nothing to compare against; it would make
|
|
69
|
+
every pre-0.2 repository red on the day this ships, for a mismatch nobody can resolve until
|
|
70
|
+
they re-adopt. The missing pin is reported as drift, which is the repairable form of the same
|
|
71
|
+
fact.
|
|
72
|
+
- **Put the check only in the shim's rendered bytes.** The dispatcher is where every gate runs and
|
|
73
|
+
where `check` already lives, so the comparison belongs in one place. Rendered bytes are read by
|
|
74
|
+
a formatter and a reviewer; a control that lives in both is two copies of one question, which
|
|
75
|
+
is the shape this record's siblings keep finding drifting.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# ADR-060 — A repository with nothing to run is adopted and says so, and a gate is verified where it runs
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-12 · filed as S36
|
|
4
|
+
**Affects:** `hooks install`, `compare`, the adoption criterion in review 0002
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Review 0002 defined adoption as eight things a repository must have, and gave the estate
|
|
9
|
+
roll-out an acceptance criterion: every repository current, every repository routed, no
|
|
10
|
+
unrunnable gate, and a green CI run in every repository that has CI. Fourteen repositories
|
|
11
|
+
were in scope.
|
|
12
|
+
|
|
13
|
+
Three of them cannot satisfy the second of those, and the row contradicted itself about it.
|
|
14
|
+
The runbook's own step 4, for one of the three, reads: *"none (no runner). Record that in
|
|
15
|
+
the manifest rather than invent one."* That is an instruction not to route it. The
|
|
16
|
+
acceptance criterion then requires that it be routed. One of the two is wrong, and the row
|
|
17
|
+
could not be closed without settling which.
|
|
18
|
+
|
|
19
|
+
This stopped being hypothetical when the roll-out ran. The three are:
|
|
20
|
+
|
|
21
|
+
- **A Gradle module consumed as a submodule.** It has no wrapper of its own — the two
|
|
22
|
+
repositories that embed it own that — so `./gradlew` cannot be run from it. Its
|
|
23
|
+
`package.json` declares no scripts at all; it exists to carry one devDependency.
|
|
24
|
+
- **A repository with no `package.json`.** No runner, no scripts, nothing to probe.
|
|
25
|
+
- **A static site with no gate and its own hook.** No `format:check` and no `test` script to
|
|
26
|
+
declare a gate from, and a Lighthouse pre-push in `.githooks/` that routing would displace.
|
|
27
|
+
|
|
28
|
+
Each is ungated for a reason that was checked rather than assumed, and each records that
|
|
29
|
+
reason in its own commit message.
|
|
30
|
+
|
|
31
|
+
ADR-017 already settled the principle in one direction: a gate is declared only if the
|
|
32
|
+
repository can run it. This record settles the other, which fourteen repositories of real
|
|
33
|
+
use never exercised — what a repository that can run nothing looks like once it has adopted
|
|
34
|
+
the tool.
|
|
35
|
+
|
|
36
|
+
A second thing surfaced at the same moment, and it is not a repository's fault. The roll-out
|
|
37
|
+
pushed to fourteen repositories and GitHub Actions executed no steps: every run completed
|
|
38
|
+
with `failure`, zero steps, and no logs — including a pre-existing lint-and-test workflow
|
|
39
|
+
whose body this work never touched. Actions reports `enabled: true` on all eight
|
|
40
|
+
repositories; the organisation-level setting needs `admin:org` and could not be read. The
|
|
41
|
+
workflow files parse, and the same workflows have run before. The jobs are not starting.
|
|
42
|
+
|
|
43
|
+
## Ruling
|
|
44
|
+
|
|
45
|
+
**One. A repository that cannot run a gate is adopted when it says so, and routing is not
|
|
46
|
+
required of it.** `hooksComplete` is not the measure of adoption, and "every repository
|
|
47
|
+
routed" was never achievable. The three carry the evidence in the manifest and in their own
|
|
48
|
+
commit messages: what is missing, and why. That is a stronger artefact than a shim that
|
|
49
|
+
runs nothing — which is what forcing them would have produced, and which the runbook already
|
|
50
|
+
forbade inventing.
|
|
51
|
+
|
|
52
|
+
**Two. A gate must be runnable, and a shim must not be installed over one that is working.**
|
|
53
|
+
This is ADR-018 as amended, restated because the roll-out made it concrete. The static site's
|
|
54
|
+
pre-push keeps running in `.githooks/` precisely because its manifest declares no `prePush`
|
|
55
|
+
gate and therefore nothing displaces it. Routing a repository that enforces nothing is not
|
|
56
|
+
a smaller change than routing one that does: `core.hooksPath` moves, git reads every hook
|
|
57
|
+
from the one directory, and the gain is zero.
|
|
58
|
+
|
|
59
|
+
**Three. "Green CI" is verified where CI runs, and is not a property of a repository when it
|
|
60
|
+
does not.** The clause is replaced by two that can be answered: the workflow file is present
|
|
61
|
+
and valid in every repository that has one, and the gate runs locally. Whether GitHub
|
|
62
|
+
executes those workflows is a fact about the account, and a gate that reports a property it
|
|
63
|
+
cannot observe is ADR-039's defect — the step that cannot fail is the one nobody reads.
|
|
64
|
+
|
|
65
|
+
**Four. The estate's CI runs on pull requests, and delivery does not.** A check that fires on
|
|
66
|
+
a timer is not evidence about a change: by the time it reports, the commit is days old and
|
|
67
|
+
nobody is waiting on it. That is why the weekly runs were removed rather than de-scheduled.
|
|
68
|
+
It is also why the two API-contract workflows were split out of the `ci.yml` files they
|
|
69
|
+
shared: republishing to the edge needs a credential a fork does not have, so it fires on
|
|
70
|
+
merge to `main`, and one workflow cannot carry two triggers once its checks and its delivery
|
|
71
|
+
need different ones. The template moved in one commit and the estate followed the template,
|
|
72
|
+
which is the only way to move fourteen repositories that do not own the file.
|
|
73
|
+
|
|
74
|
+
**Five. The measure is `check`, and `compare` is what makes it visible at estate scale.**
|
|
75
|
+
ADR-045 made `compare` count a defect it had been hiding. This cycle found the same row
|
|
76
|
+
reporting every repository current while `check` exited non-zero in all of them. Both are now
|
|
77
|
+
driven at one fixture and compared — a parity test, because two copies of one question, each
|
|
78
|
+
asserted separately, is how they drift. That is ADR-052's shape, reached from a different
|
|
79
|
+
direction.
|
|
80
|
+
|
|
81
|
+
## Consequences
|
|
82
|
+
|
|
83
|
+
- **The roll-out closes with eleven of fourteen routed, and the three exceptions named in
|
|
84
|
+
three commit messages** rather than in a dashboard. An audit trail beats a count.
|
|
85
|
+
- **If one of the three gains something to run, it routes in one command** and nothing else
|
|
86
|
+
about it changes. This is not a permanent exemption; it is the current answer to "can this
|
|
87
|
+
repository enforce anything", and `compare` re-reads it every run.
|
|
88
|
+
- **The result is stronger than the row asked for.** Every repository's gate exits zero,
|
|
89
|
+
every manifest records a released version, no rule is loaded twice, no declared gate is
|
|
90
|
+
unrunnable, and no region sits past a harness's read limit. Three of those were false when
|
|
91
|
+
the row was written.
|
|
92
|
+
- **The CI clause is recorded as unverified, not met.** Nothing in a repository can make
|
|
93
|
+
GitHub execute a workflow, and a claim about a green build nobody has seen is the thing
|
|
94
|
+
this repository's own rules exist to prevent. Re-run the query and the clause comes back.
|
|
95
|
+
|
|
96
|
+
## Rejected
|
|
97
|
+
|
|
98
|
+
- **Route the three anyway, with an empty gate list.** `hooks install` already refuses —
|
|
99
|
+
it prints *"declares no gate it can run, so there is nothing to enforce."* Overriding it
|
|
100
|
+
means a shim on every commit that dispatches, reads a manifest, and exits having enforced
|
|
101
|
+
nothing. The cost is a repository that believes it is gated.
|
|
102
|
+
- **Declare a gate to satisfy the count.** That needs a name for a check that does not
|
|
103
|
+
exist. The Connect IQ repository has the shape of this: seven scripts in `scripts/` that
|
|
104
|
+
all run, and a gate vocabulary with no name for five of them. Two were declared, where the
|
|
105
|
+
name matched, and the other five stay manual rather than borrow `test` or `lint` — a gate
|
|
106
|
+
whose printed name misdescribes the command it runs teaches a reader not to trust the
|
|
107
|
+
other seven.
|
|
108
|
+
- **Keep the weekly runs because they are "just checks".** They were the most expensive
|
|
109
|
+
thing in the estate — the Android one booted a headless emulator every Saturday — and the
|
|
110
|
+
least timely. Where the checks were worth keeping they moved to the pull request that
|
|
111
|
+
changes the code, and one repository's own test suite refused the push that deleted them,
|
|
112
|
+
which is the gate working.
|
|
113
|
+
- **Close the row before measuring it.** Every figure in this record comes from a committed
|
|
114
|
+
build run over the estate, not from the working tree that produced it. That distinction
|
|
115
|
+
matters here: earlier in this cycle an apparent fourteen-repository defect turned out to be
|
|
116
|
+
caused by an uncommitted change, and the number would have been reported as a fact about
|
|
117
|
+
the estate.
|
|
118
|
+
|
|
119
|
+
## Notes
|
|
120
|
+
|
|
121
|
+
This record names no repository. The sweep that pseudonymised the estate used stable
|
|
122
|
+
`repo-NN` labels because several older records use repository names as *evidence* — a table
|
|
123
|
+
of how three repositories answer the same question, where "a repository and a repository"
|
|
124
|
+
would destroy the argument. That argument is not present here: every repository in this
|
|
125
|
+
record is identified by what it *is*, which is both publishable and more useful to a reader
|
|
126
|
+
who has none of the files.
|
|
127
|
+
|
|
128
|
+
`test/shipped.test.mjs` now fails on any estate name in anything this package publishes —
|
|
129
|
+
the records, the changelog, the readme. It is scoped to the published set and asserts that
|
|
130
|
+
scope against `package.json`, in both directions, so a directory added to `files` without
|
|
131
|
+
being guarded fails rather than leaking.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ADR-061 — The skill registry is workspace data, and the package ships none of it
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-12 · filed as S61
|
|
4
|
+
**Affects:** `graph lint`, `skills list`, `skills show`, `skills resolve`, `skills brief`,
|
|
5
|
+
`src/graph/load.mjs`, `package.json` `files`, the estate-name guard in `test/shipped.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
`graph/skills.graph.json` is published. It is in `files`, so it is in the npm tarball and in
|
|
10
|
+
the wheel, and `REGISTRY_PATH` finds it by resolving from `src/` — which in an installed
|
|
11
|
+
package is inside the package itself:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
export const REGISTRY_PATH = resolve(HERE, '..', '..', 'graph', 'skills.graph.json');
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
So the registry every one of those commands reads is not the reader's. It is *this* source
|
|
18
|
+
tree's catalogue of the skills that live in sibling repositories of this estate. An adopting
|
|
19
|
+
repository has none of them.
|
|
20
|
+
|
|
21
|
+
The estate-name guard found the symptom, and the symptom is the smaller half. Widened from
|
|
22
|
+
`.md` to every text extension the package ships, it reported **51 strings in that one file**
|
|
23
|
+
naming eight private repositories. The paths are the larger half and are not names at all:
|
|
24
|
+
every `sources[].path` is `.agents/skills/<id>` or `.cursor/skills/<id>`, and not one of
|
|
25
|
+
them resolves in a repository that is not this workspace. A reader who runs
|
|
26
|
+
`simt skills list` gets skills they do not have, and `simt skills resolve` finds the
|
|
27
|
+
declaration and then looks for a body that was never there. That is the corpus check this
|
|
28
|
+
repository exists to enforce, failing in a file this repository publishes, on a scale the
|
|
29
|
+
name grep was too narrow to see.
|
|
30
|
+
|
|
31
|
+
Two more facts made the shape of the answer unambiguous rather than a judgement call:
|
|
32
|
+
|
|
33
|
+
- **The sources are load-bearing, not decoration.** `graph lint` refuses a skill with none
|
|
34
|
+
("no sources; every skill must point at the file it came from"), and the same array does
|
|
35
|
+
byte verification, variant resolution and duplicate detection. Nothing can simply be
|
|
36
|
+
deleted from it.
|
|
37
|
+
- **There is already a workspace, and it is already resolved here.** `loadRegistry` calls
|
|
38
|
+
`workspace(flags)` before it validates anything, precisely so the sources can be checked
|
|
39
|
+
against a root. The registry is read from *inside the installed module* and verified
|
|
40
|
+
against *the reader's workspace* — a workspace that has nothing to do with it.
|
|
41
|
+
|
|
42
|
+
So the file is not being shipped with a defect in it. It is a file that describes this
|
|
43
|
+
workspace and is being shipped as though the workspace were universal, which is the one
|
|
44
|
+
condition under which it makes no sense to any reader.
|
|
45
|
+
|
|
46
|
+
## Rulings
|
|
47
|
+
|
|
48
|
+
1. **The skill registry is workspace data.** It describes which skill bodies a particular
|
|
49
|
+
workspace contains, where they are, how big they are, and which stack tags separate the
|
|
50
|
+
variants. Every one of those is a fact about a workspace and none of them is a fact
|
|
51
|
+
about the tool.
|
|
52
|
+
2. **The package ships no registry.** `graph` leaves `files`. The file stays in this
|
|
53
|
+
checkout, because this repository still has to lint and resolve its own skills, and the
|
|
54
|
+
rule that a tool must not carry its author's portfolio is about what it *publishes*.
|
|
55
|
+
3. **A command reads the workspace's registry, and says so when there is none.** The
|
|
56
|
+
registry lives in a *member* of the workspace, not in the root: the root is the
|
|
57
|
+
directory that contains the member repositories, and it holds no `graph/` of its own.
|
|
58
|
+
When the reader names no path, resolution proceeds in tiers. First, the package root —
|
|
59
|
+
which is how this checkout finds its own registry; in an installed package `graph` is not
|
|
60
|
+
in the published set (ruling 2), so it never does. Next, a member of the resolved
|
|
61
|
+
workspace, when exactly one holds it: a tool installed from node_modules or a formula is
|
|
62
|
+
configured to find its workspace, and the member that holds the registry is usually the
|
|
63
|
+
tool's own checkout. More than one holding it means the reader must pick; none means the
|
|
64
|
+
command reports the workspace has no registry, naming that workspace. And where there is
|
|
65
|
+
no workspace at all: an honest absence — the command says there is no registry to read,
|
|
66
|
+
with a remedy that names the ways to point at one — which is the same shape
|
|
67
|
+
`loadRegistry` already uses for a missing file, and it does not become a new failure
|
|
68
|
+
mode. An explicitly named path always wins over all of this, and every tier is file
|
|
69
|
+
existence plus the configured workspace: there is no scoring, so the answer can be
|
|
70
|
+
explained to whoever asks.
|
|
71
|
+
|
|
72
|
+
What must not happen is the current silence: `skills list` in an adopting repository
|
|
73
|
+
answering with this estate's skills is a confident wrong answer, and a wrong answer from
|
|
74
|
+
a command whose only job is to list what is here. Nor may the remedy be a dead end: a
|
|
75
|
+
reader who follows the ways to point at a workspace must get the registry that workspace
|
|
76
|
+
holds, which is what the member tier exists for.
|
|
77
|
+
4. **The guard keeps widening, and the suite stays red until the file is gone.** The
|
|
78
|
+
coverage test asserts in both directions that what is guarded is what is published, so
|
|
79
|
+
removing `graph` from `files` removes it from `SHIPPED_ROOTS` too — the two are one
|
|
80
|
+
number. Pseudonymising the names was rejected because it would have left the paths, the
|
|
81
|
+
byte counts and the variant stacks describing this workspace while hiding only the part
|
|
82
|
+
a grep can see.
|
|
83
|
+
|
|
84
|
+
## Consequences
|
|
85
|
+
|
|
86
|
+
- `simt skills list`, `show`, `resolve`, `brief` and `graph lint` in an adopting repository
|
|
87
|
+
report no registry rather than a foreign one. This is a behaviour change for anyone who
|
|
88
|
+
ran those commands and got this estate's skills; it was wrong, and the commands now say
|
|
89
|
+
so instead.
|
|
90
|
+
- A copy of the tool installed from node_modules or a formula, pointed at a workspace that
|
|
91
|
+
holds a registry in one of its members, reads that member's registry and verifies it
|
|
92
|
+
against the workspace. This is how this estate keeps using its skills from an installed
|
|
93
|
+
copy; without it the remedy the absence message prints would be a dead end.
|
|
94
|
+
- Byte verification still works here, because the file is still in this checkout. Nothing
|
|
95
|
+
about this repository's own use of the tool changes.
|
|
96
|
+
- `test/shipped.test.mjs`'s `SHIPPED_ROOTS` loses `graph`, and the guard still covers
|
|
97
|
+
`bin`, `src`, `rules` and `docs/decisions`. The estate-name count in that file drops to
|
|
98
|
+
zero, which is the point.
|
|
99
|
+
- The 51 strings are no longer shipped and the suite stops being red on purpose.
|
|
100
|
+
|
|
101
|
+
## Rejected
|
|
102
|
+
|
|
103
|
+
- **Pseudonymise the eight names to `repo-NN`.** It hides the part a grep can see and keeps
|
|
104
|
+
the part that matters: hundreds of paths that resolve nowhere for the reader, byte counts
|
|
105
|
+
of bodies they do not have, and variant stacks drawn from stacks they did not write. It
|
|
106
|
+
would also have required a second registry mapping the pseudonyms back, and that registry
|
|
107
|
+
is estate data — so it cannot be published either.
|
|
108
|
+
- **Ship the registry with an empty `sources`.** `graph lint` refuses a skill with no
|
|
109
|
+
sources, so this would have meant shipping a registry that fails its own lint, or
|
|
110
|
+
shipping a second, weaker lint for adopters.
|
|
111
|
+
- **Keep it, because a reader can ignore it.** The corpus check is not about whether a
|
|
112
|
+
reader is misled; it is about whether the shipped artefact is actionable by someone with
|
|
113
|
+
only the receiving repository. This one is not, for every field it has.
|