simt-dev 0.0.0-stage → 0.1.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 (128) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -2
  4. package/bin/simt-dev.mjs +28 -0
  5. package/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  6. package/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  7. package/docs/decisions/ADR-003-test-breadth.md +46 -0
  8. package/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  9. package/docs/decisions/ADR-005-file-length.md +42 -0
  10. package/docs/decisions/ADR-006-coverage-bar.md +59 -0
  11. package/docs/decisions/ADR-007-section-skeleton.md +54 -0
  12. package/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  13. package/docs/decisions/ADR-009-skill-layout.md +48 -0
  14. package/docs/decisions/ADR-010-runner-invocation.md +56 -0
  15. package/docs/decisions/ADR-011-windows-support.md +52 -0
  16. package/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  17. package/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  18. package/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  19. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  20. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  21. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  22. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  23. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  24. package/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  25. package/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  26. package/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  27. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  28. package/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  29. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  30. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  31. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  32. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  33. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  34. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  35. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  36. package/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  37. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  38. package/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  39. package/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  40. package/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  41. package/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  42. package/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  43. package/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  44. package/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  45. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  46. package/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  47. package/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  48. package/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  49. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  50. package/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  51. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  52. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  53. package/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  54. package/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  55. package/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  56. package/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  57. package/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  58. package/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  59. package/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  60. package/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  61. package/docs/decisions/README.md +528 -0
  62. package/graph/skills.graph.json +1622 -0
  63. package/package.json +41 -4
  64. package/rules/core.md +76 -0
  65. package/rules/mdc/intake-questions.mdc +50 -0
  66. package/rules/mdc/preview-postflight.mdc +61 -0
  67. package/rules/mdc/slice-commit.mdc +52 -0
  68. package/rules/mdc/test-gate.mdc +54 -0
  69. package/rules/stacks/android.md +63 -0
  70. package/rules/stacks/cloudflare-workers.md +65 -0
  71. package/rules/stacks/connectiq.md +62 -0
  72. package/rules/stacks/container.md +31 -0
  73. package/rules/stacks/jvm.md +54 -0
  74. package/rules/stacks/node.md +45 -0
  75. package/rules/stacks/python.md +48 -0
  76. package/rules/stacks/react-native.md +58 -0
  77. package/rules/stacks/react-web.md +61 -0
  78. package/rules/stacks/typescript.md +21 -0
  79. package/rules/stacks/uv.md +32 -0
  80. package/src/argv.mjs +116 -0
  81. package/src/ci/dependabot.mjs +268 -0
  82. package/src/ci/workflow.mjs +360 -0
  83. package/src/commands/ci.mjs +285 -0
  84. package/src/commands/compare.mjs +195 -0
  85. package/src/commands/graph.mjs +73 -0
  86. package/src/commands/help.mjs +108 -0
  87. package/src/commands/hooks.mjs +471 -0
  88. package/src/commands/index.mjs +241 -0
  89. package/src/commands/init.mjs +566 -0
  90. package/src/commands/mcp.mjs +63 -0
  91. package/src/commands/rules.mjs +374 -0
  92. package/src/commands/skills.mjs +647 -0
  93. package/src/commands/skillsinstall.mjs +515 -0
  94. package/src/commands/sync.mjs +331 -0
  95. package/src/commands/upgrade.mjs +202 -0
  96. package/src/compare/discover.mjs +212 -0
  97. package/src/compare/hooksstate.mjs +159 -0
  98. package/src/compare/row.mjs +299 -0
  99. package/src/decisions/index.mjs +512 -0
  100. package/src/graph/load.mjs +541 -0
  101. package/src/hooks/capability.mjs +129 -0
  102. package/src/hooks/dispatcher.mjs +268 -0
  103. package/src/hooks/gates.mjs +144 -0
  104. package/src/hooks/resolve.mjs +194 -0
  105. package/src/hooks/shim.mjs +140 -0
  106. package/src/hooks/where.mjs +118 -0
  107. package/src/locate.mjs +75 -0
  108. package/src/mcp/server.mjs +167 -0
  109. package/src/mcp/tools.mjs +410 -0
  110. package/src/name.mjs +95 -0
  111. package/src/output/index.mjs +96 -0
  112. package/src/projection/baselines.mjs +209 -0
  113. package/src/projection/conflicts.mjs +205 -0
  114. package/src/projection/manifest.mjs +478 -0
  115. package/src/projection/markers.mjs +349 -0
  116. package/src/projection/mdc.mjs +327 -0
  117. package/src/projection/project.mjs +504 -0
  118. package/src/projection/rules.mjs +640 -0
  119. package/src/projection/surfaces.mjs +398 -0
  120. package/src/projection/targets.mjs +221 -0
  121. package/src/resolve/fingerprint.mjs +553 -0
  122. package/src/resolve/near.mjs +61 -0
  123. package/src/resolve/resolve.mjs +457 -0
  124. package/src/resolve/source.mjs +105 -0
  125. package/src/toolchain.mjs +202 -0
  126. package/src/tsconfig.mjs +314 -0
  127. package/src/upgrade/plan.mjs +203 -0
  128. package/src/workspace.mjs +289 -0
@@ -0,0 +1,211 @@
1
+ # ADR-014 — The workspace is configuration, and a single repo is a first-class case
2
+
3
+ Status: **accepted** · 2026-10-05
4
+ Affects: `simt-dev` CLI (workspace resolution, `compare`, `skills`, registry validation, packaging)
5
+
6
+ ## Context
7
+
8
+ ADR-013 made the *vocabulary* generic. It did not make the *filesystem layout* generic, and the
9
+ gap was invisible from inside the checkout. Three failures were measured rather than reasoned
10
+ about, by running the tool from a throwaway repo outside `/code/simt` and from a simulated
11
+ `node_modules` install:
12
+
13
+ 1. **Every `skills` command and `graph lint` fails outside the SIMT workspace.** `WORKSPACE_ROOT`
14
+ was `resolve(HERE, '..', '..', '..')` — three levels up from the source file. In the checkout
15
+ that is `/code/simt`, which is correct. In an npm install it is
16
+ `node_modules/@quazarmedia`:
17
+
18
+ | install location | `WORKSPACE_ROOT` resolves to |
19
+ |---|---|
20
+ | `/code/simt/simt-dev/src/graph` | `/code/simt` — correct |
21
+ | `…/node_modules/@quazarmedia/simt-dev/src/graph` | `…/node_modules/@quazarmedia` — wrong |
22
+
23
+ The symptom is `ENOENT` for all 38 sources. The *remedy* printed is
24
+ `run: simt-dev graph lint`, which is itself wrong, because the reader has no workspace to lint.
25
+
26
+ 2. **`rules why` cannot work in the published package.** `package.json` `files` lists
27
+ `bin src graph rules skills agents tooling hooks` and not `docs`. `npm pack` produces 39 files
28
+ and zero from `docs/`, so every ADR lookup fails in a real install. The whole provenance
29
+ feature — the reason `rules why` exists — is unusable outside the checkout.
30
+
31
+ 3. **`compare` treats a repo as a workspace and rejects it.** `--root <a-repo>` fails with
32
+ `contains no git repositories` and exit 3, because `discover` scans the *children* of the
33
+ given directory. The most common invocation outside a multi-repo workspace — point the tool at
34
+ one repo — is an error. The remedy, `no git repositories found under <path>`, is also
35
+ misleading: there is one, you are standing in it.
36
+
37
+ None of this was visible in the tests, because every test ran from the checkout. That is the
38
+ general lesson and it is the reason this record exists: **a tool's own layout is part of its
39
+ interface, and a test suite that only runs the tool from its source tree cannot detect that the
40
+ tool only works from its source tree.**
41
+
42
+ The related question — how does `simt-dev upgrade` know which repos to bump? — has the same root
43
+ cause. There is no consumer registry on disk. The only thing aware of sibling repos is
44
+ `compare`'s one-level-deep scan, rooted at `..` of the *current checkout*. That is why
45
+ `compare` already prints `run: simt-dev upgrade` for every drifted repo: the detection half exists and
46
+ the remedy is a dangling reference.
47
+
48
+ ## Ruling
49
+
50
+ ### 1. The workspace is configuration, resolved in a fixed, reported order
51
+
52
+ Precedence, highest first:
53
+
54
+ 1. `--workspace <path>` — an explicit flag always wins.
55
+ 2. `SIMT_DEV_WORKSPACE` — the environment variable, for CI and one-off invocations.
56
+ 3. A user config file at the platform's conventional location (§2).
57
+ 4. The sibling heuristic — the parent of this package, when that parent actually contains git
58
+ repositories. This is what makes a fresh clone of the checkout work with no configuration at
59
+ all, and it is a **heuristic, not a rule**: it is consulted last and it is only accepted when
60
+ it verifies.
61
+
62
+ **Every command that consults the workspace reports where it came from.** `simt-dev doctor` prints
63
+ the resolved root and its source. An invisible configuration is a configuration nobody can debug,
64
+ and a silent fallback to a wrong directory is the worst outcome available: every answer is then
65
+ confidently wrong.
66
+
67
+ ### 2. The user config file lives where the platform expects it, with a documented fallback
68
+
69
+ Candidate paths, searched in order; the first that exists wins, and the first is the one named
70
+ when reporting a path to create:
71
+
72
+ | Platform | Candidates, in order |
73
+ |---|---|
74
+ | any | `$XDG_CONFIG_HOME/simt-dev/config.json` |
75
+ | POSIX | `$HOME/.config/simt-dev/config.json` |
76
+ | macOS | `$HOME/Library/Application Support/simt-dev/config.json` |
77
+ | Windows | `%APPDATA%/simt-dev/config.json` |
78
+
79
+ `$XDG_CONFIG_HOME` first because it is the only way to *override* the location on a POSIX system,
80
+ and honouring an explicit override before guessing is the whole point. macOS is listed after the
81
+ XDG candidates deliberately: `~/Library/Application Support` is Apple's documented location for
82
+ application data, but a tool that honours XDG where set is friendlier to a developer who already
83
+ uses it, and the fallback means neither convention is unsupported.
84
+
85
+ The file is JSON. Its shape is deliberately **not** a workspace-shaped object:
86
+
87
+ ```json
88
+ {
89
+ "workspace": "/path/containing/repos"
90
+ }
91
+ ```
92
+
93
+ `workspace` is an optional key. An empty, absent or unreadable file is not an error — it means
94
+ "not configured", and resolution continues down the precedence list. Only a file that exists and
95
+ does not parse is reported, because that is a real mistake the reader can fix.
96
+
97
+ Windows appears in the table although [ADR-012](ADR-012-drop-windows-support.md) drops Windows
98
+ support. The lookup function stays total so it is testable on any host and correct if that
99
+ reversal ever happens; `package.json` still refuses to install on Windows.
100
+
101
+ ### 3. A workspace is optional; a single repo is a normal, supported case
102
+
103
+ Every command that needs a workspace must work with **one repo and no workspace at all**. Concretely:
104
+
105
+ - `compare --root <path>` where `<path>` is a repository compares that repository. Where it is a
106
+ directory, its git-repository children are compared. The distinction is detected, not configured,
107
+ because the two cases are already distinguishable from the filesystem.
108
+ - Discovery no longer reports "no git repositories" when it is standing in one.
109
+ - Nothing anywhere requires a second repo to exist.
110
+
111
+ ### 4. A missing workspace is a fact, reported once — never a fatal error
112
+
113
+ When no workspace is configured and the heuristic does not verify, the skill catalogue cannot be
114
+ resolved from disk. That is a **warning with one remedy**, not an error:
115
+
116
+ ```
117
+ WARNING skill sources are not available: no workspace configured
118
+ REMEDY set one with --workspace <path>, SIMT_DEV_WORKSPACE, or the config file
119
+ ```
120
+
121
+ Registry disk verification is **skipped** in that state, with the skip itself reported by
122
+ `graph lint`. The alternative — 38 `ENOENT` lines — teaches the reader that the lint is noisy,
123
+ and a lint that is always noisy is a lint that is always ignored.
124
+
125
+ The exit code follows the same principle as `compare`'s non-adoption rule
126
+ ([ADR-013](ADR-013-genericity-by-facts-and-local-profiles.md), and step 9 of plan 0002): exit
127
+ non-zero when something is **wrong**, zero when something is merely **absent**.
128
+
129
+ ### 5. The decision records ship with the package
130
+
131
+ `docs/decisions` is added to `package.json` `files`. A feature whose input is not packaged is not
132
+ a feature; `rules why` is the provenance mechanism the whole one-canonical-home doctrine rests
133
+ on, and it cannot depend on a git checkout being present.
134
+
135
+ `docs/plans` ships with it, because every non-zero exit prints a remedy pointing at
136
+ `docs/plans/0002-simt-dev-cli.md section 11`.
137
+
138
+ ### 6. "Absent", "wrong" and "moved" are three different facts
139
+
140
+ Section 4 settled the case where **no** workspace is configured. Writing the tests for it exposed
141
+ two more, and they are not the same case:
142
+
143
+ | State | Reported as |
144
+ |---|---|
145
+ | Nothing configured | `no workspace configured` + the remedy |
146
+ | Configured, directory **missing** | `the configured workspace <path> (from <source>) does not exist` |
147
+ | Configured, directory exists but **holds none of the named repositories** | `<path> contains none of the repositories the registry names (…)` |
148
+
149
+ All three are warnings and all three skip disk verification. What changed is the **specificity**
150
+ of the message, and that specificity is the whole value: "you configured nothing" and "you
151
+ configured the wrong directory" are different problems with different fixes, and the reader can
152
+ only fix the one they are told about.
153
+
154
+ The third state is the one that mattered. Existence is not a sufficient test for a workspace, and
155
+ before this section `--workspace <an empty directory>` produced 38 `ENOENT` lines reading as a
156
+ broken registry — precisely backwards, because the registry is fine and the pointer is wrong. So
157
+ verification requires that the workspace hold at least one of the repositories the registry
158
+ names, checked before any file is measured.
159
+
160
+ ### 7. A workspace can be declined, and a broken config is reported whatever outranks it
161
+
162
+ Two things the first implementation got wrong, both found by running the CLI rather than by
163
+ testing a function:
164
+
165
+ - **There was no way to say "no workspace".** The sibling heuristic always fired when nothing
166
+ was configured, so a reader who did not want a directory guessed for them had no way to refuse
167
+ — and no test running from the checkout could reach the unconfigured path at all, because the
168
+ checkout always has a verifying sibling. `--workspace off`, `SIMT_DEV_WORKSPACE=off` and
169
+ `"workspace": "off"` now all mean *declined*, and return the same shape as any other answer so
170
+ callers need no special case.
171
+ - **A config file that does not parse was only read if nothing outranked it.** Precedence decided
172
+ the *answer*, but it was also suppressing a *defect*: an unparseable config is a real mistake in
173
+ the reader's setup, and hiding it because `SIMT_DEV_WORKSPACE` happened to be set sends them
174
+ looking for a configuration problem in the wrong place. The config file is therefore read
175
+ first, and a parse failure is returned immediately.
176
+
177
+ That second bug is the standing lesson of this record, discovered by its own meta-lesson:
178
+ `configPaths` defaulted `env` to `{}` so that tests could inject a fake environment, which meant
179
+ `XDG_CONFIG_HOME` — the one documented way to *override* the config location — was ignored on
180
+ every real invocation. Every unit test passed `{env}` explicitly. **A test that supplies a
181
+ dependency explicitly cannot detect that the default for that dependency is wrong.**
182
+
183
+ ## Consequences
184
+
185
+ - The checkout keeps working with zero configuration, because the sibling heuristic is consulted
186
+ last and verified.
187
+ - A single developer with one repository never has to learn what a workspace is. `compare`,
188
+ `rules`, `sync`, `check`, `hooks` and `init` all work against one repo.
189
+ - The skill catalogue — which is SIMT content — degrades to a single warning outside the SIMT
190
+ workspace, while the *rules* engine stays fully functional, because `rules/` ships in the
191
+ package and `rules/*.md` has no external sources.
192
+ - `simt-dev upgrade` gets a real answer for consumer discovery: the resolved workspace, plus an
193
+ explicit `--root` for a single repo. No consumer list needs to be maintained in simt-dev,
194
+ because a repo that has not adopted cannot be listed by the thing it has not adopted.
195
+
196
+ ## Rejected
197
+
198
+ - **A consumer list inside simt-dev.** A repo that has not adopted has no manifest, so it cannot
199
+ appear in a manifest kept by the tool it has not adopted. Discovery has to come from the
200
+ filesystem.
201
+ - **Inferring the workspace from git remotes.** Fragile, and wrong in the common case: several
202
+ sibling repos legitimately share one remote organisation, and a personal fork of one repo has a
203
+ remote that points nowhere useful.
204
+ - **Requiring a workspace.** It would make the common case — one repo, one user, no org — pay a
205
+ configuration tax to use a tool whose rules engine is already ecosystem-neutral.
206
+ - **A machine-wide registry of skill sources.** A directory of symlinks into the workspace would
207
+ reintroduce exactly the cross-repo symlink fragility that made plan 0001 choose a generated
208
+ projection instead.
209
+ - **Treating an unresolvable workspace as a registry error.** Same failure as printing 38
210
+ `ENOENT` lines: it converts an absent thing into a loud failure and trains the reader to ignore
211
+ the output.
@@ -0,0 +1,117 @@
1
+ # ADR-015 — A source is identified by its content; the repository is a location, not a precondition
2
+
3
+ Status: **accepted** · 2026-10-05
4
+ Affects: `resolve/source.mjs`, `skills install`, `skills show`, `skills brief`
5
+
6
+ ## Context
7
+
8
+ [ADR-014](../decisions/ADR-014-workspace-is-configuration.md) made the workspace optional and
9
+ single-repo use first class. It fixed resolution, not lookup, and the next defect was found
10
+ immediately by the user who asked for the previous one.
11
+
12
+ Someone who cloned **only `simt-social`** has the `simt-social` body of `slice-commit` on disk at
13
+ `.agents/skills/slice-commit`. They have no workspace. The registry entry
14
+ `simt-social/.agents/skills/slice-commit` is satisfied by that file. Both commands refused:
15
+
16
+ ```
17
+ $ simt-dev skills install slice-commit
18
+ ERROR 2 slice-commit has 2 variants and none matches this repo (node)
19
+
20
+ $ simt-dev skills show slice-commit
21
+ (body unavailable: this installation has no workspace, so slice-commit has no body to fetch; …)
22
+ ```
23
+
24
+ The body was in the current directory for both.
25
+
26
+ The root cause is a definition that was never written down. `sources: [{repo, path, bytes, role}]`
27
+ resolves at exactly one place — `join(workspaceRoot, repo, path)`. There is a second place, and it is
28
+ not even exotic: **`<root>/<path>`**, because the repository being operated on is itself a location
29
+ for a declared source when the registry entry's `repo` is that repository.
30
+
31
+ The refusal was also wrong for a reason that had nothing to do with the workspace. `slice-commit`
32
+ has two variants, `web` and `api`, discriminated by stack tag. That clone's `package.json` emitted
33
+ `node`, which matches neither, so variant selection failed — while the clone's own 5048 bytes were
34
+ an exact answer sitting in the working tree. **The tool asked the manifest a question the manifest
35
+ could not answer, and ignored the evidence in front of it.**
36
+
37
+ ## Ruling
38
+
39
+ ### 1. A declared source is found by content, at every legal location
40
+
41
+ `resolve/source.mjs` exports `candidateLocations(source, {workspaceRoot, root})` — the workspace
42
+ location first, then the repo-relative location — and `locateSource(source, options)`, which accepts
43
+ a candidate **only when the measured size equals the declared size**.
44
+
45
+ Bytes are what make this safe rather than hopeful. Every source declares its size, and `graph lint`
46
+ already verifies every declared size against the file on disk. A candidate that exists at the right
47
+ path with the wrong size is not a degraded match; it is a different file, and it is rejected.
48
+
49
+ Two rules keep this from becoming a way to install the wrong body:
50
+
51
+ - **A workspace location wins over a repo location.** The canonical body beats the incidental copy.
52
+ - **Content decides identity, never location.**
53
+
54
+ ### 2. What the repo already holds is a resolution signal, not a fallback
55
+
56
+ `sourceHeldLocally(skill, root)` is a first-class input to source selection, placed after explicit
57
+ flags and after the repo's stack signals, and ahead of refusing. `chooseSource`'s precedence is now:
58
+
59
+ 1. `--from` — explicit. Obeyed even when it contradicts the repo's contents; the disagreement is a
60
+ note, because the author is answering a question the tool cannot.
61
+ 2. `--variant` — explicit, same treatment.
62
+ 3. **The repo's signals name exactly one variant.** The normal path.
63
+ 4. **The repo already holds exactly one declared source.** Its bytes are an exact answer, so this
64
+ settles both an unmatched repo (step 3 found nothing) and an ambiguous one (step 3 found several).
65
+ 5. Refuse — with the reason named.
66
+
67
+ Content beats taxonomy because a byte count cannot be a coincidence. A stack tag can.
68
+
69
+ ### 3. A contradiction is a refusal; an ambiguity is a decision
70
+
71
+ Two rules that look similar and are not:
72
+
73
+ - **Signals name one variant and the repo holds another** → **refuse.** One of two inputs is wrong
74
+ and the reader needs to know which; installing either silently picks a side.
75
+ - **Signals name several variants and the repo holds one of them** → **install that one**, because
76
+ the repo's bytes are not a guess. Reported as `the repo's own copy decides`.
77
+
78
+ ### 4. A file of the right shape and the wrong body gets its own message
79
+
80
+ `simt-dev skills install` reports `<path> exists but is not the declared body (5048 bytes declared)`.
81
+ The alternative message — "none of the variants matches this repo" — is true and useless: it sends
82
+ the reader to inspect their stack tags when the actual problem is the file in front of them.
83
+
84
+ ### 5. `skills show` and `skills brief` read a repo-local body, and say which
85
+
86
+ `sourcePath` tries the canonical source first and falls back to what the repo holds.
87
+ `skills show` gained a `body <workspace|repo> <path>` line, because a reader who has two candidate
88
+ locations needs to know which one answered. `skills install` gained a matching `read` line.
89
+
90
+ An install that cannot read a body **fails**. `skills show` that cannot read one still answers,
91
+ because a catalogue plus a pointer is useful with no workspace at all. The asymmetry is deliberate
92
+ and is the same asymmetry as ADR-014's: degrade the read, never the write.
93
+
94
+ ## Consequences
95
+
96
+ - A clone of any single repository is fully self-sufficient for `skills show`, `skills brief` and
97
+ `skills install`, with no configuration at all.
98
+ - A repo holding two declared sources at once (`{ambiguous: true}`) is refused: the registry and the
99
+ repo disagree, and neither is authoritative enough to break the tie.
100
+ - `graph lint` remains the thing that proves the registry still matches the world. It is unchanged,
101
+ and it is the reason a byte comparison is trustworthy rather than optimistic.
102
+ - The `--from` and `--variant` escapes are load-bearing: they are now the only ways to override the
103
+ repo's own contents, which is correct, because overriding the evidence should be deliberate.
104
+
105
+ ## Rejected
106
+
107
+ - **Treat any existing path as a match.** A near-miss body would install silently, which is worse
108
+ than refusing: the tool would have written a file it had not verified.
109
+ - **Ask for a workspace when the repo holds the body.** The workspace would then be required for the
110
+ one case where it is provably unnecessary.
111
+ - **Select the variant from the repo's directory name.** `<root>/.agents/skills/slice-commit` says
112
+ which skill it is, not which variant it is; only its bytes do.
113
+ - **Prefer the repo's copy over the workspace's.** A workspace checkout is the canonical body, and a
114
+ symlink fan-out can make them the same file; preferring either silently would hide a divergence.
115
+ - **Make variant selection advisory everywhere.** "Signals say api, the repo holds web" would
116
+ install one of them and print nothing, which is exactly the silent divergence variants exist to
117
+ prevent.
@@ -0,0 +1,180 @@
1
+ # ADR-016 — A divergent rule is retired, not merged
2
+
3
+ Status: **accepted** · 2026-10-06 · one ruling narrowed by [ADR-045](ADR-045-a-dashboard-that-counts-a-defect.md)
4
+ Affects: `rules/mdc/*.mdc`, `projection/mdc.mjs`, `projection/project.mjs`, `projection/conflicts.mjs`, `sync`, `check`, `init`
5
+
6
+ > **Narrowed by ADR-045.** The bullet below that says `compare` reports "using `AGENTS.md`
7
+ > only" still governs the row's **display**, which is where it was aimed. It was read more
8
+ > broadly than it was written, and `compare`'s *problems list* stayed silent about stale rule
9
+ > files as a result: seventeen of them across five adopted repositories, none named. The
10
+ > problems list is not a display — it is the exit code. Every other ruling here stands, which
11
+ > is why this is a narrowing and not a supersession.
12
+
13
+ ## Context
14
+
15
+ The estate carried three always-on `.mdc` rule families across three repos, in eight
16
+ files, with no two byte-identical.
17
+
18
+ The worst pair differed by **one byte** (`simt-social/.cursor/rules/intake-questions.mdc`
19
+ at 1280 against `simt-social-api`'s at 1281) while hiding three semantically loaded line
20
+ changes: a plan-tracker path rewritten to point at the other repo, `migrations` added to the
21
+ list of decisions worth asking about, and a requirement silently dropped. No size-, hash-,
22
+ hash-adjacency- or line-count-based duplicate detector could ever have flagged that pair. The
23
+ reason is not that the detectors were weak: it is that **divergence here is not a formatting
24
+ difference, it is a semantic loss**, and the smaller copy is the one missing a rule.
25
+
26
+ Reading all eight produced three findings that a variant mechanism cannot represent:
27
+
28
+ 1. `test-gate`'s fourth `BLOCK` bullet is **complementary across the pair, not alternative**.
29
+ `simt-social` blocks a consumer that requires an API field with no backward-compatibility
30
+ or deploy-order evidence; `simt-social-api` blocks a producer that ships without a test
31
+ asserting the new invariant. Each repo's reviewer was told to check only its own half.
32
+ 2. `preview-postflight`'s second Requirements bullet is likewise complementary, and looks
33
+ like a conflict because the two files put different rules in the same slot: `simt-social`
34
+ says set `SOCIAL_API_BASE_URL` in the Cloudflare Workers Builds preview environment;
35
+ `simt-social-api` says credentials live in the operator keychain only, never in source,
36
+ chat, D1 or logs.
37
+ 3. Two deletions left no trace. `simt-social-api` dropped `simt-social`'s worked example
38
+ (`Partial pass (e.g. 6/8 pass) is a hard blocker`) — the example is what does the work,
39
+ and its removal is invisible in a diff review because the rule's normative force looks
40
+ unchanged. `simt-social-media` dropped the entire `## Allowed Without Account` section, so
41
+ it has no documented set of probes that are legal without credentials.
42
+
43
+ A variant mechanism models "these are the same rule, rendered per stack". None of these three
44
+ is that. Two are **unions**, and one is a **deletion that must be undone**.
45
+
46
+ ## Rulings
47
+
48
+ ### 1. Canonical `.mdc` rules are unions, never variants
49
+
50
+ `rules/mdc/*.mdc` holds one body per rule, carrying every clause any repo needs. Where two
51
+ copies disagreed, both clauses are present and both are unconditional. Concretely:
52
+
53
+ - `test-gate` keeps both `BLOCK` bullets and says explicitly that they are complementary, so
54
+ a reader cannot tell that checking one is sufficient.
55
+ - `preview-postflight` keeps both Requirements bullets.
56
+ - `test-gate` keeps the `6/8 pass` example, and `preview-postflight` restores
57
+ `## Allowed Without Account` with social-api's `D1 migration verification` addition.
58
+
59
+ **Why:** a variant preserves a choice. These files do not record a choice; they record three
60
+ independent accidents, and two of the eight copies are strictly weaker than the best one. A
61
+ mechanism that keeps choosing between them will keep choosing the weaker one.
62
+
63
+ ### 2. Only substrate is a substitution; only what varies per repo is declared
64
+
65
+ What genuinely differs between the copies is paths and names: a plan-tracker glob, a test
66
+ file path, a path vocabulary, a production domain, a preview Worker name. Those become
67
+ `{{placeholders}}` with **declared defaults**, in `rules/mdc/*.mdc`:
68
+
69
+ ```yaml
70
+ substitutions:
71
+ - key: plansGlob
72
+ default: docs/plans/*
73
+ describe: glob for this repo's active plan tracker tables
74
+ ```
75
+
76
+ Values live in the repo's `simt-dev.json` under `substitutions`, keyed by rule id. They are
77
+ facts about the repo, while a rule is shared by every repo that adopts it — putting them in
78
+ the rule would either hard-code one repo's paths for everyone or push the decision back into
79
+ prose, which is the drift this mechanism exists to remove.
80
+
81
+ An **empty** value, or an empty default, drops the line it sits on. That is how one canonical
82
+ file reaches `simt-social-media` (no plan-tracker note) and `simt-space` (not a Workers repo
83
+ at all) without printing a blank bullet. A placeholder with neither a default nor a
84
+ declaration is a **gate failure**, because a rule shipping a literal `{{key}}` reads as
85
+ finished and is not.
86
+
87
+ ### 3. A rule that names one product's infrastructure is opt-in, not stack-detected
88
+
89
+ `preview-postflight` declares `applies: []` and `optIn: true`. It names specific preview
90
+ Workers, `AUTH_PROVIDER_POLICY`, `POSTFLIGHT_PASSWORD_UID` and specific production domains.
91
+ Selecting it on `cloudflare-workers` would hand it to `simt-space` and `simt-goto` — Workers
92
+ repos with no preview Workers and no Social identity — which is the same over-match bug that
93
+ `rules/stacks/react-web.md` had when it declared `typescript` and `vitest`.
94
+
95
+ Detection cannot distinguish a Workers repo that has this problem from one that does not, so
96
+ the repo declares it: `"substitutions": { "__optIn": ["preview-postflight"] }`. `intake-questions`
97
+ and `test-gate` are `applies: always`, because they are genuinely stack-agnostic.
98
+
99
+ This is the first place ADR-013's fact-based detection is deliberately **not** used. A fact
100
+ answers "what is this repo made of"; opt-in answers "does this repo have this problem", which
101
+ is a different question and detection cannot reach it.
102
+
103
+ ### 4. A managed `.mdc` rule is a generated file, not a region inside a hand-owned one
104
+
105
+ `AGENTS.md` keeps its hand-written prose and receives a region. A `.mdc` rule does not: the
106
+ body after the frontmatter **is** the region, full stop. There is no hand-owned prose to
107
+ preserve, because any prose that was there is exactly the divergent copy being retired.
108
+
109
+ This was found by running it. The first implementation appended the region and produced a
110
+ file with two `## Requirements` sections and every bullet twice — the duplication the
111
+ estate's own review agent (`principal-slice-reviewer` checklist item 5) is explicitly
112
+ instructed to block, produced by the tool meant to remove it. Appending is the obvious
113
+ implementation and it is wrong here.
114
+
115
+ So `sync` keeps the existing frontmatter (the editor reads `description` and `alwaysApply`
116
+ before any rule text) and replaces everything below it. `check` compares against the same
117
+ reconstruction, or it would demand the divergent text still be present.
118
+
119
+ Because that is a destructive edit to a real file, `init` and `sync` report it:
120
+
121
+ ```
122
+ note .cursor/rules/test-gate.mdc: retiring 928 bytes of divergent hand-written rule text
123
+ ```
124
+
125
+ Silent deletion is the one thing a reviewer cannot audit.
126
+
127
+ ### 5. The conflict scanner must reach every rule surface, including ones added later
128
+
129
+ `RULE_FILES` named only `AGENTS.md`, `CLAUDE.md` and `CONTRIBUTING.md` for the whole life of
130
+ the tool. `simt-dev init` therefore reported **`CONFLICTS none detected`** on
131
+ `simt-social-api` — the repo holding the worst-drifted content in the estate — because it
132
+ never looked at the `.mdc` files at all.
133
+
134
+ A detector that only knows the filenames it was written for will quietly miss a whole rule
135
+ surface added later, and it will miss it *confidently*, reporting a clean result. Rule
136
+ discovery is now by name **and** by glob (`.cursor/rules/*.mdc`,
137
+ `.github/instructions/*.md`), and generated regions are stripped before scanning so that
138
+ quoting a ruling is never mistaken for contradicting it.
139
+
140
+ Writing that test found a second-order bug worth recording: the glob was split at its first
141
+ `*` and the tail matched as a name *prefix*, so `.cursor/rules/*.mdc` matched nothing. A
142
+ detector that silently matches zero files is indistinguishable from a repo with no rules —
143
+ the same failure, one level down.
144
+
145
+ ### 6. An autonomous-push probe must match every phrasing the estate actually used
146
+
147
+ The probe matched `committed and pushed`, which is `simt-garmin`'s exact DoD wording. A
148
+ different author wrote `Always commit and pushed`; another would write `commit and push`. The
149
+ first version matched only the first form and reported a real autonomous-push rule as clean.
150
+
151
+ The general lesson is the one this repository keeps re-learning: **probes are tested against
152
+ the estate's real text, not against text written to match the probe.**
153
+
154
+ ## Consequences
155
+
156
+ - `rules/mdc/{intake-questions,test-gate,preview-postflight}.mdc` are the canonical bodies.
157
+ Eight files in three repos become generated output.
158
+ - `simt-dev.json` gains a `substitutions` object, and the manifest generator seeds it empty
159
+ rather than leaving the key absent, so the shape is visible before anything needs it.
160
+ - `simt-dev sync` and `simt-dev check` handle four targets, not one. A malformed region in any target
161
+ refuses the whole run **before the first write** — writing the good three and skipping the
162
+ broken one would leave a repo half-adopted with no record of which half.
163
+ - `compare` continues to report one row per repo using `AGENTS.md` only, because a dashboard
164
+ row cannot represent four files. The gate uses the full finding list, so the dashboard's
165
+ coarser view is not inherited by the thing that must not be wrong.
166
+
167
+ ## Rejected
168
+
169
+ - **A variant mechanism per stack** (one `test-gate.mdc` per repo). This is what produced the
170
+ drift; modelling it as intended behaviour would make the drift permanent and legitimate.
171
+ - **Picking the "best" copy per family and adopting it as canonical.** `simt-social`'s
172
+ `test-gate` is the better copy only by accident; it is missing the producer-side `BLOCK`
173
+ bullet that social-api has. Choosing one per family would have discarded a real rule.
174
+ - **Deleting the 8 files outright** rather than generating over them. A repo with no
175
+ `intake-questions.mdc` and one with a generated one must behave identically to an agent, and
176
+ only the second is verifiable by `simt-dev check`.
177
+ - **Making `preview-postflight` apply to `cloudflare-workers`.** Hands an un-actionable rule
178
+ to two repos, and is the same bug `react-web.md` had.
179
+ - **Leaving `RULE_FILES` as a fixed list** on the grounds that the three families are the only
180
+ `.mdc` rules that exist. They are the ones that exist *today*; that is not a reason.
@@ -0,0 +1,89 @@
1
+ # ADR-017 — A declared gate must be one the repo can actually run
2
+
3
+ Status: **accepted** · 2026-10-06
4
+ Affects: `hooks/capability.mjs`, `projection/manifest.mjs`, `commands/init.mjs`, `commands/sync.mjs` (`check`), `hooks/dispatcher.mjs`
5
+
6
+ ## Context
7
+
8
+ The first real adoption wrote a manifest declaring five gates into `simt-social-media`.
9
+ Three of them could not run:
10
+
11
+ | Gate | Kind | Why it could not run |
12
+ |---|---|---|
13
+ | `format` | staged | prettier is not a dependency and is not in `node_modules/.bin` |
14
+ | `lint` | script | there is no `lint` script in `package.json` |
15
+ | `i18n-completeness` | command | nothing is declared under `gates.commands` |
16
+
17
+ It reported as healthy. `simt-dev check` was green, the projected files were current, and the
18
+ repo's own 37 tests passed. The defect was invisible to all of that because nothing had asked
19
+ whether the gates could run — the stack table proposed them from the repo's tags, and the tags
20
+ said `node` and `cloudflare-workers`, which is true.
21
+
22
+ The dispatcher would have caught it, and correctly: it fails closed. Installing it on that
23
+ manifest blocks **every commit** with `FAILED format … [ADR-004]`. So the first thing a new
24
+ user does after adopting simt-dev is discover that the tool makes committing impossible, and
25
+ the only escape any of them will find is `--no-verify`.
26
+
27
+ That is the exact spiral the dispatcher was built to prevent. Step 8's design note says a gate
28
+ that cannot run must be a failure rather than a skip, precisely because `simt-space` removed
29
+ tests from `pre-commit` for being slow and the team's answer was `--no-verify`. Failing closed
30
+ is right; declaring an unrunnable gate is wrong.
31
+
32
+ Two further facts made it survivable for as long as it was:
33
+
34
+ - **Nothing else owns the manifest.** `sync` writes the projected rules; `init` writes the
35
+ manifest. A manifest written by an older version is therefore never revisited, which is the
36
+ same class of bug as the stale `$comment` fixed in `c607d0a`.
37
+ - **`hooks status` already said so** — *"gate `i18n-completeness` in preCommit is a command
38
+ gate with no declared command; the hook will fail"*. The information existed. It was printed
39
+ by the one command nobody runs before installing hooks.
40
+
41
+ ## Rulings
42
+
43
+ 1. **A gate is declared only if the repo can run it.** `init` filters the stack table's
44
+ proposals through what the repo demonstrably has: a script that exists in `package.json`,
45
+ a binary installed in `node_modules/.bin`, a command the manifest itself names. This is
46
+ `has-*` fact discipline applied to gates (ADR-013): detection reports evidence, it does not
47
+ assume capability.
48
+
49
+ 2. **Every drop is named, with the remedy that undoes it.** A silently shorter gate list reads
50
+ as "this repo needs no formatting check", when the truth is "declaring one would block every
51
+ commit". The three ways to make a gate runnable are a real choice the repo owns, so the
52
+ reader is given all three — install the tool, add the script, or name the command.
53
+
54
+ 3. **A gate the repo already declared is never dropped.** Quietly disarming a gate somebody
55
+ declared on purpose is a worse surprise than a visible one, and a re-init that silently
56
+ removed gates would make the gate list look like simt-dev's opinion rather than the repo's.
57
+
58
+ 4. **`check` reports an unrunnable declared gate as a finding.** The repo must be red *before*
59
+ anyone installs hooks, not after. `check` is the gate that runs in CI, so this is where a
60
+ landmine belongs; `hooks status` saying so is not enough when it is the command nobody runs.
61
+
62
+ 5. **The remedy differs by whether a manifest already exists.** A re-init never re-adds a gate
63
+ to a manifest that already declares one, so on an existing manifest "install prettier, then
64
+ re-run init" would send the reader round the loop and back to where they started. The remedy
65
+ there names the declaration as the second half of the fix.
66
+
67
+ ## Consequences
68
+
69
+ - `simt-dev init` needs to read the repo before it writes the manifest, so `init` now depends on
70
+ the hooks subsystem. The dependency runs one way: `gates.mjs` imports nothing.
71
+ - `check` is no longer only about the generated rules. It is the gate over what the manifest
72
+ promises, which is what a gate is for.
73
+ - A repo whose only answer is "no tool for any of these" gets `preCommit: []`. That is a valid
74
+ manifest, and the dispatcher says so in one line rather than passing in silence. A string
75
+ where the caller expected an array once printed that line one character at a time.
76
+
77
+ ## Rejected
78
+
79
+ - **Fail open in the dispatcher when a gate cannot run.** The spiral is the point; failing open
80
+ makes the tool's green meaningless, which is worse than a blocked commit because it is silent.
81
+ - **Infer capability from the dependency graph.** A repo with `prettier` in `devDependencies`
82
+ and no `node_modules` cannot run a formatter. Reading the manifest and the installed binaries
83
+ are two facts; guessing a third from a lockfile is how the two get to disagree.
84
+ - **Have `init` refuse to adopt a repo that cannot run its gates.** Refusing is worse than
85
+ writing fewer gates: the repo gets no rules at all, and the obstacle is a formatting tool.
86
+ - **Leave it to `hooks status`.** It is the command run after installing hooks, which is one
87
+ step after the commit that fails.
88
+ - **Drop unrunnable gates on re-init too.** Silently; the reader sees a shorter list and no
89
+ explanation, and cannot tell a removed gate from one that was never there.