simt-dev 0.1.1 → 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.
Files changed (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. package/graph/skills.graph.json +0 -1622
@@ -0,0 +1,148 @@
1
+ # One tool, three channels, and the order is forced
2
+
3
+ Status: **accepted** · 2026-10-11
4
+
5
+ Affects: `pyproject.toml`, `python/simt_dev/`, `Formula/simt-dev.rb`,
6
+ `.github/workflows/publish.yml`, `scripts/release-formula.mjs`,
7
+ `scripts/verify-wheel.mjs`, `test/packaging.test.mjs`
8
+
9
+ ## Context
10
+
11
+ simt-dev is a Node program. It is also a *repository* tool: somebody adopting a
12
+ repository runs `simt init` inside it, and the places people run that from are
13
+ not all Node projects. A python developer in a repository that has no
14
+ `package.json` has no reason to install a package manager to get a command that
15
+ adopts their repository, and a Homebrew user has no reason to install Node to
16
+ install a tool that needs Node.
17
+
18
+ So the tool is published to three channels:
19
+
20
+ | Channel | What it installs |
21
+ | --- | --- |
22
+ | npm | a tarball of `.mjs` files |
23
+ | PyPI | a wheel carrying those same files, plus a `simt` entry point |
24
+ | Homebrew | a formula that downloads the npm tarball at install time |
25
+
26
+ The design constraint that decided all of it: the tool has **zero runtime
27
+ dependencies and no build step**. The whole thing is ~1.3 MB of text. That makes
28
+ the "packaging for a second language" problem a *copying* problem rather than a
29
+ build one, and copying is a problem with a right answer.
30
+
31
+ ## Ruling
32
+
33
+ 1. **The Python wheel carries the tool's own source, byte for byte.** `files` in
34
+ `package.json` and `force-include` in `pyproject.toml` name the same paths,
35
+ and `scripts/verify-wheel.mjs` builds both artefacts and hashes every file in
36
+ both, failing the release when a single byte differs.
37
+
38
+ The wheel is not a wrapper around `npm install`. A wrapper would fetch code
39
+ at install time - unpinned, unsigned by anything the installer can check, and
40
+ absent the moment somebody runs `pip install` on a train.
41
+
42
+ 2. **The version is one number, read rather than written.**
43
+ `pyproject.toml` declares `dynamic = ["version"]` and reads it from
44
+ `package.json` with a regex. A second literal would drift, and the drift
45
+ would be invisible until somebody ran `pip show` beside `simt --version` and
46
+ got two answers about which release they are running.
47
+
48
+ 3. **The Python launcher is standard library only, and has no required
49
+ dependencies.** The `[node]` extra is optional and imports `nodejs_wheel`
50
+ *inside a `try`, at the point of use* - never at module level - so the
51
+ default install cannot fail on a package it never asked for.
52
+
53
+ ADR-012 rules out runtime dependencies for the npm package. The reasoning
54
+ transfers without modification: a dependency is a runtime this package cannot
55
+ verify and its owner cannot patch, and this package exists to be the thing
56
+ people run in a repository rather than the thing they have to curate.
57
+
58
+ 4. **`simt` is the one command, on every channel.** `bin` in `package.json` and
59
+ `[project.scripts]` in `pyproject.toml` are asserted equal, and the formula's
60
+ `test do` block runs it. A second name under the package name would be a
61
+ convenience that forks the documentation the first time somebody documents
62
+ one channel and not the other.
63
+
64
+ 5. **Homebrew installs the npm tarball, and does not use `std_npm_args`.**
65
+ `depends_on "node"` declares the runtime; the entry point's shebang is
66
+ rewritten to that node rather than left at `#!/usr/bin/env node`, so the
67
+ command works from a cron job and from a shell that has moved node out of
68
+ the way, both of which look identical to a broken install.
69
+
70
+ `std_npm_args` invokes npm at build time, which needs the registry at build
71
+ time. This package has nothing to install, so that would add a network fetch
72
+ to installing the one thing in the estate that provably needs none.
73
+
74
+ 6. **`Formula/simt-dev.rb` is the source of truth, and `scripts/release-formula.mjs`
75
+ patches two lines in it.** It does not regenerate the formula from a
76
+ template: a template would mean the bytes a reader sees in this repository
77
+ are not the bytes a tap serves, which is the defect ADR-023 ruled out for the
78
+ gate file. The two lines are matched by content, not by line number, because
79
+ a line number would make the formula a file that cannot be edited - adding a
80
+ comment above the `sha256` line would silently write the checksum to the
81
+ wrong place.
82
+
83
+ 7. **The npm version must be live before `publish-pypi` and `publish-brew`
84
+ succeed, and both jobs say so when it is not.** The order is not a
85
+ preference. Homebrew downloads from the npm registry at install time, so a
86
+ formula naming a version that is still in npm's staging area installs a
87
+ 404 - on somebody else's machine, at a tap, from a release this workflow
88
+ already declared done.
89
+
90
+ Those two jobs are the ones a maintainer runs again after
91
+ `npm stage approve`. "Re-run failed jobs" carries the tag context, which a
92
+ `workflow_dispatch` does not: on a dispatch, `github.ref_name` is the branch,
93
+ and the version guard would compare a tag against a branch.
94
+
95
+ ## Consequences
96
+
97
+ The invariants split in two, and the split is deliberate.
98
+
99
+ `test/packaging.test.mjs` holds the **static** ones: that `files` and
100
+ `force-include` name the same paths, that the version is not written twice, that
101
+ the command is one name, that the launcher imports nothing outside the standard
102
+ library, that no second build system sits at the repository root. Every one of
103
+ those is checkable from this checkout with no network and no interpreter, which
104
+ is what lets it run in `npm test`.
105
+
106
+ `scripts/verify-wheel.mjs` holds the one that is not: that the paths, once
107
+ built, contain the same bytes. It runs in the release workflow, where a Python
108
+ and a registry are available by construction.
109
+
110
+ That split is ADR-031 applied. A test that needed `pip` would not run for the
111
+ next person who clones this repository, and would run differently on the
112
+ machine that wrote it - which is the failure that suite is about.
113
+
114
+ Two findings from building it are recorded because they are the kind that only
115
+ appear when a check is real:
116
+
117
+ - The first run of `verify-wheel.mjs` reported `LICENSE` and `README.md` as
118
+ differences. They are not defects: npm adds both to every tarball, and the
119
+ wheel carries them as *distribution metadata* rather than as files. The
120
+ comparison is now scoped to what `files` names, which is the content the tool
121
+ reads.
122
+ - The first run of that same comparison reported every one of `bin`, `src`,
123
+ `graph` and `rules` as missing from both, because `files` names **directories**
124
+ and npm includes them recursively. The expansion happens in the script now,
125
+ with the reason stated where the code is.
126
+
127
+ The check was also tampered with deliberately - a wheel-only `README.md` was
128
+ injected into `force-include` and it failed - because a parity check that has
129
+ never failed is not evidence that it works.
130
+
131
+ ## Rejected
132
+
133
+ - **A Python wrapper that runs `npm install -g simt-dev` on first use.** This is
134
+ what the unofficial `pyright` package does, and it has the properties it is
135
+ reputed to have: a download on first run, unpinned, uncacheable, and a failure
136
+ mode that reads as a broken package rather than as a missing runtime.
137
+ - **Requiring node, with no bundled fallback.** The reader who installs from
138
+ PyPI is frequently the reader with no node, which is the whole reason the
139
+ channel exists. The failure message therefore names the `[node]` extra first,
140
+ because it is the one remedy that is the same command on every platform.
141
+ - **Guessing where node is.** A hardcoded list of `/opt/homebrew/bin/node`,
142
+ `/usr/local/bin/node` and `~/.nvm/...` is three guesses and a surprise: it
143
+ prefers a guess over the reader's own `PATH`, and it is already out of date
144
+ for anyone who installs node differently. `shutil.which` asks the question
145
+ instead of answering it.
146
+ - **`std_npm_args`.** See ruling 5.
147
+ - **Publishing all three channels simultaneously.** Rejected by the ordering in
148
+ ruling 7 - Homebrew cannot name a version npm has not released.
@@ -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.
@@ -0,0 +1,94 @@
1
+ # ADR-062 — A fresh adoption seeds the canonical gates it can run, and never more
2
+
3
+ Status: **accepted** · 2026-10-13 · filed as S40
4
+ **Affects:** `simt init`, `src/projection/manifest.mjs` (the `ECOSYSTEMS` table, `defaultGates`),
5
+ `src/hooks/capability.mjs` (the tool probe, the `tools` field), `src/commands/init.mjs`,
6
+ eight new files in `rules/stacks/`
7
+
8
+ ## Context
9
+
10
+ S40's finding: the eight common ecosystems outside the JavaScript world — Rust, Go, .NET,
11
+ Swift, Deno, Ruby, Dart and Elixir — emit no stack tag. The fingerprint recognises them as
12
+ `has-*` facts, and the only reader of those facts was the Dependabot configuration. So a
13
+ fresh Rust repository received the core essay and a manifest that runs nothing: no profile
14
+ was selected because there was none to select, and no gate was proposed because no row keyed
15
+ on a fact. The asymmetry is the defect — the ecosystems the tool recognises were the ones it
16
+ served least, while the ecosystems that do emit stack tags got the full treatment.
17
+
18
+ Two existing mechanisms make the fix data-only, per ADR-013:
19
+
20
+ - **Profiles select on tags or facts.** `simt-applies` already accepts a fact, and the
21
+ genericity tests already assert that a profile may select on a fact no stack tag names.
22
+ - **Gates are planned by signal.** `planGates` takes the union of stack tags and facts, so a
23
+ row can key on either; the two never cross-match, because facts are `has-*` and tags are
24
+ not.
25
+
26
+ What was missing is one artefact per ecosystem: the profile file — and for gates, a row that
27
+ says what a fresh adoption of one should declare. The second is the harder half, because
28
+ declaring a gate is not free:
29
+
30
+ A declared gate that cannot run is worse than an absent one. Absent, an author sees nothing
31
+ and knows nothing is being checked; declared-and-unrunnable, the dispatcher fails closed on
32
+ every commit, and the only exit an author can find is `--no-verify`, which teaches the whole
33
+ team that the gate is theatre. So the question a seeding design has to answer is not "which
34
+ ecosystem is here" — the marker file answers that — but "can this machine run what the gate
35
+ says it runs". That is ADR-017's probe-before-declare, and it shapes every ruling below.
36
+
37
+ ## Rulings
38
+
39
+ 1. **Every ecosystem the fingerprint names gets a profile.** Eight new stack profiles, each
40
+ `simt-applies:` on its own fact (the files are named in the Affects line), so one marker
41
+ file reaches both the region the repository reads and the gates its commits run. Maven's profile (`jvm`) predates
42
+ this ruling and applies on the same fact, so the table has nine rows and eight new files.
43
+ An ecosystem arriving later is a profile file and a row: no engine change (ADR-013).
44
+ 2. **A fresh adoption seeds the canonical commands, and only where the probe found the
45
+ tool.** On `init`, when the fingerprint emits an ecosystem fact, the row's commands land in
46
+ `gates.commands` for each gate it proposes — but only when the probe found `tool`. The
47
+ probe is the same discipline ADR-017 applies to node tools: a binary on PATH, or a file in
48
+ the repository when the row says `file` (the Maven wrapper, probed the way `gradlew`
49
+ already is). A marker says the ecosystem is here; the probe says its program can run. Both
50
+ must be true for a seed to land. `readCapability` reports the results in a `tools` field
51
+ next to `binaries`, so the plan can say which seeds fired and which did not.
52
+ 3. **A seed is a default, and a default loses to every form of declaration.** A command the
53
+ repository already declared in `gates.commands` is never re-seeded. An npm runner keeps its
54
+ own scripts untouched: `package.json` is the repository's own answer to "how do I test and
55
+ format", and a seed that outranked a script would replace a choice with a default —
56
+ precisely in the mixed repository (a `package.json` and a `Cargo.toml`) where the script is
57
+ the deliberate answer. The escape hatch for a repository whose runner differs from the
58
+ canonical one (a Rails app, a Flutter module) is to declare its own command, and
59
+ declaration beats seed everywhere.
60
+ 4. **Where the probe fails, the gate is dropped with a why and a remedy, never declared.**
61
+ This is the invariant `planGates` already holds for every proposed gate; seeding inherits
62
+ it rather than creating an exception to it. A fresh Rust checkout on a machine without
63
+ cargo gets `dropped gate test: …` with a remedy that names the install, not a gate that
64
+ blocks its first commit.
65
+
66
+ ## Consequences
67
+
68
+ - `simt init` on a fresh Rust, Go, .NET, Swift, Deno, Ruby, Dart or Elixir repository declares
69
+ runnable gates when the toolchain is present — a `seeded` line in the plan output names each
70
+ command that landed — and drops them with a remedy when it is not.
71
+ - `readCapability` gains the probe and the `tools` field: a PATH lookup, plus an executable
72
+ check in the repository root for a `file` row.
73
+ - `defaultGates`'s expansion of an ecosystem row tolerates a missing phase key: the Maven row
74
+ proposes only pre-push, and absence is "none", not a defect.
75
+ - The seed picks one canonical command per gate (`bundle exec rspec`, `dart test`). A
76
+ repository whose runner differs declares its own; ruling 3 makes that declaration win.
77
+ - Acceptance: one test per ecosystem asserting marker → fact → profile selection; a seeding
78
+ matrix (every row seeds when the tool can run, every row drops when it cannot, an npm
79
+ repository takes no seed, a declaration beats a seed); and two end-to-end Rust inits, with
80
+ and without `cargo` on PATH.
81
+
82
+ ## Rejected
83
+
84
+ - **Seed on the marker's word alone.** The marker is a file in the repository; the tool is a
85
+ program on the machine. A repository can have one without the other — a freshly cloned Rust
86
+ checkout on a machine that has never installed the toolchain is the common case — and
87
+ seeding there would declare a gate that fails closed on every commit. ADR-017 exists for
88
+ exactly this.
89
+ - **Seed into an npm repository's scripts.** The scripts are the repository's own answer, and
90
+ a seed that outranks them replaces a choice with a default. The mixed repository is where
91
+ this matters most, because there the script and the marker coexist on purpose.
92
+ - **A second gate table or a new engine path for ecosystems.** The fact was already flowing
93
+ into `planGates`, and the union of tags and facts never cross-matches. What was missing was
94
+ a row and a file, which is data; the engine that reads it already existed.
@@ -0,0 +1,69 @@
1
+ # ADR-063 — The adoption escape hatch is opt-in, and the default writes in place
2
+
3
+ Status: **accepted** · 2026-10-13 · filed after a consumer asked why every adoption branched
4
+ **Affects:** `simt init`, `src/commands/init.mjs` (`establishEscapeHatch` and its call site),
5
+ `src/argv.mjs`, `src/commands/help.mjs`; amends plan 0002 §8.2 item 3
6
+
7
+ ## Context
8
+
9
+ Plan 0002 §8.2 item 3 ruled: "Create a branch and a `backup-before-simt-dev` tag before the
10
+ first write, always." The tool complied — `simt init --yes` in a git repository tagged the
11
+ pre-adoption commit, checked out a branch named after the tool, and wrote every adoption file
12
+ onto it. The reviewable-diff argument is sound: adoption writes a dozen files into a
13
+ repository the tool does not own, and doing it on a dedicated branch leaves the mainline
14
+ untouched with two named undo paths.
15
+
16
+ The "always" was the part that failed. Consumers kept reporting the branch switch as a
17
+ surprise: they ran the documented command to adopt their repository, and found themselves on
18
+ a branch they had not chosen, with a tag they had not asked for. Review 0001's S15 fixed the
19
+ silence — the output now names both operations and the command that undoes each — but a
20
+ well-announced surprise is still a surprise. The branch and the tag are an undo path for
21
+ readers who want one; making them unconditional turned a safety feature into a default that
22
+ every consumer has to explain away.
23
+
24
+ ## Rulings
25
+
26
+ 1. **The default adoption writes in place and creates no git object.** `simt init --yes`
27
+ without the new flag leaves the reader on the branch they were on, tags nothing, and
28
+ writes the adoption files as uncommitted changes. The reviewable diff survives: `git diff`
29
+ shows the whole adoption, and discarding it is `git checkout -- .` plus a clean. The run
30
+ says so in an `in place` line that names the flag which restores the undo path.
31
+ 2. **The escape hatch moves behind a flag named for what it gives.** `--backup` is the
32
+ opt-in: tag the pre-adoption commit as the backup tag and branch onto the adopt branch
33
+ before the first write, exactly as the tool did before. The name is the promise — a way
34
+ back to what the repository was — and it already appears in the output the tool prints, so
35
+ the flag and its effect share one word.
36
+ 3. **Whatever a run does to git state is reported, including doing nothing.** Plan 0002's
37
+ heading over the adoption rules is "Nothing is silent". With the hatch opt-in, the
38
+ in-place run is a git-state event too — the deliberate absence of one — and it gets a line
39
+ that says what was not done and how to get it done. The `--backup` run keeps the two lines
40
+ S15 established: what was tagged, and where the branch came from.
41
+
42
+ ## Consequences
43
+
44
+ - `simt init --yes` on a git repository no longer moves the reader or writes a tag; consumers
45
+ who scripted against the old default add `--backup`. This is the one behaviour the change
46
+ invalidates, and it is named here rather than discovered in a broken pipeline.
47
+ - The `in place` line appears on every real adoption that does not pass the flag, and the
48
+ help text for `init` and for the flag say what it means.
49
+ - The dry-run path is untouched: it already created no git object, and it still says `apply
50
+ with: simt init --yes`.
51
+ - Acceptance: the S15 test now runs with the flag and asserts both operations are reported;
52
+ a new test runs without it and asserts no tag, no branch, the reader stays put, and the
53
+ in-place line names the flag; the not-a-git-work-tree test runs with the flag, because that
54
+ is where the best-effort note lives now.
55
+
56
+ ## Rejected
57
+
58
+ - **Keep "always" and add a flag to opt out.** An opt-out flag fixes the surprise for those
59
+ who read the help, and it still surprises everyone else — which is every consumer of a tool
60
+ whose default action is to touch someone's repository. The cost of the wrong default is
61
+ paid on every adoption; the cost of the right default is one flag on the few that want it.
62
+ - **Two flags, one per operation.** The tag and the branch arrive together in the code and in
63
+ the undo story: the tag is the reset target, the branch is where the adoption sits while it
64
+ is reviewed. Splitting them lets a reader build a state the tool has never tested — a branch
65
+ without a tag to reset to, or a tag with nothing isolated under it.
66
+ - **Move the decision into the dry-run preview.** The dry run shows what would change in the
67
+ files; git state is not a file, and a preview line for an operation that now does not happen
68
+ by default would describe the opt-in as if it were the plan. The `in place` line after
69
+ `--yes` is where the reader learns what the real run did not do.